Browse Source

fix: persist plate-clear gate so Auto Off power cycles can't bypass the queue confirmation (#961)

  With Auto Off enabled and another job queued, the smart plug cut power when a
  print finished and immediately re-powered the printer because the scheduler
  saw pending items. The printer booted fresh into IDLE and the next job
  auto-dispatched, bypassing the "Clear Plate & Start Next" confirmation.

  Root cause: the plate-clear gate lived only in PrinterManager._plate_cleared
  (in-memory set) and _is_printer_idle treated IDLE as unconditionally idle. On
  power cycle the in-memory flag was lost and the IDLE-on-boot state skipped
  the gate entirely.

  Fix:
  - Replace the in-memory flag with an awaiting_plate_clear column on the
    printers table, rehydrated into the PrinterManager at startup.
  - Set the flag in on_print_complete for completed/failed prints (not user
    cancellations); clear it on ack and on scheduler dispatch.
  - _is_printer_idle now short-circuits to not-idle whenever require_plate_clear
    is on and the flag is set, regardless of the currently reported state —
    so the gate holds through power cycles, Bambuddy restarts, and the printer
    booting back into IDLE.
  - /printers/{id}/clear-plate no longer requires the printer to report
    FINISH/FAILED; it accepts the ack whenever the flag is raised.
  - Frontend widgets (PrinterQueueWidget, Layout, BulkPrinterToolbar) gate on
    the flag rather than reported state.

  Tests: added regression tests for IDLE+awaiting=True (the #961 case) and
  full DB round-trip tests for the persistence layer.
maziggy 1 month ago
parent
commit
de7fff0be4

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b4] - Unreleased
 
 ### 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.
 
 

+ 9 - 4
backend/app/api/routes/printers.py

@@ -607,7 +607,7 @@ async def get_printer_status(
         heatbreak_fan_speed=state.heatbreak_fan_speed,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
-        plate_cleared=printer_manager.is_plate_cleared(printer_id),
+        awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
     )
 
@@ -2246,13 +2246,18 @@ async def clear_plate(
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(400, "Printer not connected")
 
+    # Accept the acknowledgment whenever the printer is awaiting it — not only when the
+    # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE
+    # but the awaiting flag persists, and the user still needs a way to ack it (#961).
     state = printer_manager.get_status(printer_id)
-    if not state or state.state not in ("FINISH", "FAILED"):
+    awaiting = printer_manager.is_awaiting_plate_clear(printer_id)
+    if not awaiting and (not state or state.state not in ("FINISH", "FAILED")):
         raise HTTPException(
-            400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
+            400,
+            f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})",
         )
 
-    printer_manager.set_plate_cleared(printer_id)
+    printer_manager.set_awaiting_plate_clear(printer_id, False)
 
     return {"success": True, "message": "Plate cleared, next print will start shortly"}
 

+ 3 - 0
backend/app/core/database.py

@@ -1296,6 +1296,9 @@ async def run_migrations(conn):
     # Migration: Add camera_rotation column to printers
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0")
 
+    # Migration: Add awaiting_plate_clear column to printers (#961)
+    await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN awaiting_plate_clear BOOLEAN DEFAULT FALSE NOT NULL")
+
     # Migration: Add REST/Webhook smart plug fields
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT")

+ 10 - 0
backend/app/main.py

@@ -2319,6 +2319,13 @@ async def on_print_complete(printer_id: int, data: dict):
         data = {**data, "status": "cancelled"}
     _user_stopped_printers.discard(printer_id)
 
+    # Raise the plate-clear gate for queued dispatch (#961). Only for completed/failed —
+    # user-cancelled prints don't require a plate-clear ack (nothing printed on the bed).
+    # Persisted to DB so the gate survives Auto Off power cycles and Bambuddy restarts.
+    _final_status = data.get("status", "completed")
+    if _final_status in ("completed", "failed"):
+        printer_manager.set_awaiting_plate_clear(printer_id, True)
+
     # MQTT relay - publish print complete
     try:
         printer_info = printer_manager.get_printer(printer_id)
@@ -3764,6 +3771,9 @@ async def lifespan(app: FastAPI):
     printer_manager.set_print_complete_callback(on_print_complete)
     printer_manager.set_ams_change_callback(on_ams_change)
 
+    # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
+    await printer_manager.load_awaiting_plate_clear_from_db()
+
     # Layer change callback for external camera timelapse
     async def on_layer_change(printer_id: int, layer_num: int):
         """Capture timelapse frame on layer change + first layer notification."""

+ 3 - 0
backend/app/models/printer.py

@@ -36,6 +36,9 @@ class Printer(Base):
     plate_detection_roi_y: Mapped[float | None] = mapped_column(Float, nullable=True)  # Y start %
     plate_detection_roi_w: Mapped[float | None] = mapped_column(Float, nullable=True)  # Width %
     plate_detection_roi_h: Mapped[float | None] = mapped_column(Float, nullable=True)  # Height %
+    # Queue: True after a print finishes/fails, until user acknowledges the plate is cleared.
+    # Persisted so the gate survives crashes and power cycles (issue #961).
+    awaiting_plate_clear: Mapped[bool] = mapped_column(Boolean, default=False)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 

+ 3 - 2
backend/app/schemas/printer.py

@@ -267,7 +267,8 @@ class PrinterStatus(BaseModel):
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
     developer_mode: bool | None = None
-    # Queue: user has acknowledged plate is cleared for next queued print
-    plate_cleared: bool = False
+    # Queue: printer is awaiting the user to acknowledge the build plate is cleared
+    # after a finished/failed print. Persisted across restarts (#961).
+    awaiting_plate_clear: bool = False
     # AMS drying support
     supports_drying: bool = False

+ 17 - 14
backend/app/services/print_scheduler.py

@@ -399,14 +399,14 @@ class PrintScheduler:
                 for pid in busy_printers:
                     state = printer_manager.get_status(pid)
                     connected = printer_manager.is_connected(pid)
-                    plate_cleared = printer_manager.is_plate_cleared(pid)
+                    awaiting = printer_manager.is_awaiting_plate_clear(pid)
                     state_name = state.state if state else "NO_STATUS"
                     logger.info(
-                        "Queue: printer %d not available — connected=%s, state=%s, plate_cleared=%s",
+                        "Queue: printer %d not available — connected=%s, state=%s, awaiting_plate_clear=%s",
                         pid,
                         connected,
                         state_name,
-                        plate_cleared,
+                        awaiting,
                     )
 
             # Auto-drying: start drying on idle printers that have no pending queue items
@@ -1117,19 +1117,22 @@ class PrintScheduler:
             logger.debug("Printer %d: no status available", printer_id)
             return False
 
-        # IDLE = ready for next print
-        # 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 (not require_plate_clear or printer_manager.is_plate_cleared(printer_id))
-        )
-        if not idle:
+        # Plate-clear gate: if the printer finished/failed a previous print and the user
+        # hasn't acknowledged the plate was cleared, the queue must not dispatch the next
+        # job — even if the printer currently reports IDLE. After Auto Off cycles the
+        # printer, it boots back into IDLE with no memory of the previous finish; without
+        # the persisted awaiting flag we'd bypass the confirmation prompt (#961).
+        if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer_id):
             logger.debug(
-                "Printer %d: not idle — state=%s, plate_cleared=%s",
+                "Printer %d: not idle — awaiting plate-clear acknowledgment (state=%s)",
                 printer_id,
                 state.state,
-                printer_manager.is_plate_cleared(printer_id),
             )
+            return False
+
+        idle = state.state in ("IDLE", "FINISH", "FAILED")
+        if not idle:
+            logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
         return idle
 
     async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
@@ -1825,8 +1828,8 @@ class PrintScheduler:
         item.started_at = datetime.now(timezone.utc)
         await db.commit()
 
-        # Consume the plate-cleared flag now that we're starting a print
-        printer_manager.consume_plate_cleared(item.printer_id)
+        # Clear the awaiting-plate-clear flag now that we're starting a new print
+        printer_manager.set_awaiting_plate_clear(item.printer_id, False)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
         # Start the print with AMS mapping, plate_id and print options

+ 49 - 11
backend/app/services/printer_manager.py

@@ -155,8 +155,10 @@ class PrinterManager:
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
-        # Track plate-cleared acknowledgments for queue flow
-        self._plate_cleared: set[int] = set()  # printer_ids where user confirmed plate is cleared
+        # Track printers awaiting plate-clear acknowledgment after a finished/failed print.
+        # Persisted to DB (printers.awaiting_plate_clear) so the gate survives restarts/power
+        # cycles — see issue #961. Loaded into this set at startup via load_awaiting_plate_clear_from_db().
+        self._awaiting_plate_clear: set[int] = set()
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
@@ -174,17 +176,53 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         self._current_print_user.pop(printer_id, None)
 
-    def set_plate_cleared(self, printer_id: int):
-        """Mark that user has cleared the build plate for this printer."""
-        self._plate_cleared.add(printer_id)
+    def is_awaiting_plate_clear(self, printer_id: int) -> bool:
+        """Return True when the printer finished/failed a print and is waiting for the
+        user to acknowledge the plate is cleared before the queue may dispatch the next job.
+        """
+        return printer_id in self._awaiting_plate_clear
+
+    def set_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
+        """Set/clear the awaiting-plate-clear gate and persist it to DB.
+
+        Persisted so the gate survives Bambuddy/printer restarts (#961): after Auto Off
+        cycles the printer, the printer boots into IDLE with no memory of the previous
+        finish, and without persistence the queue would bypass the confirmation prompt.
+        """
+        if awaiting:
+            self._awaiting_plate_clear.add(printer_id)
+        else:
+            self._awaiting_plate_clear.discard(printer_id)
+        # Only create the coroutine when there is a loop to run it on — otherwise Python
+        # emits "coroutine was never awaited" warnings (e.g. in sync unit tests).
+        if self._loop and self._loop.is_running():
+            self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
+
+    async def _persist_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
+        from backend.app.core.database import async_session
 
-    def is_plate_cleared(self, printer_id: int) -> bool:
-        """Check if user has confirmed the plate is cleared."""
-        return printer_id in self._plate_cleared
+        try:
+            async with async_session() as db:
+                printer = await db.get(Printer, printer_id)
+                if printer is not None:
+                    printer.awaiting_plate_clear = awaiting
+                    await db.commit()
+        except Exception as e:
+            logger.warning("Failed to persist awaiting_plate_clear for printer %d: %s", printer_id, e)
+
+    async def load_awaiting_plate_clear_from_db(self):
+        """Rehydrate the awaiting-plate-clear set from the printers table on startup."""
+        from backend.app.core.database import async_session
 
-    def consume_plate_cleared(self, printer_id: int):
-        """Clear the plate-cleared flag (called when scheduler starts next print)."""
-        self._plate_cleared.discard(printer_id)
+        try:
+            async with async_session() as db:
+                result = await db.execute(select(Printer.id).where(Printer.awaiting_plate_clear.is_(True)))
+                ids = {row[0] for row in result.all()}
+                self._awaiting_plate_clear = ids
+                if ids:
+                    logger.info("Loaded %d printer(s) awaiting plate-clear acknowledgment: %s", len(ids), sorted(ids))
+        except Exception as e:
+            logger.warning("Failed to load awaiting_plate_clear from DB: %s", e)
 
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""

+ 172 - 53
backend/tests/unit/test_scheduler_clear_plate.py

@@ -18,46 +18,163 @@ class TestPrinterManagerPlateCleared:
 
     def test_plate_cleared_initially_false(self, manager):
         """No printers should have plate cleared by default."""
-        assert not manager.is_plate_cleared(1)
-        assert not manager.is_plate_cleared(999)
+        assert not manager.is_awaiting_plate_clear(1)
+        assert not manager.is_awaiting_plate_clear(999)
 
     def test_set_plate_cleared(self, manager):
-        """Setting plate cleared should make is_plate_cleared return True."""
-        manager.set_plate_cleared(1)
-        assert manager.is_plate_cleared(1)
-        assert not manager.is_plate_cleared(2)
+        """Setting plate cleared should make is_awaiting_plate_clear return True."""
+        manager.set_awaiting_plate_clear(1, True)
+        assert manager.is_awaiting_plate_clear(1)
+        assert not manager.is_awaiting_plate_clear(2)
 
     def test_consume_plate_cleared(self, manager):
         """Consuming plate cleared should reset the flag."""
-        manager.set_plate_cleared(1)
-        assert manager.is_plate_cleared(1)
-        manager.consume_plate_cleared(1)
-        assert not manager.is_plate_cleared(1)
+        manager.set_awaiting_plate_clear(1, True)
+        assert manager.is_awaiting_plate_clear(1)
+        manager.set_awaiting_plate_clear(1, False)
+        assert not manager.is_awaiting_plate_clear(1)
 
     def test_consume_plate_cleared_idempotent(self, manager):
         """Consuming when not set should not raise."""
-        manager.consume_plate_cleared(1)  # Should not raise
-        assert not manager.is_plate_cleared(1)
+        manager.set_awaiting_plate_clear(1, False)  # Should not raise
+        assert not manager.is_awaiting_plate_clear(1)
 
     def test_set_plate_cleared_multiple_printers(self, manager):
         """Plate cleared should be tracked per printer."""
-        manager.set_plate_cleared(1)
-        manager.set_plate_cleared(3)
-        assert manager.is_plate_cleared(1)
-        assert not manager.is_plate_cleared(2)
-        assert manager.is_plate_cleared(3)
+        manager.set_awaiting_plate_clear(1, True)
+        manager.set_awaiting_plate_clear(3, True)
+        assert manager.is_awaiting_plate_clear(1)
+        assert not manager.is_awaiting_plate_clear(2)
+        assert manager.is_awaiting_plate_clear(3)
 
     def test_consume_only_affects_target_printer(self, manager):
         """Consuming plate cleared for one printer should not affect others."""
-        manager.set_plate_cleared(1)
-        manager.set_plate_cleared(2)
-        manager.consume_plate_cleared(1)
-        assert not manager.is_plate_cleared(1)
-        assert manager.is_plate_cleared(2)
+        manager.set_awaiting_plate_clear(1, True)
+        manager.set_awaiting_plate_clear(2, True)
+        manager.set_awaiting_plate_clear(1, False)
+        assert not manager.is_awaiting_plate_clear(1)
+        assert manager.is_awaiting_plate_clear(2)
+
+
+class TestAwaitingPlateClearPersistence:
+    """Verify the awaiting-plate-clear flag round-trips through the DB (#961)."""
+
+    @pytest.mark.asyncio
+    async def test_load_rehydrates_in_memory_set_from_db(self):
+        """Printers flagged in DB must re-appear in the in-memory set on startup."""
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        # Ensure all models are imported so Base.metadata includes them
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+        from backend.app.models.printer import Printer
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+        # Seed: two printers, one flagged awaiting, one not
+        async with session_maker() as db:
+            db.add_all(
+                [
+                    Printer(
+                        id=1,
+                        name="P1",
+                        serial_number="S1",
+                        ip_address="1.1.1.1",
+                        access_code="x",
+                        awaiting_plate_clear=True,
+                    ),
+                    Printer(
+                        id=2,
+                        name="P2",
+                        serial_number="S2",
+                        ip_address="2.2.2.2",
+                        access_code="y",
+                        awaiting_plate_clear=False,
+                    ),
+                ]
+            )
+            await db.commit()
+
+        # Point the manager's session factory at our in-memory DB and load
+        manager = PrinterManager()
+        with patch("backend.app.core.database.async_session", session_maker):
+            await manager.load_awaiting_plate_clear_from_db()
+
+        assert manager.is_awaiting_plate_clear(1) is True
+        assert manager.is_awaiting_plate_clear(2) is False
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_persist_writes_flag_to_db(self):
+        """set_awaiting_plate_clear + _persist writes the flag to the DB row."""
+        from sqlalchemy import select
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+        from backend.app.models.printer import Printer
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+        async with session_maker() as db:
+            db.add(
+                Printer(
+                    id=1,
+                    name="P1",
+                    serial_number="S1",
+                    ip_address="1.1.1.1",
+                    access_code="x",
+                    awaiting_plate_clear=False,
+                )
+            )
+            await db.commit()
+
+        manager = PrinterManager()
+        with patch("backend.app.core.database.async_session", session_maker):
+            await manager._persist_awaiting_plate_clear(1, True)
+
+        async with session_maker() as db:
+            row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
+            assert row.awaiting_plate_clear is True
+
+        with patch("backend.app.core.database.async_session", session_maker):
+            await manager._persist_awaiting_plate_clear(1, False)
+
+        async with session_maker() as db:
+            row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
+            assert row.awaiting_plate_clear is False
+
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_persist_missing_printer_does_not_raise(self):
+        """Persisting for a non-existent printer should be a silent no-op."""
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+        manager = PrinterManager()
+        with patch("backend.app.core.database.async_session", session_maker):
+            # Should not raise even though printer 999 does not exist
+            await manager._persist_awaiting_plate_clear(999, True)
+
+        await engine.dispose()
 
 
 class TestSchedulerIdleCheckWithPlateCleared:
-    """Test _is_printer_idle with plate-cleared flag interactions."""
+    """Test _is_printer_idle interactions with the awaiting-plate-clear flag (#961)."""
 
     @pytest.fixture
     def scheduler(self):
@@ -65,101 +182,103 @@ class TestSchedulerIdleCheckWithPlateCleared:
 
     @patch("backend.app.services.print_scheduler.printer_manager")
     def test_idle_state_is_idle(self, mock_pm, scheduler):
-        """Printer in IDLE state should be considered idle."""
+        """IDLE state with no awaiting flag → idle."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="IDLE")
+        mock_pm.is_awaiting_plate_clear.return_value = False
         assert scheduler._is_printer_idle(1) is True
 
     @patch("backend.app.services.print_scheduler.printer_manager")
     def test_running_state_not_idle(self, mock_pm, scheduler):
-        """Printer in RUNNING state should not be idle."""
+        """RUNNING state is never idle."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        mock_pm.is_awaiting_plate_clear.return_value = False
         assert scheduler._is_printer_idle(1) is False
 
     @patch("backend.app.services.print_scheduler.printer_manager")
-    def test_finish_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
-        """Printer in FINISH state should NOT be idle without plate cleared."""
+    def test_finish_state_not_idle_when_awaiting(self, mock_pm, scheduler):
+        """FINISH + awaiting plate-clear ack → NOT idle."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="FINISH")
-        mock_pm.is_plate_cleared.return_value = False
+        mock_pm.is_awaiting_plate_clear.return_value = True
         assert scheduler._is_printer_idle(1) is False
 
     @patch("backend.app.services.print_scheduler.printer_manager")
-    def test_finish_state_idle_with_plate_cleared(self, mock_pm, scheduler):
-        """Printer in FINISH state should be idle when plate is cleared."""
+    def test_finish_state_idle_when_acknowledged(self, mock_pm, scheduler):
+        """FINISH with flag cleared → idle."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="FINISH")
-        mock_pm.is_plate_cleared.return_value = True
+        mock_pm.is_awaiting_plate_clear.return_value = False
         assert scheduler._is_printer_idle(1) is True
 
     @patch("backend.app.services.print_scheduler.printer_manager")
-    def test_failed_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
-        """Printer in FAILED state should NOT be idle without plate cleared."""
+    def test_failed_state_not_idle_when_awaiting(self, mock_pm, scheduler):
+        """FAILED + awaiting → NOT idle."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="FAILED")
-        mock_pm.is_plate_cleared.return_value = False
+        mock_pm.is_awaiting_plate_clear.return_value = True
         assert scheduler._is_printer_idle(1) is False
 
     @patch("backend.app.services.print_scheduler.printer_manager")
-    def test_failed_state_idle_with_plate_cleared(self, mock_pm, scheduler):
-        """Printer in FAILED state should be idle when plate is cleared."""
+    def test_failed_state_idle_when_acknowledged(self, mock_pm, scheduler):
+        """FAILED with flag cleared → idle."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="FAILED")
-        mock_pm.is_plate_cleared.return_value = True
+        mock_pm.is_awaiting_plate_clear.return_value = False
         assert scheduler._is_printer_idle(1) is True
 
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_idle_state_not_idle_when_awaiting_survives_power_cycle(self, mock_pm, scheduler):
+        """Regression for #961: after Auto Off power-cycles the printer it boots into IDLE
+        with no memory of the previous finish. The persisted awaiting flag must still gate
+        the queue — IDLE + awaiting → NOT idle.
+        """
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="IDLE")
+        mock_pm.is_awaiting_plate_clear.return_value = True
+        assert scheduler._is_printer_idle(1) is False
+
     @patch("backend.app.services.print_scheduler.printer_manager")
     def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
-        """Disconnected printer should never be idle."""
         mock_pm.is_connected.return_value = False
         assert scheduler._is_printer_idle(1) is False
 
     @patch("backend.app.services.print_scheduler.printer_manager")
     def test_no_status_not_idle(self, mock_pm, scheduler):
-        """Printer with no status should not be idle."""
         mock_pm.is_connected.return_value = True
         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."""
+        """FINISH is idle when require_plate_clear=False, regardless of awaiting flag."""
         mock_pm.is_connected.return_value = True
         mock_pm.get_status.return_value = MagicMock(state="FINISH")
-        mock_pm.is_plate_cleared.return_value = False
+        mock_pm.is_awaiting_plate_clear.return_value = True
         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
+        mock_pm.is_awaiting_plate_clear.return_value = True
         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")
+        mock_pm.is_awaiting_plate_clear.return_value = False
         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")
+        mock_pm.is_awaiting_plate_clear.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_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)."""

+ 48 - 17
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" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={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" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -103,7 +103,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows passive link when FINISH but plateCleared is true', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" plateCleared={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={false} />);
 
       await waitFor(() => {
         const link = screen.getByRole('link');
@@ -114,7 +114,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows passive link when FAILED but plateCleared is true', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" plateCleared={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={false} />);
 
       await waitFor(() => {
         const link = screen.getByRole('link');
@@ -123,11 +123,31 @@ describe('PrinterQueueWidget - Clear Plate', () => {
 
       expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
     });
+
+    // Regression for #961: after Auto Off cycles the printer it boots into IDLE while
+    // 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} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    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} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('clear plate button shows queue info', () => {
     it('shows next item name in clear plate mode', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('First Print')).toBeInTheDocument();
@@ -135,7 +155,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows additional items badge in clear plate mode', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('+1')).toBeInTheDocument();
@@ -146,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" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -172,7 +192,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       );
 
       const user = userEvent.setup();
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -189,7 +209,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
 
   describe('empty queue', () => {
     it('renders nothing in FINISH state with no queue items', async () => {
-      const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
+      const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(container.querySelector('button')).not.toBeInTheDocument();
@@ -222,6 +242,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PLA'])}
         />
       );
@@ -242,6 +263,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PLA', 'PETG'])}
         />
       );
@@ -258,6 +280,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PLA'])}
         />
       );
@@ -274,7 +297,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       );
 
       render(
-        <PrinterQueueWidget printerId={1} printerState="FINISH" />
+        <PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />
       );
 
       await waitFor(() => {
@@ -319,6 +342,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PLA'])}
         />
       );
@@ -353,6 +377,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PETG'])}
         />
       );
@@ -390,6 +415,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PETG'])}
           loadedFilaments={new Set(['PETG:0000ff'])}
         />
@@ -410,6 +436,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PETG'])}
           loadedFilaments={new Set(['PETG:ffffff'])}
         />
@@ -446,6 +473,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PLA'])}
           loadedFilaments={new Set(['PLA:ff0000'])}
         />
@@ -465,6 +493,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PETG'])}
         />
       );
@@ -480,6 +509,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilaments={new Set(['PLA:000000'])}
         />
       );
@@ -518,6 +548,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         <PrinterQueueWidget
           printerId={1}
           printerState="FINISH"
+          awaitingPlateClear={true}
           loadedFilamentTypes={new Set(['PLA'])}
           loadedFilaments={new Set(['PLA:00ff00'])}
         />
@@ -531,7 +562,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
 
   describe('requirePlateClear setting', () => {
     it('shows passive link when requirePlateClear is false even in FINISH state', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" requirePlateClear={false} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={false} />);
 
       await waitFor(() => {
         const link = screen.getByRole('link');
@@ -542,7 +573,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows passive link when requirePlateClear is false even in FAILED state', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" requirePlateClear={false} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={false} />);
 
       await waitFor(() => {
         const link = screen.getByRole('link');
@@ -553,7 +584,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows clear plate button when requirePlateClear is true (explicit)', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" requirePlateClear={true} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -561,7 +592,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('shows clear plate button when requirePlateClear is not provided (defaults to true)', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
@@ -569,7 +600,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
     });
 
     it('still shows next item info in passive link when requirePlateClear is false', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" requirePlateClear={false} />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={false} />);
 
       await waitFor(() => {
         expect(screen.getByText('First Print')).toBeInTheDocument();
@@ -588,7 +619,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         http.get('/api/v1/queue/', () => HttpResponse.json(stagedItems)),
       );
 
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       // Should show the passive link (not the clear plate button)
       await waitFor(() => {
@@ -606,7 +637,7 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)),
       );
 
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
 
       await waitFor(() => {
         expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();

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

@@ -283,8 +283,9 @@ export interface PrinterStatus {
   firmware_version: string | null;   // Firmware version from MQTT
   // Developer LAN mode: true = enabled, false = disabled, null = unknown
   developer_mode: boolean | null;
-  // Queue: user has acknowledged plate is cleared for next queued print
-  plate_cleared: boolean;
+  // Queue: printer is awaiting user ack that the build plate was cleared after a
+  // finished/failed print. Persisted across restarts (#961).
+  awaiting_plate_clear: boolean;
   // AMS drying support
   supports_drying: boolean;
 }

+ 2 - 2
frontend/src/components/BulkPrinterToolbar.tsx

@@ -22,7 +22,7 @@ interface PrinterStatus {
   connected: boolean;
   state: string | null;
   hms_errors?: HMSError[];
-  plate_cleared?: boolean;
+  awaiting_plate_clear?: boolean;
 }
 
 interface BulkPrinterToolbarProps {
@@ -76,7 +76,7 @@ export function BulkPrinterToolbar({
   );
   const anyStoppable = anyRunning || anyPaused;
   const anyNeedsClearPlate = selectedStatuses.some(
-    ({ status }) => status?.connected && (status.state === 'FINISH' || status.state === 'FAILED') && !status.plate_cleared,
+    ({ status }) => !!(status?.connected && status.awaiting_plate_clear),
   );
   const anyWithHMS = selectedStatuses.some(({ status }) => {
     if (!status?.connected || !status.hms_errors) return false;

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

@@ -244,7 +244,7 @@ export function Layout() {
   const needsClearPlate = printerStatusQueries.some(result => {
     const status = result.data;
     if (!status) return false;
-    return (status.state === 'FINISH' || status.state === 'FAILED') && !status.plate_cleared;
+    return !!status.awaiting_plate_clear;
   });
 
   // Calculate debug duration client-side for real-time updates

+ 13 - 8
frontend/src/components/PrinterQueueWidget.tsx

@@ -12,14 +12,15 @@ import { filterCompatibleQueueItems } from '../utils/printer';
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerModel?: string | null;
+  /** @deprecated use awaitingPlateClear — kept so existing callers/tests still compile */
   printerState?: string | null;
-  plateCleared?: boolean;
+  awaitingPlateClear?: boolean;
   requirePlateClear?: boolean;
   loadedFilamentTypes?: Set<string>;
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
 }
 
-export function PrinterQueueWidget({ printerId, printerModel, printerState, plateCleared, requirePlateClear = true, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, awaitingPlateClear, requirePlateClear = true, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -42,13 +43,14 @@ export function PrinterQueueWidget({ printerId, printerModel, printerState, plat
     },
   });
 
-  // Reset mutation state when printer starts a new print cycle so the button
-  // is clickable again when the next print finishes (fixes #912)
+  // Reset mutation state when the awaiting flag clears so the button is clickable
+  // again after the next finished print (fixes #912). The flag is the authoritative
+  // signal — state alone is not reliable across power cycles (#961).
   useEffect(() => {
-    if (printerState !== 'FINISH' && printerState !== 'FAILED') {
+    if (!awaitingPlateClear) {
       clearPlateMutation.reset();
     }
-  }, [printerState, clearPlateMutation]);
+  }, [awaitingPlateClear, clearPlateMutation]);
 
   // Filter queue to items this printer can actually print (filament type + color check)
   const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
@@ -63,8 +65,11 @@ export function PrinterQueueWidget({ printerId, printerModel, printerState, plat
 
   const nextAutoItem = autoDispatchQueue[0];
   const nextItem = compatibleQueue?.[0];
-  // Only prompt "Clear Plate & Start Next" when there are auto-dispatchable items
-  const needsClearPlate = requirePlateClear && (printerState === 'FINISH' || printerState === 'FAILED') && !plateCleared && autoDispatchQueue.length > 0;
+  // Prompt "Clear Plate & Start Next" whenever the backend flags the printer as awaiting
+  // acknowledgment. Don't gate on reported state: after Auto Off cycles the printer, it
+  // boots into IDLE while still awaiting — the prompt must survive that (#961). The flag
+  // is cleared by the backend on ack or when the next print dispatches.
+  const needsClearPlate = requirePlateClear && !!awaitingPlateClear && autoDispatchQueue.length > 0;
 
   if (needsClearPlate) {
     const displayItem = nextAutoItem || nextItem;

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

@@ -2614,7 +2614,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} requirePlateClear={requirePlateClear} loadedFilamentTypes={loadedFilamentTypes} loadedFilaments={loadedFilaments} />
+                <PrinterQueueWidget printerId={printer.id} printerModel={printer.model} printerState={status.state} awaitingPlateClear={status.awaiting_plate_clear} requirePlateClear={requirePlateClear} loadedFilamentTypes={loadedFilamentTypes} loadedFilaments={loadedFilaments} />
               </>
             )}
 
@@ -5730,7 +5730,7 @@ export function PrintersPage() {
         case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE';
         case 'pause': return status.state === 'RUNNING';
         case 'resume': return status.state === 'PAUSE';
-        case 'clearPlate': return (status.state === 'FINISH' || status.state === 'FAILED') && !(status as { plate_cleared?: boolean }).plate_cleared;
+        case 'clearPlate': return !!(status as { awaiting_plate_clear?: boolean }).awaiting_plate_clear;
         case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0;
         default: return false;
       }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DfEBMSZy.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-MZV8iNil.js"></script>
+    <script type="module" crossorigin src="/assets/index-DfEBMSZy.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Czpqfgna.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff