Explorar el Código

Disable busy printers in print modal's reprint mode (#622)

  When printing from the file manager, the print modal showed all printers
  including busy ones. Selecting a busy printer resulted in a failed send.
  The printer selector now fetches live status for each printer and shows
  state badges (Idle, Printing, Paused, etc.). In reprint mode, non-available
  printers (anything other than IDLE, FINISH, FAILED) are grayed out and
  not selectable. "Select all" skips them. Queue/edit modes still allow
  selecting busy printers since the job will wait.
maziggy hace 2 meses
padre
commit
a010134c09

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.
 
 ### Fixed
+- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
 - **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `"any maskable"` purpose into a dedicated `"maskable"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.
 - **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status="completed"` (actually printed via a printer) now count toward completion stats. Files with `status="archived"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.
 - **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.

+ 9 - 2
frontend/public/manifest.json

@@ -1,4 +1,5 @@
 {
+  "id": "/",
   "name": "Bambuddy",
   "short_name": "Bambuddy",
   "description": "Monitor and manage your Bambu Lab 3D printers",
@@ -32,8 +33,14 @@
       "purpose": "any"
     },
     {
-      "src": "/img/apple-touch-icon.png",
-      "sizes": "180x180",
+      "src": "/img/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "maskable"
+    },
+    {
+      "src": "/img/android-chrome-512x512.png",
+      "sizes": "512x512",
       "type": "image/png",
       "purpose": "maskable"
     }

+ 180 - 2
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -19,6 +19,7 @@ import type { PrintQueueItem } from '../../api/client';
 const mockPrinters = [
   { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
   { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
+  { id: 3, name: 'A1 Mini', model: 'A1M', ip_address: '192.168.1.102', enabled: true, is_active: true },
 ];
 
 const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
@@ -508,7 +509,7 @@ describe('PrintModal', () => {
       await user.click(screen.getByText('Select all'));
 
       await waitFor(() => {
-        expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();
+        expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
       });
     });
 
@@ -530,7 +531,184 @@ describe('PrintModal', () => {
       await user.click(screen.getByText('Select all'));
 
       await waitFor(() => {
-        expect(screen.getByRole('button', { name: /print to 2 printers/i })).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /print to 3 printers/i })).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('busy printer handling (#622)', () => {
+    beforeEach(() => {
+      // Set up per-printer statuses: printer 1 RUNNING, printer 2 IDLE, printer 3 FINISH
+      server.use(
+        http.get('/api/v1/printers/:id/status', ({ params }) => {
+          const id = Number(params.id);
+          if (id === 1) {
+            return HttpResponse.json({
+              connected: true, state: 'RUNNING', stg_cur_name: null,
+              ams: [], vt_tray: [], nozzles: [],
+            });
+          }
+          if (id === 2) {
+            return HttpResponse.json({
+              connected: true, state: 'IDLE', stg_cur_name: null,
+              ams: [], vt_tray: [], nozzles: [],
+            });
+          }
+          // printer 3
+          return HttpResponse.json({
+            connected: true, state: 'FINISH', stg_cur_name: null,
+            ams: [], vt_tray: [], nozzles: [],
+          });
+        })
+      );
+    });
+
+    it('shows state badges on printers in reprint mode', async () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+        expect(screen.getByText('Idle')).toBeInTheDocument();
+        expect(screen.getByText('Finished')).toBeInTheDocument();
+      });
+    });
+
+    it('prevents selecting a busy printer in reprint mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+      });
+
+      // The busy printer button should be disabled
+      const busyButton = screen.getByText('X1 Carbon').closest('button');
+      expect(busyButton).toBeDisabled();
+
+      // Click the busy printer — selection should not change
+      await user.click(busyButton!);
+
+      // Idle printer should still be selectable
+      const idleButton = screen.getByText('P1S').closest('button');
+      expect(idleButton).not.toBeDisabled();
+      await user.click(idleButton!);
+
+      await waitFor(() => {
+        expect(screen.getByText('1 printer selected')).toBeInTheDocument();
+      });
+    });
+
+    it('select all skips busy printers in reprint mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        // Only 2 available printers selected (IDLE + FINISH), not the RUNNING one
+        expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();
+      });
+    });
+
+    it('allows selecting busy printers in add-to-queue mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+      });
+
+      // The busy printer button should NOT be disabled in queue mode
+      const busyButton = screen.getByText('X1 Carbon').closest('button');
+      expect(busyButton).not.toBeDisabled();
+
+      await user.click(busyButton!);
+
+      await waitFor(() => {
+        expect(screen.getByText('1 printer selected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Offline badge for disconnected printers', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({
+            connected: false, state: null, stg_cur_name: null,
+            ams: [], vt_tray: [], nozzles: [],
+          });
+        })
+      );
+
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        const offlineBadges = screen.getAllByText('Offline');
+        expect(offlineBadges.length).toBeGreaterThanOrEqual(1);
+      });
+    });
+
+    it('shows calibration stage name when printer is calibrating', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({
+            connected: true, state: 'RUNNING', stg_cur_name: 'Auto bed leveling',
+            ams: [], vt_tray: [], nozzles: [],
+          });
+        })
+      );
+
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        const badges = screen.getAllByText('Auto bed leveling');
+        expect(badges.length).toBeGreaterThanOrEqual(1);
       });
     });
   });

+ 77 - 8
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -1,5 +1,5 @@
 import { useState, useMemo } from 'react';
-import { useQueryClient } from '@tanstack/react-query';
+import { useQueryClient, useQueries } from '@tanstack/react-query';
 import {
   Printer as PrinterIcon,
   Loader2,
@@ -11,7 +11,7 @@ import {
   Wand2,
   Users,
 } from 'lucide-react';
-import { api } from '../../api/client';
+import { api, type PrinterStatus } from '../../api/client';
 import { getColorName } from '../../utils/colors';
 import {
   normalizeColorForCompare,
@@ -48,6 +48,9 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   slicedForModel?: string | null;
 }
 
+/** States where the printer is available to accept a new print */
+const AVAILABLE_STATES = new Set(['IDLE', 'FINISH', 'FAILED']);
+
 /**
  * Inline AMS mapping editor for a single printer.
  */
@@ -204,6 +207,7 @@ export function PrinterSelector({
   isLoading = false,
   allowMultiple = false,
   showInactive = false,
+  disableBusy = false,
   printerMappingResults,
   filamentReqs,
   onAutoConfigurePrinter,
@@ -222,6 +226,49 @@ export function PrinterSelector({
   // Filter printers based on showInactive flag
   const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
 
+  // Fetch printer statuses to determine busy/idle state
+  const statusQueries = useQueries({
+    queries: activePrinters.map((printer) => ({
+      queryKey: ['printerStatus', printer.id],
+      queryFn: () => api.getPrinterStatus(printer.id),
+      staleTime: 5000,
+    })),
+  });
+
+  // Build a map of printer ID -> status for quick lookup
+  const printerStatusMap = useMemo(() => {
+    const map = new Map<number, PrinterStatus>();
+    activePrinters.forEach((printer, idx) => {
+      const query = statusQueries[idx];
+      if (query?.data) {
+        map.set(printer.id, query.data);
+      }
+    });
+    return map;
+  }, [activePrinters, statusQueries]);
+
+  const isPrinterBusy = (printerId: number): boolean => {
+    const status = printerStatusMap.get(printerId);
+    if (!status) return false; // Unknown state — don't block
+    if (!status.connected) return true;
+    return !AVAILABLE_STATES.has(status.state ?? '');
+  };
+
+  const getPrinterStateLabel = (printerId: number): string | null => {
+    const status = printerStatusMap.get(printerId);
+    if (!status) return null;
+    if (!status.connected) return 'Offline';
+    const state = status.state;
+    if (!state) return null;
+    if (state === 'RUNNING') return status.stg_cur_name || 'Printing';
+    if (state === 'PREPARE') return 'Preparing';
+    if (state === 'PAUSE') return 'Paused';
+    if (state === 'IDLE') return 'Idle';
+    if (state === 'FINISH') return 'Finished';
+    if (state === 'FAILED') return 'Failed';
+    return state;
+  };
+
   // Filter by sliced model (only in printer mode, when slicedForModel is set)
   const displayPrinters = useMemo(() => {
     if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
@@ -283,6 +330,8 @@ export function PrinterSelector({
   }
 
   const handlePrinterClick = (printerId: number) => {
+    if (disableBusy && isPrinterBusy(printerId)) return;
+
     if (allowMultiple) {
       if (selectedPrinterIds.includes(printerId)) {
         onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
@@ -295,7 +344,10 @@ export function PrinterSelector({
   };
 
   const handleSelectAll = () => {
-    onMultiSelect(displayPrinters.map((p) => p.id));
+    const selectable = disableBusy
+      ? displayPrinters.filter((p) => !isPrinterBusy(p.id))
+      : displayPrinters;
+    onMultiSelect(selectable.map((p) => p.id));
   };
 
   const handleDeselectAll = () => {
@@ -460,6 +512,9 @@ export function PrinterSelector({
         const selected = isSelected(printer.id);
         const mappingResult = getPrinterMappingResult(printer.id);
         const hasOverride = mappingResult && !mappingResult.config.useDefault;
+        const busy = isPrinterBusy(printer.id);
+        const disabled = disableBusy && busy;
+        const stateLabel = getPrinterStateLabel(printer.id);
 
         return (
           <div key={printer.id}>
@@ -467,25 +522,28 @@ export function PrinterSelector({
             <button
               type="button"
               onClick={() => handlePrinterClick(printer.id)}
+              disabled={disabled}
               className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
-                selected
+                disabled
+                  ? 'border-bambu-dark-tertiary bg-bambu-dark opacity-50 cursor-not-allowed'
+                  : selected
                   ? 'border-bambu-green bg-bambu-green/10'
                   : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
               } ${!printer.is_active ? 'opacity-60' : ''}`}
             >
               <div
                 className={`p-2 rounded-lg ${
-                  selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
+                  disabled ? 'bg-bambu-dark-tertiary' : selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
                 }`}
               >
                 <PrinterIcon
                   className={`w-5 h-5 ${
-                    selected ? 'text-bambu-green' : 'text-bambu-gray'
+                    disabled ? 'text-bambu-gray/50' : selected ? 'text-bambu-green' : 'text-bambu-gray'
                   }`}
                 />
               </div>
               <div className="text-left flex-1">
-                <p className="text-white font-medium">
+                <p className={`font-medium ${disabled ? 'text-bambu-gray' : 'text-white'}`}>
                   {printer.name}
                   {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
                 </p>
@@ -493,10 +551,21 @@ export function PrinterSelector({
                   {printer.model || 'Unknown model'} • {printer.ip_address}
                 </p>
               </div>
+              {stateLabel && (
+                <span className={`text-xs px-2 py-0.5 rounded-full ${
+                  busy
+                    ? 'bg-yellow-500/20 text-yellow-400'
+                    : 'bg-bambu-green/20 text-bambu-green'
+                }`}>
+                  {stateLabel}
+                </span>
+              )}
               {allowMultiple && (
                 <div
                   className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
-                    selected
+                    disabled
+                      ? 'border-bambu-gray/30'
+                      : selected
                       ? 'bg-bambu-green border-bambu-green'
                       : 'border-bambu-gray/50'
                   }`}

+ 1 - 0
frontend/src/components/PrintModal/index.tsx

@@ -711,6 +711,7 @@ export function PrintModal({
                 isLoading={loadingPrinters}
                 allowMultiple={true}
                 showInactive={mode === 'edit-queue-item'}
+                disableBusy={mode === 'reprint'}
                 printerMappingResults={multiPrinterMapping.printerResults}
                 filamentReqs={effectiveFilamentReqs}
                 onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}

+ 2 - 0
frontend/src/components/PrintModal/types.ts

@@ -124,6 +124,8 @@ export interface PrinterSelectorProps {
   allowMultiple?: boolean;
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   showInactive?: boolean;
+  /** Disable selection of busy printers (used in reprint mode) */
+  disableBusy?: boolean;
   /** Current assignment mode */
   assignmentMode?: AssignmentMode;
   /** Handler for assignment mode change */

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-CMePull4.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-4rCw-7Dd.js"></script>
+    <script type="module" crossorigin src="/assets/index-CMePull4.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DOJtH8DG.css">
   </head>
   <body>

+ 9 - 2
static/manifest.json

@@ -1,4 +1,5 @@
 {
+  "id": "/",
   "name": "Bambuddy",
   "short_name": "Bambuddy",
   "description": "Monitor and manage your Bambu Lab 3D printers",
@@ -32,8 +33,14 @@
       "purpose": "any"
     },
     {
-      "src": "/img/apple-touch-icon.png",
-      "sizes": "180x180",
+      "src": "/img/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "maskable"
+    },
+    {
+      "src": "/img/android-chrome-512x512.png",
+      "sizes": "512x512",
       "type": "image/png",
       "purpose": "maskable"
     }

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio