Przeglądaj źródła

Show the backup spool on a printing queue job's card once AMS Filament Backup takes over

When a spool ran out mid-print and the printer carried on from the backup
spool, the queue card still named the original slot and marked it Empty.
The printer status now carries the print's tray change log, and the card
shows the tray that took over: one outside the job's mapping, right after
the mapped tray it replaced, followed through a backup that ran out in
turn. Queued jobs still warn about an empty slot.
maziggy 2 tygodni temu
rodzic
commit
be4b471e04

+ 1 - 0
backend/app/api/routes/printers.py

@@ -800,6 +800,7 @@ async def get_printer_status(
             for ext_id, slot in state.extruder_slots.items()
         },
         tray_now=tray_now,
+        tray_change_log=[[tray, layer] for tray, layer in state.tray_change_log],
         # Runout guidance (#2587): resolve the firmware's target/previous slot to a
         # global tray ID, but only while PAUSED — the moment the operator needs it.
         expected_tray=(

+ 5 - 0
backend/app/schemas/printer.py

@@ -409,6 +409,11 @@ class PrinterStatus(BaseModel):
     extruder_slots: dict[str, ExtruderSlotResponse] = {}
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
+    # The trays this print has drawn from, in order: [[global tray ID, layer the
+    # switch happened at], ...]. A tray outside the job's AMS mapping is a backup
+    # spool the printer switched to (AMS Filament Backup); the queue card shows it
+    # in place of the slot that ran out. Reset at the start of each print.
+    tray_change_log: list[list[int]] = []
     # Runout / filament-replacement guidance (#2587). Populated only while the
     # print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
     # AMS-HT, or 254 for external) so the frontend can highlight them with the same

+ 2 - 0
backend/app/services/printer_manager.py

@@ -1562,6 +1562,8 @@ def printer_state_to_dict(
         "ams_status_main": state.ams_status_main,
         "ams_status_sub": state.ams_status_sub,
         "tray_now": state.tray_now,
+        # Grows only when tray_now changes, which already triggers a push.
+        "tray_change_log": [[tray, layer] for tray, layer in getattr(state, "tray_change_log", None) or []],
         # Runout / filament-replacement guidance (#2587). Only meaningful while
         # PAUSED — resolve the firmware's target/previous slot to a global tray ID
         # so the AMS graphic can highlight the slot the print now expects and name

+ 23 - 0
backend/tests/integration/test_printers_api.py

@@ -722,6 +722,29 @@ class TestPrintersAPI:
             "1": {"ams_id": None, "slot_id": None, "has_filament": False},
         }
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_reports_the_trays_the_print_drew_from(self, async_client: AsyncClient, printer_factory):
+        """Restored after a restart as lists, recorded live as tuples: both serialise alike."""
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.tray_change_log = [[2, 0], (1, 350)]
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.json()["tray_change_log"] == [[2, 0], [1, 350]]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(

+ 6 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -989,6 +989,12 @@ class TestPrinterStateToDict:
             "1": {"ams_id": None, "slot_id": None, "has_filament": False},
         }
 
+    def test_the_tray_change_log_rides_the_websocket(self, mock_state):
+        """The queue card reads the backup spool a print switched to from it."""
+        mock_state.tray_change_log = [(2, 0), (1, 350)]
+
+        assert printer_state_to_dict(mock_state)["tray_change_log"] == [[2, 0], [1, 350]]
+
     def test_extruder_slots_are_empty_when_unreported(self, mock_state):
         """Printers outside the H2/X2 series never send the block."""
         assert printer_state_to_dict(mock_state)["extruder_slots"] == {}

+ 76 - 3
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -718,7 +718,7 @@ describe('QueuePage', () => {
     });
 
     describe('stored mapping on the live printer (#3132)', () => {
-      const mappedItem = (amsMapping: number[]) => ({
+      const mappedItem = (amsMapping: number[], extra: Record<string, unknown> = {}) => ({
         ...mockQueueItems[0],
         id: 88,
         printer_id: 1,
@@ -738,11 +738,16 @@ describe('QueuePage', () => {
             force_color_match: false,
           },
         ],
+        ...extra,
       });
 
-      const useMappedPrinter = (amsMapping: number[], status: Record<string, unknown>) => {
+      const useMappedPrinter = (
+        amsMapping: number[],
+        status: Record<string, unknown>,
+        item: Record<string, unknown> = {},
+      ) => {
         server.use(
-          http.get('/api/v1/queue/', () => HttpResponse.json([mappedItem(amsMapping)])),
+          http.get('/api/v1/queue/', () => HttpResponse.json([mappedItem(amsMapping, item)])),
           http.get('/api/v1/library/files/19/plates', () =>
             HttpResponse.json({
               file_id: 19,
@@ -826,6 +831,74 @@ describe('QueuePage', () => {
         expect(within(row).getByTestId('filament-swatch')).toHaveAttribute('title', '#8E351B');
       });
 
+      describe('AMS Filament Backup during the print', () => {
+        const printing = { status: 'printing', started_at: '2024-01-01T10:00:00Z' };
+
+        it('shows the backup spool the printer switched to', async () => {
+          // A3 ran out at layer 350 and the printer carried on from A2.
+          useMappedPrinter(
+            [2],
+            { connected: true, state: 'RUNNING', ams: amsWithEmptyA3, vt_tray: [], tray_change_log: [[2, 0], [1, 350]] },
+            printing,
+          );
+          const row = await mappedRow();
+
+          await waitFor(() => {
+            expect(within(row).getByText(/^A2 · PLA · /)).toBeInTheDocument();
+          });
+          expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
+        });
+
+        it('follows a backup that ran out in turn', async () => {
+          useMappedPrinter(
+            [2],
+            {
+              connected: true,
+              state: 'RUNNING',
+              ams: amsWithEmptyA3,
+              vt_tray: [],
+              tray_change_log: [[2, 0], [1, 120], [0, 400]],
+            },
+            printing,
+          );
+          const row = await mappedRow();
+
+          await waitFor(() => {
+            expect(within(row).getByText(/^A1 · PLA · /)).toBeInTheDocument();
+          });
+          expect(within(row).queryByText(/^A2 · /)).not.toBeInTheDocument();
+        });
+
+        it('does not take a change to another mapped slot for a backup', async () => {
+          // Slot 2 of the job feeds from A2: switching there is a colour change.
+          useMappedPrinter(
+            [2, 1],
+            { connected: true, state: 'RUNNING', ams: amsWithEmptyA3, vt_tray: [], tray_change_log: [[2, 0], [1, 30]] },
+            printing,
+          );
+          const row = await mappedRow();
+
+          await waitFor(() => {
+            expect(within(row).getByText('A3 · Empty · Caramel')).toBeInTheDocument();
+          });
+        });
+
+        it('leaves a queued job pointed at its mapped slot', async () => {
+          // The log belongs to whatever the printer prints now, not this job.
+          useMappedPrinter([2], {
+            connected: true,
+            ams: amsWithEmptyA3,
+            vt_tray: [],
+            tray_change_log: [[2, 0], [1, 350]],
+          });
+          const row = await mappedRow();
+
+          await waitFor(() => {
+            expect(within(row).getByText('A3 · Empty · Caramel')).toBeInTheDocument();
+          });
+        });
+      });
+
       it('never calls the external spool empty, having no presence signal', async () => {
         // Shape the status route really sends: an external tray carries no
         // state / exists, so an unconfigured spool and no spool look the same.

+ 4 - 0
frontend/src/api/client.ts

@@ -697,6 +697,10 @@ export interface PrinterStatus {
   extruder_slots: Record<string, ExtruderSlot>;
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   tray_now: number;
+  // The trays this print has drawn from, in order: [global tray ID, layer of the
+  // switch]. A tray outside the job's AMS mapping is a backup spool the printer
+  // switched to (AMS Filament Backup). Reset at the start of each print.
+  tray_change_log?: [number, number][];
   // Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
   // Global tray IDs (ams_id*4+slot, 128-135 = AMS-HT, 254 = external), matching
   // the same numbering as tray_now so the AMS graphic can highlight them.

+ 36 - 2
frontend/src/pages/QueuePage.tsx

@@ -122,6 +122,35 @@ function emptyMappedSlotLabel(status: PrinterStatus | undefined, trayId: number)
   return formatSlotLabel(amsId, slot, isHt, false);
 }
 
+/**
+ * The backup tray each mapped tray handed over to during the running print.
+ *
+ * With AMS Filament Backup on, a spool that runs out is replaced by an identical
+ * one in another slot, and the job's stored mapping still names the empty slot.
+ * The printer's tray log shows the switch: a tray the mapping doesn't name, right
+ * after the tray it took over from. A backup that runs out in turn hands over
+ * again, so the chain resolves to the slot the mapping named.
+ */
+function backupTrays(mapping: number[] | null | undefined, log: [number, number][] | undefined): Map<number, number> {
+  const backups = new Map<number, number>();
+  if (!mapping?.length || !log?.length) return backups;
+  const mapped = new Set(mapping.filter((tray) => tray >= 0));
+  // Backup tray -> the mapped tray it stands in for.
+  const standsInFor = new Map<number, number>();
+  let previous: number | undefined;
+  for (const [tray] of log) {
+    if (previous !== undefined && !mapped.has(tray) && !standsInFor.has(tray)) {
+      const original = mapped.has(previous) ? previous : standsInFor.get(previous);
+      if (original !== undefined) {
+        standsInFor.set(tray, original);
+        backups.set(original, tray);
+      }
+    }
+    previous = tray;
+  }
+  return backups;
+}
+
 function queueFilamentLabel(filament: QueueFilamentDisplay): string {
   return filament.slotLabel
     ? [
@@ -151,10 +180,15 @@ function resolveQueueFilaments(
   // slot. Once its live tray data is available, that is more specific than
   // either the queue override or the original slice. When the printer reports
   // that tray as empty, keep the intended colour but say the slot is empty.
+  // While the job prints, a slot whose spool ran out shows the backup spool the
+  // printer switched to instead.
+  const backups =
+    item.status === 'printing' ? backupTrays(item.ams_mapping, status?.tray_change_log) : new Map<number, number>();
   const resolveSlot = (planned: QueueFilamentDisplay): QueueFilamentDisplay => {
     const slotId = planned.slotId;
-    const mappedTrayId = slotId > 0 ? item.ams_mapping?.[slotId - 1] : undefined;
-    if (mappedTrayId == null || mappedTrayId < 0) return planned;
+    const storedTrayId = slotId > 0 ? item.ams_mapping?.[slotId - 1] : undefined;
+    if (storedTrayId == null || storedTrayId < 0) return planned;
+    const mappedTrayId = backups.get(storedTrayId) ?? storedTrayId;
     const loaded = loadedFilaments.find((filament) => filament.globalTrayId === mappedTrayId);
     if (loaded) {
       return {