Просмотр исходного кода

Fix "Clear Plate & Next" shown on printers without correct filament (#527)

The "Clear Plate & Start Next" button appeared on ALL printers of a
model when a job was queued via "Queue to Any X", even printers without
the required filament loaded. Clicking it started a print that fails.

PrinterQueueWidget now filters queue items by filament compatibility —
each item's required_filament_types must all be present in the printer's
loaded filaments (AMS + external spools). If no compatible items exist,
the widget is hidden entirely. Matching is type-only (not color) and
case-insensitive, consistent with the backend scheduler. When filament
data is unavailable, no filtering is applied (graceful degradation).
maziggy 3 месяцев назад
Родитель
Сommit
20c291d678

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.1] - Unreleased
 
 ### Fixed
+- **"Clear Plate & Start Next" Button Shown on Printers Without Correct Filament** ([#527](https://github.com/maziggy/bambuddy/issues/527)) — When a print job was queued for "any printer" of a model (e.g., "any H2S"), the "Clear Plate & Start Next" button appeared on ALL printers of that model, including those without the required filament loaded. Clicking it on a printer without the right filament would start a print that fails. The `PrinterQueueWidget` now filters queue items by filament compatibility — it checks the printer's loaded filament types (from AMS and external spools) against the queue item's `required_filament_types` and only shows items the printer can actually print. If no compatible items exist, the widget is hidden.
 - **Manual Spool Weight Overwritten by AMS Auto-Sync** ([#525](https://github.com/maziggy/bambuddy/issues/525)) — When a user manually entered a spool weight (via UI or API), the value was overwritten by the automatic AMS remain% sync that runs on every MQTT update. The AMS remain% is integer-only (~10g resolution for 1kg spool) and can't match precise manual entries. Added a `weight_locked` flag that is automatically set when `weight_used` is explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by setting `weight_locked: false`.
 - **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to `archive.cost` with conflicting strategies: the usage tracker summed ALL historical `SpoolUsageHistory` rows for the archive (including rows from previous reprints), and a separate `add_reprint_cost` method added yet another full print's cost on top. Removed the redundant `add_reprint_cost` path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. `archive.cost` now always reflects the cost of a single print.
 - **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.

+ 167 - 0
frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx

@@ -196,4 +196,171 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       });
     });
   });
+
+  describe('filament compatibility filtering', () => {
+    const petgQueueItems = [
+      {
+        id: 10,
+        printer_id: 1,
+        archive_id: 10,
+        position: 1,
+        status: 'pending',
+        archive_name: 'PETG Print',
+        printer_name: 'H2S',
+        print_time_seconds: 3600,
+        scheduled_time: null,
+        required_filament_types: ['PETG'],
+      },
+    ];
+
+    it('hides widget when queue item requires filament not loaded on printer', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
+      );
+
+      const { container } = render(
+        <PrinterQueueWidget
+          printerId={1}
+          printerState="FINISH"
+          loadedFilamentTypes={new Set(['PLA'])}
+        />
+      );
+
+      // Wait for query to settle, then confirm widget is not rendered
+      await waitFor(() => {
+        expect(container.querySelector('button')).not.toBeInTheDocument();
+      });
+      expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
+    });
+
+    it('shows widget when queue item required filaments match loaded', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
+      );
+
+      render(
+        <PrinterQueueWidget
+          printerId={1}
+          printerState="FINISH"
+          loadedFilamentTypes={new Set(['PLA', 'PETG'])}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('PETG Print')).toBeInTheDocument();
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows widget when queue item has no required_filament_types', async () => {
+      // Default mockQueueItems have no required_filament_types
+      render(
+        <PrinterQueueWidget
+          printerId={1}
+          printerState="FINISH"
+          loadedFilamentTypes={new Set(['PLA'])}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('First Print')).toBeInTheDocument();
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows widget when loadedFilamentTypes prop is not provided', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
+      );
+
+      render(
+        <PrinterQueueWidget printerId={1} printerState="FINISH" />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('PETG Print')).toBeInTheDocument();
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('skips incompatible first item and shows compatible second item', async () => {
+      const mixedQueue = [
+        {
+          id: 10,
+          printer_id: 1,
+          archive_id: 10,
+          position: 1,
+          status: 'pending',
+          archive_name: 'PETG Print',
+          printer_name: 'H2S',
+          print_time_seconds: 3600,
+          scheduled_time: null,
+          required_filament_types: ['PETG'],
+        },
+        {
+          id: 11,
+          printer_id: 1,
+          archive_id: 11,
+          position: 2,
+          status: 'pending',
+          archive_name: 'PLA Print',
+          printer_name: 'H2S',
+          print_time_seconds: 1800,
+          scheduled_time: null,
+          required_filament_types: ['PLA'],
+        },
+      ];
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(mixedQueue))
+      );
+
+      render(
+        <PrinterQueueWidget
+          printerId={1}
+          printerState="FINISH"
+          loadedFilamentTypes={new Set(['PLA'])}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Print')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
+    });
+
+    it('matches filament types case-insensitively', async () => {
+      const lowercaseQueue = [
+        {
+          id: 10,
+          printer_id: 1,
+          archive_id: 10,
+          position: 1,
+          status: 'pending',
+          archive_name: 'Petg Print',
+          printer_name: 'H2S',
+          print_time_seconds: 3600,
+          scheduled_time: null,
+          required_filament_types: ['petg'],
+        },
+      ];
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(lowercaseQueue))
+      );
+
+      render(
+        <PrinterQueueWidget
+          printerId={1}
+          printerState="FINISH"
+          loadedFilamentTypes={new Set(['PETG'])}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Petg Print')).toBeInTheDocument();
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+  });
 });

+ 11 - 3
frontend/src/components/PrinterQueueWidget.tsx

@@ -12,9 +12,10 @@ interface PrinterQueueWidgetProps {
   printerModel?: string | null;
   printerState?: string | null;
   plateCleared?: boolean;
+  loadedFilamentTypes?: Set<string>;
 }
 
-export function PrinterQueueWidget({ printerId, printerModel, printerState, plateCleared }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, printerState, plateCleared, loadedFilamentTypes }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -37,8 +38,15 @@ export function PrinterQueueWidget({ printerId, printerModel, printerState, plat
     },
   });
 
-  const nextItem = queue?.[0];
-  const totalPending = queue?.length || 0;
+  // Filter queue to items this printer can actually print (filament type check)
+  const compatibleQueue = queue?.filter(item => {
+    if (!item.required_filament_types?.length) return true;
+    if (!loadedFilamentTypes?.size) return true;
+    return item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()));
+  });
+
+  const nextItem = compatibleQueue?.[0];
+  const totalPending = compatibleQueue?.length || 0;
 
   if (totalPending === 0) {
     return null;

+ 17 - 1
frontend/src/pages/PrintersPage.tsx

@@ -1540,6 +1540,22 @@ function PrinterCard({
     return Array.from(ids);
   }, [status?.ams, status?.vt_tray, status?.nozzle_rack]);
 
+  // Collect loaded filament types for queue widget filtering
+  const loadedFilamentTypes = useMemo(() => {
+    const types = new Set<string>();
+    if (status?.ams) {
+      for (const ams of status.ams) {
+        for (const tray of ams.tray || []) {
+          if (tray.tray_type) types.add(tray.tray_type.toUpperCase());
+        }
+      }
+    }
+    for (const vt of status?.vt_tray ?? []) {
+      if (vt.tray_type) types.add(vt.tray_type.toUpperCase());
+    }
+    return types;
+  }, [status?.ams, status?.vt_tray]);
+
   // Fetch cloud filament info for tooltips (name includes color, also has K value)
   const { data: filamentInfo } = useQuery({
     queryKey: ['filamentInfo', trayInfoIds],
@@ -2473,7 +2489,7 @@ function PrinterCard({
                 </div>
 
                 {/* Queue Widget - always visible when there are pending items */}
-                <PrinterQueueWidget printerId={printer.id} printerModel={printer.model} printerState={status.state} plateCleared={status.plate_cleared} />
+                <PrinterQueueWidget printerId={printer.id} printerModel={printer.model} printerState={status.state} plateCleared={status.plate_cleared} loadedFilamentTypes={loadedFilamentTypes} />
               </>
             )}
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D6vc8KCS.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DXDq-oI9.js"></script>
+    <script type="module" crossorigin src="/assets/index-D6vc8KCS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-1Ts9jjQl.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов