Przeglądaj źródła

change(workflow): default Plate-Clear Confirmation to off on fresh installs

  New users repeatedly reported queued prints "not starting" because the
  confirmation prompt was waiting on an ack they didn't know existed.
  Flip the default in the settings schema and in the frontend fallbacks
  so a missing/unset value reads as disabled. Existing installs keep
  their saved preference.
maziggy 1 miesiąc temu
rodzic
commit
574b39aee5

+ 3 - 0
CHANGELOG.md

@@ -15,6 +15,9 @@ All notable changes to Bambuddy will be documented in this file.
 ### Improved
 - **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.
 
+### Changed
+- **Plate-Clear Confirmation Disabled by Default** — New installs ship with Settings → Workflow → "Require Plate-Clear Confirmation" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.
+
 ### Fixed
 - **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.
 - **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.

+ 1 - 1
backend/app/schemas/settings.py

@@ -224,7 +224,7 @@ class AppSettings(BaseModel):
 
     # Plate-clear confirmation for queue scheduling
     require_plate_clear: bool = Field(
-        default=True,
+        default=False,
         description="Require per-printer plate-clear confirmation before starting queued prints on finished printers",
     )
     queue_shortest_first: bool = Field(

+ 17 - 10
frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx

@@ -58,7 +58,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
 
   describe('clear plate button visibility', () => {
     it('shows clear plate button when printer state is FINISH', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -66,7 +66,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows clear plate button when printer state is FAILED', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -128,7 +128,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     // still awaiting plate-clear ack. The prompt must still show — the ack state, not
     // the reported printer state, is the authoritative signal.
     it('shows clear plate button in IDLE state when awaitingPlateClear is true (#961)', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="IDLE" awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="IDLE" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -137,7 +137,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
 
     it('shows clear plate button with no printerState when awaitingPlateClear is true', async () => {
       // State may be null briefly after a reconnect; the widget must still gate on the flag.
-      render(<PrinterQueueWidget printerId={1} awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -166,7 +166,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
   describe('clear plate action', () => {
     it('shows confirmation state after clicking clear plate', async () => {
       const user = userEvent.setup();
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -192,7 +192,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       );
 
       const user = userEvent.setup();
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -264,6 +264,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
           printerId={1}
           printerState="FINISH"
           awaitingPlateClear={true}
+          requirePlateClear={true}
           loadedFilamentTypes={new Set(['PLA', 'PETG'])}
         />
       );
@@ -281,6 +282,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
           printerId={1}
           printerState="FINISH"
           awaitingPlateClear={true}
+          requirePlateClear={true}
           loadedFilamentTypes={new Set(['PLA'])}
         />
       );
@@ -297,7 +299,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       );
 
       render(
-        <PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />
+        <PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />
       );
 
       await waitFor(() => {
@@ -378,6 +380,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
           printerId={1}
           printerState="FINISH"
           awaitingPlateClear={true}
+          requirePlateClear={true}
           loadedFilamentTypes={new Set(['PETG'])}
         />
       );
@@ -437,6 +440,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
           printerId={1}
           printerState="FINISH"
           awaitingPlateClear={true}
+          requirePlateClear={true}
           loadedFilamentTypes={new Set(['PETG'])}
           loadedFilaments={new Set(['PETG:ffffff'])}
         />
@@ -591,12 +595,15 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       });
     });
 
-    it('shows clear plate button when requirePlateClear is not provided (defaults to true)', async () => {
+    it('shows passive link when requirePlateClear is not provided (defaults to false)', async () => {
       render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
       });
+
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
     });
 
     it('still shows next item info in passive link when requirePlateClear is false', async () => {
@@ -637,7 +644,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)),
       );
 
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();

+ 1 - 1
frontend/src/components/PrinterQueueWidget.tsx

@@ -20,7 +20,7 @@ interface PrinterQueueWidgetProps {
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
 }
 
-export function PrinterQueueWidget({ printerId, printerModel, awaitingPlateClear, requirePlateClear = true, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, awaitingPlateClear, requirePlateClear = false, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();

+ 3 - 3
frontend/src/pages/PrintersPage.tsx

@@ -1279,7 +1279,7 @@ function PrinterCard({
   onOpenEmbeddedCamera,
   checkPrinterFirmware = true,
   dryingPresets = DRYING_PRESETS,
-  requirePlateClear = true,
+  requirePlateClear = false,
   selectionMode = false,
   isSelected = false,
   onToggleSelect,
@@ -6360,7 +6360,7 @@ export function PrintersPage() {
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                     checkPrinterFirmware={settings?.check_printer_firmware !== false}
                     dryingPresets={effectiveDryingPresets}
-                    requirePlateClear={settings?.require_plate_clear !== false}
+                    requirePlateClear={settings?.require_plate_clear === true}
                     selectionMode={selectionMode}
                     isSelected={selectedPrinterIds.has(printer.id)}
                     onToggleSelect={toggleSelect}
@@ -6399,7 +6399,7 @@ export function PrintersPage() {
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
               checkPrinterFirmware={settings?.check_printer_firmware !== false}
               dryingPresets={effectiveDryingPresets}
-              requirePlateClear={settings?.require_plate_clear !== false}
+              requirePlateClear={settings?.require_plate_clear === true}
               selectionMode={selectionMode}
               isSelected={selectedPrinterIds.has(printer.id)}
               onToggleSelect={toggleSelect}

+ 2 - 2
frontend/src/pages/SettingsPage.tsx

@@ -889,7 +889,7 @@ export function SettingsPage() {
       (settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
       (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
       (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
-      (settings.require_plate_clear ?? true) !== (localSettings.require_plate_clear ?? true);
+      (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false);
 
     if (!hasChanges) {
       return;
@@ -3730,7 +3730,7 @@ export function SettingsPage() {
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                     type="checkbox"
-                    checked={localSettings.require_plate_clear ?? true}
+                    checked={localSettings.require_plate_clear ?? false}
                     onChange={(e) => updateSetting('require_plate_clear', e.target.checked)}
                     className="sr-only peer"
                   />