Procházet zdrojové kódy

Add stagger to Print dialog, add plate-clear setting (#752)

  Stagger option now available when printing directly to multiple printers,
  not just in queue mode. Prints are automatically queued with staggered
  start times using group size/interval from Settings. New "Require
  plate-clear confirmation" setting lets farm users disable per-printer
  plate confirmations so queued prints start automatically on finished
  printers.

  Also fixes settings API type parsing for require_plate_clear (boolean),
  stagger_group_size and stagger_interval_minutes (integer) — without this,
  saved values returned as strings would cause the settings toggle to
  always show enabled and trigger a permanent save loop.
maziggy před 1 měsícem
rodič
revize
046dbf3608

+ 2 - 1
CHANGELOG.md

@@ -6,7 +6,8 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### New Features
 - **Queue Timeline View** ([#823](https://github.com/maziggy/bambuddy/issues/823)) — The queue page now has a production schedule view showing when each print is estimated to finish. Events are sorted chronologically and grouped by hour, with cards showing the file name, printer, estimated completion time, and time remaining. Active prints show a live progress bar. Filter by "Show All", "Printing", or "Queued", and navigate between days. Click any event to edit or stop it. Toggle between List and Timeline views with the button group above the queue. Requested by @sanjay2409.
-- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. Requested by @maziggy.
+- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. The stagger option is also available in the direct Print dialog when multiple printers are selected — prints are automatically queued with staggered start times, so you can close the browser and walk away. Requested by @UVCXanth.
+- **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved). Requested by @UVCXanth.
 - **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
 
 ### Improved

+ 2 - 2
README.md

@@ -106,14 +106,14 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop and timeline schedule view
 - Multi-printer selection (send to multiple printers at once)
-- Staggered batch start (start printers in groups with configurable interval to avoid power spikes)
+- Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament override for model-based queue (swap filament colors/types before scheduling)
 - Filament validation (only assign to printers with required filaments)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
-- Clear plate confirmation between queued prints
+- Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - Smart plug integration (Tasmota, Home Assistant, MQTT)
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)

+ 3 - 0
backend/app/api/routes/settings.py

@@ -102,6 +102,7 @@ async def get_settings(
                 "queue_drying_enabled",
                 "queue_drying_block",
                 "ambient_drying_enabled",
+                "require_plate_clear",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
@@ -121,6 +122,8 @@ async def get_settings(
                 "ftp_retry_delay",
                 "ftp_timeout",
                 "mqtt_port",
+                "stagger_group_size",
+                "stagger_interval_minutes",
             ]:
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":

+ 7 - 0
backend/app/schemas/settings.py

@@ -194,6 +194,12 @@ class AppSettings(BaseModel):
         default=5, ge=1, le=60, description="Minutes between staggered printer groups"
     )
 
+    # Plate-clear confirmation for queue scheduling
+    require_plate_clear: bool = Field(
+        default=True,
+        description="Require per-printer plate-clear confirmation before starting queued prints on finished printers",
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -270,6 +276,7 @@ class AppSettingsUpdate(BaseModel):
     user_notifications_enabled: bool | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
+    require_plate_clear: bool | None = None
     default_sidebar_order: str | None = None
 
     @field_validator("default_sidebar_order")

+ 24 - 11
backend/app/services/print_scheduler.py

@@ -101,9 +101,12 @@ class PrintScheduler:
             )
             items = list(result.scalars().all())
 
+            # Read plate-clear setting once per queue check
+            require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=True)
+
             if not items:
                 # No pending items — still check auto-drying on idle printers
-                await self._check_auto_drying(db, [], set())
+                await self._check_auto_drying(db, [], set(), require_plate_clear=require_plate_clear)
                 return
 
             logger.info(
@@ -139,7 +142,7 @@ class PrintScheduler:
                         continue
 
                     # Check if printer is idle
-                    printer_idle = self._is_printer_idle(item.printer_id)
+                    printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                     printer_connected = printer_manager.is_connected(item.printer_id)
 
                     # If printer not connected, try to power on via smart plug
@@ -150,7 +153,7 @@ class PrintScheduler:
                             powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
                             if powered_on:
                                 printer_connected = True
-                                printer_idle = self._is_printer_idle(item.printer_id)
+                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
                                 logger.warning("Could not power on printer %s via smart plug", item.printer_id)
                                 busy_printers.add(item.printer_id)
@@ -173,7 +176,7 @@ class PrintScheduler:
                                 # Print takes priority — stop drying
                                 await self._stop_drying(item.printer_id)
                                 # Re-check idle after stopping drying
-                                printer_idle = self._is_printer_idle(item.printer_id)
+                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                                 if not printer_idle:
                                     busy_printers.add(item.printer_id)
                                     continue
@@ -249,6 +252,7 @@ class PrintScheduler:
                         effective_types,
                         item.target_location,
                         filament_overrides=filament_overrides,
+                        require_plate_clear=require_plate_clear,
                     )
 
                     # Update waiting_reason if changed and send notification when first waiting
@@ -339,7 +343,7 @@ class PrintScheduler:
                     )
 
             # Auto-drying: start drying on idle printers that have no pending queue items
-            await self._check_auto_drying(db, items, busy_printers)
+            await self._check_auto_drying(db, items, busy_printers, require_plate_clear=require_plate_clear)
 
     async def _find_idle_printer_for_model(
         self,
@@ -349,6 +353,7 @@ class PrintScheduler:
         required_filament_types: list[str] | None = None,
         target_location: str | None = None,
         filament_overrides: list[dict] | None = None,
+        require_plate_clear: bool = True,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -413,7 +418,7 @@ class PrintScheduler:
                 continue
 
             is_connected = printer_manager.is_connected(printer.id)
-            is_idle = self._is_printer_idle(printer.id) if is_connected else False
+            is_idle = self._is_printer_idle(printer.id, require_plate_clear) if is_connected else False
 
             if not is_connected:
                 printers_offline.append(printer.name)
@@ -1021,7 +1026,7 @@ class PrintScheduler:
 
         return mapping
 
-    def _is_printer_idle(self, printer_id: int) -> bool:
+    def _is_printer_idle(self, printer_id: int, require_plate_clear: bool = True) -> bool:
         """Check if a printer is connected and idle."""
         if not printer_manager.is_connected(printer_id):
             logger.debug("Printer %d: not connected", printer_id)
@@ -1033,9 +1038,10 @@ class PrintScheduler:
             return False
 
         # IDLE = ready for next print
-        # FINISH/FAILED = ready only if user confirmed plate is cleared
+        # FINISH/FAILED = ready if plate-clear not required, or user confirmed plate is cleared
         idle = state.state == "IDLE" or (
-            state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
+            state.state in ("FINISH", "FAILED")
+            and (not require_plate_clear or printer_manager.is_plate_cleared(printer_id))
         )
         if not idle:
             logger.debug(
@@ -1106,7 +1112,14 @@ class PrintScheduler:
             return None
         return (min_temp, max_hours or 12, filament_type)
 
-    async def _check_auto_drying(self, db: AsyncSession, queue_items: list[PrintQueueItem], busy_printers: set[int]):
+    async def _check_auto_drying(
+        self,
+        db: AsyncSession,
+        queue_items: list[PrintQueueItem],
+        busy_printers: set[int],
+        *,
+        require_plate_clear: bool = True,
+    ):
         """Start drying on idle printers based on humidity.
 
         Two modes (can both be enabled):
@@ -1175,7 +1188,7 @@ class PrintScheduler:
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
                 continue
-            if not self._is_printer_idle(pid):
+            if not self._is_printer_idle(pid, require_plate_clear):
                 logger.debug("Auto-drying: printer %d skipped — not idle", pid)
                 continue
 

+ 38 - 0
backend/tests/unit/test_scheduler_clear_plate.py

@@ -122,6 +122,44 @@ class TestSchedulerIdleCheckWithPlateCleared:
         mock_pm.get_status.return_value = None
         assert scheduler._is_printer_idle(1) is False
 
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_idle_when_require_plate_clear_disabled(self, mock_pm, scheduler):
+        """FINISH state should be idle when require_plate_clear=False, regardless of plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_idle_when_require_plate_clear_disabled(self, mock_pm, scheduler):
+        """FAILED state should be idle when require_plate_clear=False."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_running_state_not_idle_even_when_require_plate_clear_disabled(self, mock_pm, scheduler):
+        """RUNNING state should NOT be idle even with require_plate_clear=False."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        assert scheduler._is_printer_idle(1, require_plate_clear=False) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_idle_state_unaffected_by_require_plate_clear(self, mock_pm, scheduler):
+        """IDLE state should always be idle regardless of require_plate_clear."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="IDLE")
+        assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_still_needs_plate_cleared_when_setting_enabled(self, mock_pm, scheduler):
+        """FINISH + require_plate_clear=True + plate not cleared → NOT idle (default behavior)."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1, require_plate_clear=True) is False
+
 
 class TestSchedulerQueueCheckLogging:
     """Test queue check logging when pending items are found (#374)."""

+ 51 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -757,7 +757,7 @@ describe('PrintModal', () => {
       });
     });
 
-    it('does not show stagger option in reprint mode', async () => {
+    it('shows stagger option in reprint mode with multiple printers', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
@@ -778,6 +778,56 @@ describe('PrintModal', () => {
         expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();
       });
 
+      expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+    });
+
+    it('shows stagger preview in reprint mode when enabled', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByLabelText('Stagger printer starts'));
+
+      await waitFor(() => {
+        // Default: 3 printers, group size 2 = 2 groups — preview text shown
+        expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
+      });
+    });
+
+    it('does not show stagger option in reprint mode with single printer', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Select only one printer
+      await user.click(screen.getByText('X1 Carbon'));
+
       expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
     });
 

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

@@ -889,6 +889,8 @@ export interface AppSettings {
   // Staggered batch start defaults
   stagger_group_size: number;
   stagger_interval_minutes: number;
+  // Plate-clear confirmation
+  require_plate_clear: boolean;
   // Default sidebar order (admin-set for all users)
   default_sidebar_order: string;
 }

+ 54 - 8
frontend/src/components/PrintModal/index.tsx

@@ -1,5 +1,5 @@
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
+import { AlertCircle, AlertTriangle, Calendar, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
@@ -478,6 +478,8 @@ export function PrintModal({
     },
   });
 
+  const willUseStagger = scheduleOptions.staggerEnabled && selectedPrinters.length > 1;
+
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
@@ -682,7 +684,7 @@ export function PrintModal({
       // Printer-based assignment: loop through plates × printers
       // Compute stagger base time once before the loop
       const useStagger = scheduleOptions.staggerEnabled
-        && mode === 'add-to-queue'
+        && (mode === 'add-to-queue' || mode === 'reprint')
         && selectedPrinters.length > 1;
       const staggerBaseTime = useStagger
         ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -700,7 +702,7 @@ export function PrintModal({
           setSubmitProgress({ current: progressCounter, total: totalCount });
 
           try {
-            if (mode === 'reprint') {
+            if (mode === 'reprint' && !useStagger) {
               // Reprint mode - start print immediately (single plate only, multi-select not available)
               const printerMapping = getMappingForPrinter(printerId);
               if (isLibraryFile) {
@@ -737,7 +739,7 @@ export function PrintModal({
               };
               await updateQueueMutation.mutateAsync(updateData);
             } else {
-              // Add-to-queue mode OR edit mode with additional entries
+              // Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
               const queueData = getQueueData(printerId, plateId);
               // Apply stagger offset for groups after the first
               if (useStagger) {
@@ -765,9 +767,12 @@ export function PrintModal({
 
     setIsSubmitting(false);
 
-    // Show result toast (skip for reprint mode — the dispatch toast handles it)
+    // Show result toast (skip for direct reprint — the dispatch toast handles it)
     if (results.failed === 0) {
-      if (mode !== 'reprint') {
+      if (mode === 'reprint' && willUseStagger) {
+        // Stagger-reprint routed through queue
+        showToast(t('queue.itemsQueued', { count: results.success }));
+      } else if (mode !== 'reprint') {
         if (mode === 'edit-queue-item') {
           showToast('Queue item updated');
         } else if (results.success === 1) {
@@ -810,11 +815,14 @@ export function PrintModal({
     const printerCount = selectedPrinters.length;
 
     if (mode === 'reprint') {
+      const staggerReprint = willUseStagger && printerCount > 1;
       return {
         title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
         icon: Printer,
-        submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
-        submitIcon: Printer,
+        submitText: staggerReprint
+          ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
+          : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
+        submitIcon: staggerReprint ? Calendar : Printer,
         loadingText: submitProgress.total > 1
           ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
           : t('queue.sending'),
@@ -1014,6 +1022,44 @@ export function PrintModal({
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
             )}
 
+            {/* Stagger option for reprint mode with multiple printers */}
+            {mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
+              <div className="space-y-2 pb-2">
+                <div className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    id="staggerEnabledReprint"
+                    checked={scheduleOptions.staggerEnabled}
+                    onChange={(e) => setScheduleOptions({ ...scheduleOptions, staggerEnabled: e.target.checked })}
+                    className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                  />
+                  <label htmlFor="staggerEnabledReprint" className="text-sm flex items-center gap-1 text-bambu-gray">
+                    <Layers className="w-3.5 h-3.5" />
+                    {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}
+                  </label>
+                </div>
+                {scheduleOptions.staggerEnabled && (() => {
+                  const groupSize = scheduleOptions.staggerGroupSize;
+                  const interval = scheduleOptions.staggerIntervalMinutes;
+                  const groupCount = Math.ceil(selectedPrinters.length / groupSize);
+                  const totalMinutes = (groupCount - 1) * interval;
+                  return (
+                    <p className="ml-6 text-xs text-bambu-gray">
+                      {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {
+                        printers: selectedPrinters.length,
+                        groups: groupCount,
+                        size: groupSize,
+                        interval,
+                      })}
+                      {groupCount > 1
+                        ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`
+                        : ''}
+                    </p>
+                  );
+                })()}
+              </div>
+            )}
+
             {/* Schedule options - only for queue modes */}
             {mode !== 'reprint' && (
               <ScheduleOptionsPanel

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

@@ -1548,6 +1548,9 @@ export default {
     historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: 'Druckplatte-Bestätigung',
+    requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
+    requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatte-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Deaktivieren Sie dies für Farm-Workflows, bei denen die Platten physisch überprüft werden.',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3153,6 +3156,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
   },
 
   // Backup

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

@@ -1548,6 +1548,9 @@ export default {
     historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: 'Plate-Clear Confirmation',
+    requirePlateClear: 'Require plate-clear confirmation',
+    requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3157,6 +3160,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: 'Stagger to {{count}} printers',
   },
 
   // Backup

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

@@ -1548,6 +1548,9 @@ export default {
     historyRetentionDescription: 'Les anciennes données seront supprimées.',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: 'Confirmation de plateau libre',
+    requirePlateClear: 'Exiger la confirmation de plateau libre',
+    requirePlateClearDescription: 'Lorsque activé, le planificateur attend la confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. Désactivez pour les workflows de ferme où les plateaux sont vérifiés physiquement.',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3144,6 +3147,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
   },
 
   // Backup

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

@@ -1548,6 +1548,9 @@ export default {
     historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: 'Conferma piatto libero',
+    requirePlateClear: 'Richiedi conferma piatto libero',
+    requirePlateClearDescription: 'Quando abilitato, lo scheduler attende la conferma per stampante che il piatto è libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitare per flussi di lavoro in farm dove i piatti vengono verificati fisicamente.',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3143,6 +3146,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: 'Scagliona a {{count}} stampanti',
   },
 
   // Backup

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

@@ -1547,6 +1547,9 @@ export default {
     historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: 'プレートクリア確認',
+    requirePlateClear: 'プレートクリア確認を必須にする',
+    requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。プレートを物理的に確認するファームワークフローでは無効にしてください。',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3156,6 +3159,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
   },
 
   // Backup

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

@@ -1548,6 +1548,9 @@ export default {
     historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: 'Confirmação de placa livre',
+    requirePlateClear: 'Exigir confirmação de placa livre',
+    requirePlateClearDescription: 'Quando ativado, o agendador aguarda a confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desative para fluxos de trabalho de fazenda onde as placas são verificadas fisicamente.',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3143,6 +3146,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: 'Escalonar para {{count}} impressoras',
   },
 
   // Backup

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

@@ -1548,6 +1548,9 @@ export default {
     historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    plateClear: '热床清空确认',
+    requirePlateClear: '需要热床清空确认',
+    requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。对于物理验证热床的农场工作流,请禁用此选项。',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3143,6 +3146,7 @@ export default {
     staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
+    staggerToPrinters: '分批发送到 {{count}} 台打印机',
   },
 
   // Backup

+ 34 - 1
frontend/src/pages/SettingsPage.tsx

@@ -772,7 +772,8 @@ export function SettingsPage() {
       settings.prometheus_token !== localSettings.prometheus_token ||
       (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
       (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
-      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5);
+      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
+      (settings.require_plate_clear ?? true) !== (localSettings.require_plate_clear ?? true);
 
     if (!hasChanges) {
       return;
@@ -847,6 +848,7 @@ export function SettingsPage() {
         user_notifications_enabled: localSettings.user_notifications_enabled,
         stagger_group_size: localSettings.stagger_group_size,
         stagger_interval_minutes: localSettings.stagger_interval_minutes,
+        require_plate_clear: localSettings.require_plate_clear,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -3386,6 +3388,37 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+          {/* Plate-Clear Confirmation */}
+          <Card>
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Shield className="w-4 h-4 text-bambu-green" />
+                {t('settings.plateClear', 'Plate-Clear Confirmation')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <div className="flex items-center justify-between">
+                <div className="flex-1 mr-4">
+                  <p className="text-sm text-white">
+                    {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.require_plate_clear ?? true}
+                    onChange={(e) => updateSetting('require_plate_clear', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+            </CardContent>
+          </Card>
+
           {/* Auto-Drying */}
           <Card>
             <CardHeader>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CVcX3HwW.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-C0fieME8.js"></script>
+    <script type="module" crossorigin src="/assets/index-CVcX3HwW.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
   </head>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů