Ver código fonte

Printer card: a spool swapped in mid-print shows (issue #3434)

- After a runout, a spool loaded into the same slot and assigned in
  Bambuddy stayed invisible: the printer reports the slot loaded but
  blank and refuses a filament setting for the slot it prints from,
  so the card showed "?" / "No filament assigned".
- Such a slot with an assignment now draws the spool's colour with an
  amber marker; the hover card names the spool, offers Unassign and
  says the printer has not taken it yet. AMS, AMS-HT and external
  slots, both inventory modes.
- The deferred configure is held for the slot feeding a running print
  (tray_now or an extruder's fed slot) instead of being sent, and
  refused, on every AMS change; it goes out once the print is over.
maziggy 1 dia atrás
pai
commit
1cc7f4f18a
55 arquivos alterados com 304 adições e 21 exclusões
  1. 1 0
      CHANGELOG.md
  2. 41 0
      backend/app/main.py
  3. 69 0
      backend/tests/integration/test_preconfig_replay_failure.py
  4. 70 0
      frontend/src/__tests__/pages/PrintersPageAmsSlotActionsWiring.test.tsx
  5. 33 6
      frontend/src/components/FilamentHoverCard.tsx
  6. 11 4
      frontend/src/components/FilamentSlotCircle.tsx
  7. 1 0
      frontend/src/i18n/locales/de.ts
  8. 1 0
      frontend/src/i18n/locales/en.ts
  9. 1 0
      frontend/src/i18n/locales/es.ts
  10. 1 0
      frontend/src/i18n/locales/fr.ts
  11. 1 0
      frontend/src/i18n/locales/it.ts
  12. 1 0
      frontend/src/i18n/locales/ja.ts
  13. 2 1
      frontend/src/i18n/locales/ko.ts
  14. 1 0
      frontend/src/i18n/locales/nl.ts
  15. 1 0
      frontend/src/i18n/locales/pl.ts
  16. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  17. 1 0
      frontend/src/i18n/locales/ru.ts
  18. 1 0
      frontend/src/i18n/locales/sv.ts
  19. 1 0
      frontend/src/i18n/locales/tr.ts
  20. 1 0
      frontend/src/i18n/locales/uk.ts
  21. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  22. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  23. 59 7
      frontend/src/pages/PrintersPage.tsx
  24. 0 0
      static/assets/ArchivesPage-Bvao6g1Y.js
  25. 0 0
      static/assets/CamWallPage-BmRe-4dW.js
  26. 0 0
      static/assets/CameraPage-Db6X0u7B.js
  27. 0 0
      static/assets/FileManagerPage-Cq_RTlFr.js
  28. 0 0
      static/assets/InventoryPage-Dmf1gZKq.js
  29. 0 0
      static/assets/Layout-DfVNWALj.js
  30. 0 0
      static/assets/ModelSourcesPage-CVCWs-AL.js
  31. 0 0
      static/assets/PrintersPage-NIcB4mvt.js
  32. 0 0
      static/assets/SettingsPage-CfV1ZkS7.js
  33. 0 0
      static/assets/SliceModal-B2ir_BbQ.js
  34. 0 0
      static/assets/SpoolBuddyInventoryPage-DpcIjW8T.js
  35. 1 1
      static/assets/SpoolBuddySettingsPage-CrgCjjlZ.js
  36. 0 0
      static/assets/de-ByueOBi-.js
  37. 0 0
      static/assets/es-DBm3QfJb.js
  38. 0 0
      static/assets/fr-BTR_v_4-.js
  39. 0 0
      static/assets/index-DpPJ-Y1g.js
  40. 0 0
      static/assets/it-jsh4ZvmH.js
  41. 0 0
      static/assets/ja-BHbxlRB0.js
  42. 0 0
      static/assets/ko-BLKFcdsO.js
  43. 0 0
      static/assets/locationSensorDefaults-BVjEatCz.js
  44. 0 0
      static/assets/nl-CTbrfxvQ.js
  45. 0 0
      static/assets/pl-mxkn-LY2.js
  46. 0 0
      static/assets/pt-BR-BMDLPp6H.js
  47. 0 0
      static/assets/ru-B6u8fMes.js
  48. 0 0
      static/assets/spoolCost-COrTHDZN.js
  49. 0 0
      static/assets/sv-DiH5arxY.js
  50. 0 0
      static/assets/tr-UtkHEmL0.js
  51. 0 0
      static/assets/uk-Cj0Gzbyh.js
  52. 0 0
      static/assets/usePageFileDrop-cEZBk3mQ.js
  53. 0 0
      static/assets/zh-CN-DtIJgfrV.js
  54. 0 0
      static/assets/zh-TW-E1bUYRfv.js
  55. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -217,6 +217,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A spool swapped in mid-print shows on the printer card (#3434, reported by @0xCyberFortress)** — After a runout, a new spool loaded into the same slot and assigned in Bambuddy stayed invisible until the print ended: the printer reports the slot loaded but blank and refuses a filament setting for the slot it is printing from, so the card showed "?" and "No filament assigned". The card now shows the assigned spool on such a slot in its colour, with an amber marker, names it and offers Unassign, saying that the printer has not taken it yet. Bambuddy also stops sending that setting on every AMS change while the slot feeds the print, which the printer refused each time, and sends it once the print is over.
 - **Spoolman: a Bambu spool keeps its filament preset (#3433, reported by @ojimpo)** — In Spoolman mode, a spool the AMS sync created from a Bambu RFID tray was stored without the preset the tray reports, which the built-in inventory keeps. Assigned later to a slot without a reader, such as the external spool, it was written as the generic filament for its material: a Bambu PLA Basic became Generic PLA. The sync now stores the tray's preset on a new spool and fills it in on an existing Bambu spool that has none (a preset set by hand stays). And a Spoolman spool without a preset keeps the slot's current filament when it fits, as in built-in mode, before falling back to the generic one.
 - **A camera opened from a printer card stays on the Printers page (#2737)** — Since cameras could be opened from any page, every camera overlay was shown on every page, also one opened from a printer card or the Cam Wall, which followed you around the app. Only a camera opened with the sidebar's **Cameras** button is shown everywhere now. One opened from a card or the Cam Wall is hidden on the other pages, still open, and back on returning to Printers; opening it from the **Cameras** button too puts it on every page. Each camera remembers which it is across a reload.
 - **Spoolman mode no longer shows slots from the built-in inventory (#3430, reported by @sean3318)** — Since switching inventory modes keeps both modes' slot assignments, the Inventory page in Spoolman mode also read the built-in inventory's and gave them priority. Its spool numbers are its own, so a built-in assignment kept from before the switch put a printer slot on whichever Spoolman spool had the same number, and nothing in Spoolman mode could clear it: a spool taken out of the AMS kept its slot. Spoolman mode now only uses Spoolman's slot assignments: on the Inventory page, on the printer card and in SpoolBuddy (a slot's fill level came from the built-in spool too), also while Spoolman is unreachable, when the printer card offered to assign and unassign built-in spools. The built-in assignments are kept for when you switch back.

+ 41 - 0
backend/app/main.py

@@ -2732,6 +2732,28 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
         logger.warning("[Printer %s] Could not re-apply K-profiles after inlet move: %s", printer_id, e)
 
 
+def _slot_feeds_print(printer_id: int, ams_id: int, tray_id: int) -> bool:
+    """Is this slot the one feeding the printer right now?
+
+    ``tray_now`` names one slot for the whole printer, so on a dual-nozzle
+    machine each extruder's own fed slot is asked as well. Only meaningful
+    while a print runs; the caller checks that.
+    """
+    status = printer_manager.get_status(printer_id)
+    if status is None:
+        return False
+    if ams_id == 255:
+        global_id = 254 + tray_id
+    elif ams_id >= 128:
+        global_id = ams_id
+    else:
+        global_id = ams_id * 4 + tray_id
+    if getattr(status, "tray_now", None) == global_id:
+        return True
+    slots = getattr(status, "extruder_slots", None) or {}
+    return any(slot.has_filament and slot.holds(ams_id, tray_id) for slot in slots.values())
+
+
 async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_now: bool) -> None:
     """Unlink built-in inventory assignments whose slot no longer holds their spool.
 
@@ -2898,6 +2920,25 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                     loaded = spool_present(current_tray) is True or (
                         cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
                     )
+                    # Not into the slot feeding a running print: the printer refuses
+                    # the setting for it (``ams_filament_setting refused``), and a
+                    # spool swapped in after a runout sat through one refused send
+                    # per AMS change. Held, it fires on the first change once the
+                    # print is over (#3434).
+                    if (
+                        not fp_type.strip()
+                        and loaded
+                        and assignment.spool
+                        and printing_now
+                        and _slot_feeds_print(printer_id, assignment.ams_id, assignment.tray_id)
+                    ):
+                        logger.debug(
+                            "Pre-config held for spool %d AMS%d-T%d: the slot is feeding the print",
+                            assignment.spool_id,
+                            assignment.ams_id,
+                            assignment.tray_id,
+                        )
+                        continue
                     if not fp_type.strip() and loaded and assignment.spool:
                         # The fingerprint ends this replay. Not recorded when nothing
                         # could be sent (no MQTT client while the printer reconnects),

+ 69 - 0
backend/tests/integration/test_preconfig_replay_failure.py

@@ -69,3 +69,72 @@ async def test_a_configured_slot_records_its_fingerprint(async_client, db_sessio
     apply = AsyncMock(return_value=True)
 
     assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == ("PLA", "FF0000FF")
+
+
+async def _replay(db_session, printer_factory, spool_factory, *, state, tray_now=255, extruder_slots=None):
+    """One AMS change for a pre-assigned AMS 2 slot 3 (global tray 11) that now holds filament."""
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory(name="A1 Mini")
+    spool = await spool_factory(slicer_filament="GFL99", material="PLA")
+    assignment = SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=2, tray_id=3)
+    db_session.add(assignment)
+    await db_session.commit()
+    ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
+    status = _make_mock_status(ams_data=ams_data)
+    status.state = state
+    status.tray_now = tray_now
+    status.extruder_slots = extruder_slots or {}
+    apply = AsyncMock(return_value=True)
+
+    with (
+        patch("backend.app.api.routes.inventory.apply_spool_to_slot_via_mqtt", apply),
+        patch("backend.app.main.printer_manager") as pm,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+    ):
+        pm.get_printer.return_value = MagicMock(name="A1 Mini", serial_number="0309CA000000001")
+        pm.get_status.return_value = status
+        pm.get_client.return_value = MagicMock()
+        pm.get_model.return_value = "A1 Mini"
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        await on_ams_change(printer.id, ams_data)
+
+    await db_session.refresh(assignment)
+    return apply, assignment
+
+
+class TestReplayIntoTheSlotFeedingAPrint:
+    """#3434: a spool swapped in after a runout is assigned while the print runs
+    from that slot. The printer refuses a setting for it then, and the replay
+    sent one per AMS change for the rest of the print."""
+
+    async def test_held_while_the_slot_feeds_the_print(self, async_client, db_session, printer_factory, spool_factory):
+        apply, assignment = await _replay(db_session, printer_factory, spool_factory, state="RUNNING", tray_now=11)
+        apply.assert_not_awaited()
+        # Nothing recorded, so the first AMS change after the print still replays.
+        assert assignment.fingerprint_type is None
+
+    async def test_held_for_a_slot_an_extruder_is_fed_from(
+        self, async_client, db_session, printer_factory, spool_factory
+    ):
+        """Dual nozzle: tray_now names the other hotend's slot."""
+        from backend.app.services.bambu_mqtt import ExtruderSlot
+
+        slots = {0: ExtruderSlot(ams_id=0, slot_id=0, has_filament=True), 1: ExtruderSlot(2, 3, True)}
+        apply, _ = await _replay(
+            db_session, printer_factory, spool_factory, state="RUNNING", tray_now=0, extruder_slots=slots
+        )
+        apply.assert_not_awaited()
+
+    async def test_another_slot_is_configured_mid_print(self, async_client, db_session, printer_factory, spool_factory):
+        apply, assignment = await _replay(db_session, printer_factory, spool_factory, state="RUNNING", tray_now=0)
+        apply.assert_awaited_once()
+        assert assignment.fingerprint_type == "PLA"
+
+    async def test_configured_once_the_print_is_over(self, async_client, db_session, printer_factory, spool_factory):
+        apply, assignment = await _replay(db_session, printer_factory, spool_factory, state="FINISH", tray_now=11)
+        apply.assert_awaited_once()
+        assert assignment.fingerprint_type == "PLA"

+ 70 - 0
frontend/src/__tests__/pages/PrintersPageAmsSlotActionsWiring.test.tsx

@@ -513,3 +513,73 @@ describe('AMS slot: Configure dialog', () => {
     else expect(reset).toBeEnabled();
   });
 });
+
+describe('A spool on a slot the printer reports loaded but blank (#3434)', () => {
+  beforeEach(() => {
+    permissions.denied = new Set();
+  });
+
+  // Slot 1 is loaded (presence bit) but blank: a spool swapped in mid-print,
+  // which the printer takes no configuration for until the print is over.
+  const blank = { ...baseTray, tray_type: '', tray_color: '', tray_sub_brands: '', tray_info_idx: '', state: 9, exists: true };
+  async function emptySlots() {
+    await waitFor(() => expect(screen.getAllByTestId('empty-slot').length).toBeGreaterThan(0));
+    return screen.getAllByTestId('empty-slot');
+  }
+  const withBlankSlots = (slot2: Record<string, unknown> = blank) =>
+    statusWith([amsUnit({ tray: [{ id: 0, ...baseTray }, { id: 1, ...blank }, { id: 2, ...slot2 }, { id: 3, ...baseTray }] })]);
+
+  it('built-in: the card shows the assigned spool in its colour, and unassigns it', async () => {
+    const user = userEvent.setup();
+    const sent = setup({ status: withBlankSlots() });
+    const slot = (await emptySlots())[0];
+
+    expect(slot.querySelector('[data-pending-config="true"]')).not.toBeNull();
+    expect(slot.textContent).toContain('PLA');
+
+    await user.click(await hover(slot, 'Unassign'));
+    expect(screen.queryByText('Generic PLA Basic (Red)')).toBeNull(); // the card closed on click
+    await waitFor(() =>
+      expect(sent).toEqual([
+        { method: 'DELETE', path: `/api/v1/inventory/assignments/${PRINTER_ID}/0/1`, search: '', body: null },
+      ])
+    );
+  });
+
+  it('names the spool and says the printer has not taken it yet', async () => {
+    setup({ status: withBlankSlots() });
+    await hover((await emptySlots())[0], 'Unassign');
+    expect(screen.getByText('Generic PLA Basic (Red)')).toBeInTheDocument();
+    expect(screen.getByText(/not configured on the printer yet/)).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /Assign Spool/ })).toBeNull();
+  });
+
+  it('Spoolman mode: the Spoolman spool, unassigned by its id', async () => {
+    const user = userEvent.setup();
+    const sent = setup({ status: withBlankSlots(), spoolman: true });
+    const slot = (await emptySlots())[0];
+    await user.click(await hover(slot, 'Unassign'));
+    await waitFor(() =>
+      expect(sent).toEqual([
+        { method: 'DELETE', path: '/api/v1/spoolman/inventory/slot-assignments/55', search: '', body: null },
+      ])
+    );
+  });
+
+  it('a blank slot without an assignment still says no filament is assigned', async () => {
+    setup({ status: withBlankSlots() });
+    const slot = (await emptySlots())[1];
+    expect(slot.querySelector('[data-pending-config="true"]')).toBeNull();
+    await hover(slot, 'Assign Spool');
+    expect(screen.getByText('No filament assigned')).toBeInTheDocument();
+  });
+
+  it('a slot the printer reports empty shows no spool, assignment or not', async () => {
+    // The row is on slot 1; reported physically empty there, nothing is in it.
+    setup({
+      status: statusWith([amsUnit({ tray: [{ id: 0, ...baseTray }, { id: 1, ...blank, exists: false }, { id: 2, ...baseTray }, { id: 3, ...baseTray }] })]),
+    });
+    const slot = (await emptySlots())[0];
+    expect(slot.querySelector('[data-pending-config="true"]')).toBeNull();
+  });
+});

+ 33 - 6
frontend/src/components/FilamentHoverCard.tsx

@@ -592,9 +592,13 @@ interface EmptySlotHoverCardProps {
   // surfaces the user-cleared label; undefined / "physical" keeps the
   // historical "Empty slot" wording.
   kind?: 'physical' | 'reset';
+  /** A spool assigned in Bambuddy to a slot the printer reports loaded but
+   *  blank -- typically one swapped in mid-print, which the printer will not
+   *  take a configuration for until the print is over (#3434). */
+  assigned?: { label: string; colorHex?: string | null; onUnassign?: () => void };
 }
 
-export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, actions, kind }: EmptySlotHoverCardProps) {
+export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, actions, kind, assigned }: EmptySlotHoverCardProps) {
   const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   // Screen-space coords for the portaled card — same pattern as
@@ -657,6 +661,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
     <div
       ref={triggerRef}
       className={`relative ${className}`}
+      data-testid="empty-slot"
       onMouseEnter={handleMouseEnter}
       onMouseLeave={handleMouseLeave}
     >
@@ -678,18 +683,40 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
             bg-bambu-dark-secondary border border-bambu-dark-tertiary
             rounded-md shadow-lg overflow-hidden
           ">
-            <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
-              {kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
-            </div>
+            {assigned ? (
+              <div className="px-3 py-1.5 text-xs space-y-1">
+                <div className="flex items-center gap-1.5 text-white whitespace-nowrap">
+                  <span
+                    className="w-2.5 h-2.5 rounded-full border border-amber-500 border-dashed flex-shrink-0"
+                    style={{ backgroundColor: assigned.colorHex ? `#${assigned.colorHex.slice(0, 6)}` : 'transparent' }}
+                  />
+                  {assigned.label}
+                </div>
+                <div className="text-amber-400 max-w-[14rem]">{t('ams.assignedNotConfigured')}</div>
+              </div>
+            ) : (
+              <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
+                {kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
+              </div>
+            )}
             {/* Configure slot button */}
-            {(configureSlot?.enabled || onAssignSpool || actions) && (
+            {(configureSlot?.enabled || onAssignSpool || assigned?.onUnassign || actions) && (
               <div className="px-2 pb-2 space-y-1">
                 {/* Assign before Configure, matching the filled-slot card
                     above (#2791).  The two cards are separate render paths
                     and had drifted into opposite orders, so the menu
                     reshuffled itself depending on whether the slot happened
                     to hold filament. */}
-                {onAssignSpool && (
+                {assigned?.onUnassign && (
+                  <button
+                    onClick={(e) => { e.stopPropagation(); dismiss(); assigned.onUnassign?.(); }}
+                    className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/40 text-red-700 dark:text-red-400"
+                  >
+                    <Unlink className="w-3.5 h-3.5" />
+                    {t('inventory.unassignSpool')}
+                  </button>
+                )}
+                {onAssignSpool && !assigned && (
                   <button
                     onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
                     className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/40 text-bambu-blue"

+ 11 - 4
frontend/src/components/FilamentSlotCircle.tsx

@@ -13,6 +13,9 @@
  *                confirmed no spool (state 9/10), "reset" for slots where
  *                the user cleared the assignment but the firmware hasn't
  *                positively confirmed emptiness. Ignored when isEmpty is false.
+ *   pendingColor - Colour of a spool assigned in Bambuddy to a slot the
+ *                printer reports loaded but blank (#3434). Drawn with an amber
+ *                dashed border: assigned, not configured on the printer yet.
  *   slotNumber - 1-based slot number to display inside the circle. Accepts
  *                a string for non-numeric labels (e.g. "L" / "R" for the
  *                dual-nozzle external trays, where carrying a separate
@@ -24,6 +27,7 @@ interface FilamentSlotCircleProps {
   trayType?: string | null;
   isEmpty: boolean;
   emptyKind?: 'physical' | 'reset' | null;
+  pendingColor?: string | null;
   slotNumber: number | string;
 }
 
@@ -35,23 +39,26 @@ function isLightFilamentColor(hex: string): boolean {
   return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
 }
 
-export function FilamentSlotCircle({ trayColor, trayType, isEmpty, emptyKind, slotNumber }: FilamentSlotCircleProps) {
+export function FilamentSlotCircle({ trayColor, trayType, isEmpty, emptyKind, pendingColor, slotNumber }: FilamentSlotCircleProps) {
   // Reset slots get a quieter border than physical-empty so they read as
   // "cleared but possibly still has a spool the firmware hasn't confirmed
   // gone" rather than "definitely no spool".
   const emptyBorderColor = emptyKind === 'reset' ? '#3d3d3d' : '#666';
+  const pending = isEmpty && pendingColor ? pendingColor.slice(0, 6) : null;
+  const fill = pending ?? trayColor;
   return (
     <div
       className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center"
+      data-pending-config={pending ? 'true' : undefined}
       style={{
-        backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),
-        borderColor: isEmpty ? emptyBorderColor : 'rgba(255,255,255,0.1)',
+        backgroundColor: fill ? `#${fill}` : (trayType ? '#333' : 'transparent'),
+        borderColor: pending ? '#f59e0b' : isEmpty ? emptyBorderColor : 'rgba(255,255,255,0.1)',
         borderStyle: isEmpty ? 'dashed' : 'solid',
       }}
     >
       <span
         className="text-[6px] font-bold leading-none select-none"
-        style={{ color: trayColor && isLightFilamentColor(trayColor) ? '#000' : '#fff' }}
+        style={{ color: fill && isLightFilamentColor(fill) ? '#000' : '#fff' }}
       >
         {slotNumber}
       </span>

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -5580,6 +5580,7 @@ export default {
     slotEmpty: 'Leer',
     slotUnconfigured: '?',
     emptySlotReset: 'Keine Spule zugewiesen',
+    assignedNotConfigured: 'In Bambuddy zugewiesen, am Drucker noch nicht eingerichtet. Der Drucker übernimmt die Einstellung, sobald der Slot frei ist, nach dem Druck.',
     unknown: 'Unbekannt',
     humidity: 'Luftfeuchtigkeit',
     temperature: 'Temperatur',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -5627,6 +5627,7 @@ export default {
     slotEmpty: 'Empty',
     slotUnconfigured: '?',
     emptySlotReset: 'No filament assigned',
+    assignedNotConfigured: 'Assigned in Bambuddy, not configured on the printer yet. The printer takes the setting once the slot is free, after the print.',
     unknown: 'Unknown',
     humidity: 'Humidity',
     temperature: 'Temperature',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -5587,6 +5587,7 @@ export default {
     slotEmpty: 'Vacía',
     slotUnconfigured: '?',
     emptySlotReset: 'No hay filamento asignado',
+    assignedNotConfigured: 'Asignado en Bambuddy, aún sin configurar en la impresora. La impresora acepta el ajuste cuando la ranura queda libre, después de la impresión.',
     unknown: 'Desconocido',
     humidity: 'Humedad',
     temperature: 'Temperatura',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -5567,6 +5567,7 @@ export default {
     slotEmpty: 'Vide',
     slotUnconfigured: '?',
     emptySlotReset: 'Aucune bobine assignée',
+    assignedNotConfigured: 'Assignée dans Bambuddy, pas encore configurée sur l\'imprimante. L\'imprimante prend le réglage dès que l\'emplacement est libre, après l\'impression.',
     unknown: 'Inconnu',
     humidity: 'Humidité',
     temperature: 'Température',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -5566,6 +5566,7 @@ export default {
     slotEmpty: 'Vuoto',
     slotUnconfigured: '?',
     emptySlotReset: 'Nessuna bobina assegnata',
+    assignedNotConfigured: 'Assegnata in Bambuddy, non ancora configurata sulla stampante. La stampante accetta l\'impostazione quando lo slot è libero, dopo la stampa.',
     unknown: 'Sconosciuto',
     humidity: 'Umidità',
     temperature: 'Temperatura',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -5580,6 +5580,7 @@ export default {
     slotEmpty: '空',
     slotUnconfigured: '?',
     emptySlotReset: 'スプール未割当',
+    assignedNotConfigured: 'Bambuddyで割当済み、プリンターにはまだ設定されていません。印刷後、スロットが空くとプリンターに設定されます。',
     unknown: '不明',
     humidity: '湿度',
     temperature: '温度',

+ 2 - 1
frontend/src/i18n/locales/ko.ts

@@ -5368,7 +5368,8 @@ export default {
     remainingUnit: '남음',
     slotEmpty: '비어 있음',
     slotUnconfigured: '?',
-    emptySlotReset: '필라멘트가 할당되지 않음'
+    emptySlotReset: '필라멘트가 할당되지 않음',
+    assignedNotConfigured: 'Bambuddy에서 할당됨, 프린터에는 아직 설정되지 않음. 출력이 끝나 슬롯이 비면 프린터에 설정됩니다.'
   },
   printModal: {
     variants: {

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -5625,6 +5625,7 @@ export default {
     slotEmpty: 'Leeg',
     slotUnconfigured: '?',
     emptySlotReset: 'Geen filament toegewezen',
+    assignedNotConfigured: 'Toegewezen in Bambuddy, nog niet ingesteld op de printer. De printer neemt de instelling over zodra het slot vrij is, na de print.',
     unknown: 'Onbekend',
     humidity: 'Luchtvochtigheid',
     temperature: 'Temperatuur',

+ 1 - 0
frontend/src/i18n/locales/pl.ts

@@ -5627,6 +5627,7 @@ export default {
     slotEmpty: 'Pusty',
     slotUnconfigured: '?',
     emptySlotReset: 'Nie przypisano filamentu',
+    assignedNotConfigured: 'Przypisano w Bambuddy, jeszcze nie skonfigurowano na drukarce. Drukarka przyjmie ustawienie, gdy slot będzie wolny, po wydruku.',
     unknown: 'Nieznany',
     humidity: 'Wilgotność',
     temperature: 'Temperatura',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5566,6 +5566,7 @@ export default {
     slotEmpty: 'Vazio',
     slotUnconfigured: '?',
     emptySlotReset: 'Nenhum carretel atribuído',
+    assignedNotConfigured: 'Atribuído no Bambuddy, ainda não configurado na impressora. A impressora aceita a configuração quando o slot fica livre, após a impressão.',
     unknown: 'Desconhecido',
     humidity: 'Umidade',
     temperature: 'Temperatura',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -5341,6 +5341,7 @@ export default {
     slotEmpty: "Пусто",
     slotUnconfigured: "?",
     emptySlotReset: "Филамент не назначен",
+    assignedNotConfigured: "Назначено в Bambuddy, на принтере ещё не настроено. Принтер примет настройку, когда слот освободится, после печати.",
     unknown: "Неизвестно",
     humidity: "Влажность",
     temperature: "Температура",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -5623,6 +5623,7 @@ errors: {
     slotEmpty: 'Tomt',
     slotUnconfigured: '?',
     emptySlotReset: 'Inget filament tilldelat',
+    assignedNotConfigured: 'Tilldelat i Bambuddy, inte inställt på skrivaren än. Skrivaren tar emot inställningen när facket är ledigt, efter utskriften.',
     unknown: 'Okänd',
     humidity: 'Luftfuktighet',
     temperature: 'Temperatur',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -5556,6 +5556,7 @@ export default {
     slotEmpty: 'Boş',
     slotUnconfigured: '?',
     emptySlotReset: 'Atanmış filament yok',
+    assignedNotConfigured: 'Bambuddy\'de atandı, yazıcıda henüz ayarlanmadı. Yazıcı, baskıdan sonra yuva boşaldığında ayarı alır.',
     unknown: 'Bilinmiyor',
     humidity: 'Nem',
     temperature: 'Sıcaklık',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -5620,6 +5620,7 @@ export default {
     slotEmpty: "Порожній",
     slotUnconfigured: "?",
     emptySlotReset: "Філамент не призначено",
+    assignedNotConfigured: "Призначено в Bambuddy, на принтері ще не налаштовано. Принтер прийме налаштування, коли слот звільниться, після друку.",
     unknown: "Невідомий",
     humidity: "Вологість",
     temperature: "температура",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5566,6 +5566,7 @@ export default {
     slotEmpty: '空',
     slotUnconfigured: '?',
     emptySlotReset: '未分配料盘',
+    assignedNotConfigured: '已在 Bambuddy 中分配,打印机上尚未设置。打印结束、料槽空闲后打印机会接收该设置。',
     unknown: '未知',
     humidity: '湿度',
     temperature: '温度',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5566,6 +5566,7 @@ export default {
     slotEmpty: '空',
     slotUnconfigured: '?',
     emptySlotReset: '未指派線材',
+    assignedNotConfigured: '已在 Bambuddy 中指派,印表機上尚未設定。列印結束、槽位空出後印表機會接收該設定。',
     unknown: '未知',
     humidity: '濕度',
     temperature: '溫度',

+ 59 - 7
frontend/src/pages/PrintersPage.tsx

@@ -209,6 +209,25 @@ import { getCurrencySymbol } from '../utils/currency';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { isSlicedLibraryFile, mayBeSlicedFileName } from '../utils/libraryFiles';
 
+/** The spool Bambuddy has assigned to a slot the printer reports loaded but
+ *  blank: typically one swapped in mid-print after a runout, which the printer
+ *  takes no configuration for until the print is over (#3434). The card shows
+ *  it, in the spool's colour, instead of a bare "?". */
+function pendingSlotSpool(
+  emptyKind: 'physical' | 'reset' | null,
+  spool: { brand?: string | null; material?: string | null; subtype?: string | null; color_name?: string | null; rgba?: string | null } | null | undefined,
+  onUnassign: (() => void) | undefined,
+): { label: string; colorHex: string | null; material: string; onUnassign?: () => void } | null {
+  if (emptyKind !== 'reset' || !spool) return null;
+  const name = [spool.brand, spool.material, spool.subtype].filter(Boolean).join(' ');
+  return {
+    label: spool.color_name ? `${name} (${spool.color_name})` : name,
+    colorHex: spool.rgba ?? null,
+    material: spool.material ?? '',
+    onUnassign,
+  };
+}
+
 // The status filter's options, and the only values it may hold. One list so a
 // saved filter cannot be validated against a set the dropdown has since moved
 // on from (#2833). Labels stay literal so they remain greppable.
@@ -5634,10 +5653,19 @@ function PrinterCard({
                                 const activePrintSlotLabel = activePrintSlotIdx >= 0
                                   ? `P${activePrintSlotIdx + 1}`
                                   : null;
+                                const pending = isEmpty
+                                  ? pendingSlotSpool(
+                                      emptyKind,
+                                      spoolmanEnabled ? slotSpoolForFill : inventoryAssignment?.spool,
+                                      spoolmanEnabled
+                                        ? (slotSpoolForFill ? () => onUnassignSpoolmanSpool?.(slotSpoolForFill.id) : undefined)
+                                        : (inventoryAssignment ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined),
+                                    )
+                                  : null;
                                 // Slot visual content (goes inside hover card)
                                 const slotVisual = (
                                   <div
-                                    className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${
+                                    className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty && !pending ? 'opacity-50' : ''} ${
                                       isExpectedSlot
                                         ? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-bambu-dark animate-pulse'
                                         : isRanOutSlot
@@ -5671,10 +5699,11 @@ function PrinterCard({
                                       trayType={tray?.tray_type}
                                       isEmpty={isEmpty}
                                       emptyKind={emptyKind}
+                                      pendingColor={pending?.colorHex}
                                       slotNumber={slotIdx + 1}
                                     />
                                     <div className="text-[length:var(--pc-t9,9px)] text-white font-bold truncate">
-                                      {tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
+                                      {tray?.tray_type || pending?.material || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
                                     </div>
                                     <KValueLine k={filamentData ? tray?.k : null} reserve={anySlotHasKValue} />
                                     {/* Fill bar */}
@@ -5812,6 +5841,7 @@ function PrinterCard({
                                     ) : (
                                       <EmptySlotHoverCard
                                         kind={emptyKind ?? undefined}
+                                        assigned={pending ?? undefined}
                                         actions={renderAmsSlotActions({
                                           amsId: ams.id,
                                           slotId: slotIdx,
@@ -5928,10 +5958,19 @@ function PrinterCard({
                         const htActivePrintSlotLabel = htActivePrintSlotIdx >= 0
                           ? `P${htActivePrintSlotIdx + 1}`
                           : null;
+                        const pending = isEmpty
+                          ? pendingSlotSpool(
+                              emptyKind,
+                              spoolmanEnabled ? htSlotSpoolForFill : htInventoryAssignment?.spool,
+                              spoolmanEnabled
+                                ? (htSlotSpoolForFill ? () => onUnassignSpoolmanSpool?.(htSlotSpoolForFill.id) : undefined)
+                                : (htInventoryAssignment ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined),
+                            )
+                          : null;
                         // Slot visual content (goes inside hover card)
                         const slotVisual = (
                           <div
-                            className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${
+                            className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty && !pending ? 'opacity-50' : ''} ${
                               isExpectedSlot
                                 ? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-bambu-dark animate-pulse'
                                 : isRanOutSlot
@@ -5965,10 +6004,11 @@ function PrinterCard({
                               trayType={tray?.tray_type}
                               isEmpty={isEmpty}
                               emptyKind={emptyKind}
+                              pendingColor={pending?.colorHex}
                               slotNumber={1}
                             />
                             <div className="text-[length:var(--pc-t9,9px)] text-white font-bold truncate">
-                              {tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
+                              {tray?.tray_type || pending?.material || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
                             </div>
                             <KValueLine k={filamentData ? tray?.k : null} reserve={anySlotHasKValue} />
                             {/* Fill bar */}
@@ -6218,6 +6258,7 @@ function PrinterCard({
                                 ) : (
                                   <EmptySlotHoverCard
                                     kind={emptyKind ?? undefined}
+                                    assigned={pending ?? undefined}
                                     actions={renderAmsSlotActions({
                                       amsId: ams.id,
                                       slotId: htSlotId,
@@ -6360,8 +6401,17 @@ function PrinterCard({
 
                               const isEmpty = !extTray.tray_type;
                               const emptyKind = getEmptySlotKind(extTray);
+                              const pending = isEmpty
+                                ? pendingSlotSpool(
+                                    emptyKind,
+                                    spoolmanEnabled ? extSlotSpoolForFill : extInventoryAssignment?.spool,
+                                    spoolmanEnabled
+                                      ? (extSlotSpoolForFill ? () => onUnassignSpoolmanSpool?.(extSlotSpoolForFill.id) : undefined)
+                                      : (extInventoryAssignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined),
+                                  )
+                                : null;
                               const extSlotContent = (
-                                <div className={`w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
+                                <div className={`w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty && !pending ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
                                   {/* Color circle: L/R inside on dual-nozzle external (replaces
                                       the separate Ext-L/Ext-R caption that made the row taller than
                                       regular AMS slots), 1-based slot number on single-nozzle. */}
@@ -6370,10 +6420,11 @@ function PrinterCard({
                                     trayType={extTray.tray_type}
                                     isEmpty={isEmpty}
                                     emptyKind={emptyKind}
+                                    pendingColor={pending?.colorHex}
                                     slotNumber={isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : slotTrayId + 1}
                                   />
-                                  <div className={`text-[length:var(--pc-t9,9px)] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
-                                    {extTray.tray_type || t('ams.slotEmpty')}
+                                  <div className={`text-[length:var(--pc-t9,9px)] font-bold truncate ${isEmpty && !pending ? 'text-white/40' : 'text-white'}`}>
+                                    {extTray.tray_type || pending?.material || t('ams.slotEmpty')}
                                   </div>
                                   <KValueLine k={isEmpty ? null : extTray.k} reserve={anySlotHasKValue} />
                                   <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
@@ -6500,6 +6551,7 @@ function PrinterCard({
                                   ) : (
                                     <EmptySlotHoverCard
                                       kind={emptyKind ?? undefined}
+                                      assigned={pending ?? undefined}
                                       actions={renderAmsSlotActions({
                                         amsId: 255,
                                         slotId: slotTrayId,

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-Bvao6g1Y.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CamWallPage-BmRe-4dW.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-Db6X0u7B.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FileManagerPage-Cq_RTlFr.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/InventoryPage-Dmf1gZKq.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/Layout-DfVNWALj.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ModelSourcesPage-CVCWs-AL.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-NIcB4mvt.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-CfV1ZkS7.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SliceModal-B2ir_BbQ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DpcIjW8T.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CrgCjjlZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/de-ByueOBi-.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/es-DBm3QfJb.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/fr-BTR_v_4-.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-DpPJ-Y1g.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/it-jsh4ZvmH.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ja-BHbxlRB0.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ko-BLKFcdsO.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/locationSensorDefaults-BVjEatCz.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/nl-CTbrfxvQ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/pl-mxkn-LY2.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/pt-BR-BMDLPp6H.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ru-B6u8fMes.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/spoolCost-COrTHDZN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/sv-DiH5arxY.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/tr-UtkHEmL0.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/uk-Cj0Gzbyh.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/usePageFileDrop-cEZBk3mQ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/zh-CN-DtIJgfrV.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/zh-TW-E1bUYRfv.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CDjEVEyJ.js"></script>
+    <script type="module" crossorigin src="/assets/index-DpPJ-Y1g.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Bw3urp1w.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-Bihwyis7.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6uXNSEZ.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-C2rRKF7J.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-BaFc8Rjv.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-BLtz11zN.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-DfVNWALj.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Vboviekw.css">
   </head>
   <body>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff