Parcourir la source

feat(mqtt): publish the plate-clear gate and add a notification for it (#2525)

When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".

The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.

Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.

A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.

Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract

Translated in all locales; wiki updated. Covered by backend and
frontend tests.
maziggy il y a 1 mois
Parent
commit
8fd1f884dc

Fichier diff supprimé car celui-ci est trop grand
+ 2 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/notifications.py

@@ -58,6 +58,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
+        "on_plate_clear_required": provider.on_plate_clear_required,
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
@@ -139,6 +140,7 @@ async def create_notification_provider(
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         # Build plate detection
         on_plate_not_empty=provider_data.on_plate_not_empty,
+        on_plate_clear_required=provider_data.on_plate_clear_required,
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete

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

@@ -3798,6 +3798,18 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
 
+    # Migration: plate-clear-required notification opt-in (#2525). Off by
+    # default — it fires after every print, at the same moment as the
+    # print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
+    if is_sqlite():
+        await _safe_execute(
+            conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
+        )
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
+        )
+
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),

+ 7 - 1
backend/app/main.py

@@ -1242,7 +1242,13 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     try:
         printer_info = printer_manager.get_printer(printer_id)
         if printer_info:
-            await mqtt_relay.on_printer_status(printer_id, state, printer_info.name, printer_info.serial_number)
+            await mqtt_relay.on_printer_status(
+                printer_id,
+                state,
+                printer_info.name,
+                printer_info.serial_number,
+                printer_manager.is_awaiting_plate_clear(printer_id),
+            )
     except Exception:
         pass  # Don't fail status callback if MQTT fails
 

+ 2 - 0
backend/app/models/notification.py

@@ -84,6 +84,8 @@ class NotificationProvider(Base):
 
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
+    # Off by default: fires after every print, alongside the print-complete alert (#2525)
+    on_plate_clear_required = Column(Boolean, default=False)  # Print ended, queue gated until plate is confirmed clear
 
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print

+ 6 - 0
backend/app/models/notification_template.py

@@ -85,6 +85,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Plate Not Empty - Print Paused",
         "body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
     },
+    {
+        "event_type": "plate_clear_required",
+        "name": "Plate Clear Required",
+        "title_template": "Plate Clear Required",
+        "body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
+    },
     {
         "event_type": "filament_low",
         "name": "Filament Low",

+ 4 - 0
backend/app/schemas/notification.py

@@ -63,6 +63,9 @@ class NotificationProviderBase(BaseModel):
 
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
+    on_plate_clear_required: bool = Field(
+        default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
+    )
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
@@ -147,6 +150,7 @@ class NotificationProviderUpdate(BaseModel):
 
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
+    on_plate_clear_required: bool | None = None
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None

+ 46 - 1
backend/app/services/mqtt_relay.py

@@ -240,7 +240,14 @@ class MQTTRelayService:
     # Printer Events
     # =========================================================================
 
-    async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):
+    async def on_printer_status(
+        self,
+        printer_id: int,
+        state: Any,
+        printer_name: str,
+        printer_serial: str,
+        awaiting_plate_clear: bool = False,
+    ):
         """Publish printer status change (throttled to 1 update/sec per printer)."""
         if not self.enabled or not self.connected:
             return
@@ -275,6 +282,13 @@ class MQTTRelayService:
             "big_fan1_speed": state.big_fan1_speed,
             "big_fan2_speed": state.big_fan2_speed,
             "heatbreak_fan_speed": state.heatbreak_fan_speed,
+            # Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
+            # Web UI already receives via printer_state_to_dict, so an external
+            # automation can tell "finished" from "finished and still waiting for
+            # someone to clear the bed". Edge changes are also published on
+            # printers/{serial}/plate_clear — this topic only refreshes when the
+            # printer pushes telemetry, which stops entirely after Auto Off.
+            "awaiting_plate_clear": awaiting_plate_clear,
         }
 
         self._publish(
@@ -283,6 +297,37 @@ class MQTTRelayService:
             retain=True,
         )
 
+    async def on_plate_clear_state(
+        self,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+        awaiting: bool,
+    ):
+        """Publish the plate-clear gate as it flips (#2525).
+
+        Retained, unlike the other per-printer event topics, because this is a
+        *state* an automation needs on subscribe rather than a moment it might
+        have missed. The status topic carries the same field, but only refreshes
+        when the printer pushes telemetry — after Auto Off cycles the printer the
+        retained status payload would sit at ``awaiting_plate_clear: false``
+        indefinitely while the gate is in fact still up.
+        """
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/plate_clear",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "awaiting": awaiting,
+                "timestamp": datetime.now(timezone.utc).isoformat(),
+            },
+            retain=True,
+        )
+
     async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
         """Publish printer came online event."""
         if not self.enabled or not self.connected:

+ 33 - 0
backend/app/services/notification_service.py

@@ -1381,6 +1381,39 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_plate_clear_required(
+        self,
+        printer_id: int,
+        printer_name: str,
+        db: AsyncSession,
+    ):
+        """Handle plate-clear-required event — a print ended and the queue is gated (#2525).
+
+        Distinct from ``on_plate_not_empty``, which is the camera check *before* a
+        print starts. This one fires on the rising edge of the Bambuddy-side
+        awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
+        and the next queued job can't dispatch until someone confirms the bed is
+        free. Off by default on every provider: it lands at the same moment as the
+        print-complete notification, so opting in is a deliberate choice.
+        """
+        providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
+        if not providers:
+            return
+
+        variables = {"printer": printer_name}
+
+        title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "plate_clear_required",
+            printer_id,
+            printer_name,
+            variables=variables,
+        )
+
     async def on_filament_low(
         self,
         printer_id: int,

+ 46 - 0
backend/app/services/printer_manager.py

@@ -377,6 +377,13 @@ class PrinterManager:
         UI without it. Centralised here so every current AND future caller is
         covered without each one having to remember to broadcast.
         """
+        # Callers re-assert the current value routinely (the queue clears the gate
+        # on every dispatch, whether or not it was up), so the outward-facing
+        # emissions below are edge-triggered — an MQTT subscriber or a phone
+        # notification must not see a "plate cleared" for a plate that was never
+        # dirty. Persistence and the WebSocket broadcast stay unconditional: they
+        # are idempotent and predate this (#961/#1128).
+        changed = awaiting != (printer_id in self._awaiting_plate_clear)
         if awaiting:
             self._awaiting_plate_clear.add(printer_id)
         else:
@@ -386,6 +393,45 @@ class PrinterManager:
         if self._loop and self._loop.is_running():
             self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
             self._schedule_async(self._broadcast_status_change(printer_id))
+            if changed:
+                self._schedule_async(self._emit_plate_clear_change(printer_id, awaiting))
+
+    async def _emit_plate_clear_change(self, printer_id: int, awaiting: bool) -> None:
+        """Relay a plate-clear gate transition to MQTT and notifications (#2525).
+
+        The flag is Bambuddy-side, so nothing about it reaches an external
+        automation on its own — the printer's own MQTT push knows only
+        RUNNING/PAUSE/FAILED/FINISH/IDLE. Emitted from here rather than from the
+        three call sites so every current and future caller is covered, the same
+        reasoning as the WebSocket broadcast above.
+
+        Imports are local: ``mqtt_relay`` and ``notification_service`` both sit
+        above this module in the dependency order.
+        """
+        printer = self.get_printer(printer_id)
+        if not printer:
+            return
+
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            await mqtt_relay.on_plate_clear_state(printer_id, printer.name, printer.serial_number, awaiting)
+        except Exception as e:
+            logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
+
+        # Only the rising edge is worth a notification — "the bed is now free"
+        # is not an action item, and the queue clears the gate by itself.
+        if not awaiting:
+            return
+
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.services.notification_service import notification_service
+
+            async with async_session() as db:
+                await notification_service.on_plate_clear_required(printer_id, printer.name, db)
+        except Exception as e:
+            logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
 
     async def _broadcast_status_change(self, printer_id: int) -> None:
         """Emit a ``printer_status`` WebSocket update for this printer (#1128).

+ 79 - 0
backend/tests/integration/test_plate_clear_notification.py

@@ -0,0 +1,79 @@
+"""Integration tests for the plate-clear-required notification (#2525).
+
+The event is opt-in: it fires after every print, at the same moment as the
+print-complete alert, so a provider only receives it when the toggle is
+explicitly enabled.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.notification_service import notification_service
+
+
+class TestPlateClearNotificationDispatch:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sends_to_a_provider_that_opted_in(self, notification_provider_factory, db_session):
+        await notification_provider_factory(name="Opted In", on_plate_clear_required=True)
+
+        send = AsyncMock()
+        with patch.object(notification_service, "_send_to_providers", send):
+            await notification_service.on_plate_clear_required(1, "Workshop", db_session)
+
+        assert send.await_count == 1
+        providers = send.await_args.args[0]
+        assert [p.name for p in providers] == ["Opted In"]
+        assert send.await_args.args[4] == "plate_clear_required"
+        # _build_message_from_template folds in app_name/timestamp; the caller's
+        # own variable is what matters here.
+        assert send.await_args.kwargs["variables"]["printer"] == "Workshop"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_silent_for_a_provider_that_did_not_opt_in(self, notification_provider_factory, db_session):
+        await notification_provider_factory(name="Default Off", on_plate_clear_required=False)
+
+        send = AsyncMock()
+        with patch.object(notification_service, "_send_to_providers", send):
+            await notification_service.on_plate_clear_required(1, "Workshop", db_session)
+
+        send.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skips_a_provider_scoped_to_a_different_printer(self, notification_provider_factory, db_session):
+        await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=99)
+
+        send = AsyncMock()
+        with patch.object(notification_service, "_send_to_providers", send):
+            await notification_service.on_plate_clear_required(1, "Workshop", db_session)
+
+        send.assert_not_awaited()
+
+
+class TestPlateClearProviderField:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_off_on_create_and_round_trips_on_update(self, async_client: AsyncClient):
+        create = await async_client.post(
+            "/api/v1/notifications/",
+            json={
+                "name": "Plate Clear Test",
+                "provider_type": "ntfy",
+                "enabled": True,
+                "config": {"server": "https://ntfy.sh", "topic": "test-topic"},
+            },
+        )
+        assert create.status_code in (200, 201), create.text
+        provider_id = create.json()["id"]
+        assert create.json()["on_plate_clear_required"] is False
+
+        update = await async_client.patch(
+            f"/api/v1/notifications/{provider_id}",
+            json={"on_plate_clear_required": True},
+        )
+        assert update.status_code == 200, update.text
+        assert update.json()["on_plate_clear_required"] is True

+ 253 - 0
backend/tests/unit/test_plate_clear_mqtt_notification.py

@@ -0,0 +1,253 @@
+"""Tests for the plate-clear gate reaching MQTT and notifications (#2525).
+
+``awaiting_plate_clear`` is a Bambuddy-side flag (#961) — the printer's own MQTT
+push only ever reports RUNNING/PAUSE/FAILED/FINISH/IDLE, so an external
+automation had no way to tell "finished" from "finished and still waiting for
+someone to clear the bed". It now rides along on the retained per-printer status
+topic, gets its own retained topic on every transition, and can raise a
+notification on the rising edge.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.mqtt_relay import MQTTRelayService
+from backend.app.services.printer_manager import PrinterManager
+
+
+def _relay() -> MQTTRelayService:
+    relay = MQTTRelayService()
+    relay.enabled = True
+    relay.connected = True
+    relay.client = MagicMock()
+    return relay
+
+
+def _state() -> SimpleNamespace:
+    return SimpleNamespace(
+        connected=True,
+        state="FINISH",
+        progress=100,
+        remaining_time=0,
+        layer_num=250,
+        total_layers=250,
+        current_print="benchy.gcode.3mf",
+        subtask_name="benchy",
+        gcode_file="benchy.gcode",
+        temperatures={"nozzle": 40, "bed": 30},
+        wifi_signal="-50dBm",
+        chamber_light="off",
+        speed_level=2,
+        cooling_fan_speed=0,
+        big_fan1_speed=0,
+        big_fan2_speed=0,
+        heatbreak_fan_speed=0,
+    )
+
+
+def _published(relay: MQTTRelayService) -> list[tuple[str, dict, bool]]:
+    """Decode every publish as (topic, payload, retain)."""
+    import json
+
+    calls = []
+    for call in relay.client.publish.call_args_list:
+        topic = call.args[0]
+        payload = json.loads(call.args[1])
+        calls.append((topic, payload, call.kwargs.get("retain", False)))
+    return calls
+
+
+class TestStatusPayload:
+    @pytest.mark.asyncio
+    async def test_status_payload_carries_awaiting_plate_clear(self):
+        relay = _relay()
+
+        await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001", True)
+
+        topic, payload, retain = _published(relay)[0]
+        assert topic == "bambuddy/printers/01P00A000000001/status"
+        assert payload["awaiting_plate_clear"] is True
+        assert retain is True
+
+    @pytest.mark.asyncio
+    async def test_status_payload_defaults_to_not_awaiting(self):
+        relay = _relay()
+
+        await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001")
+
+        _, payload, _ = _published(relay)[0]
+        assert payload["awaiting_plate_clear"] is False
+        # The pre-existing telemetry fields must survive the addition.
+        assert payload["state"] == "FINISH"
+        assert payload["progress"] == 100
+
+
+class TestPlateClearTopic:
+    @pytest.mark.asyncio
+    async def test_publishes_retained_state_on_its_own_topic(self):
+        relay = _relay()
+
+        await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
+
+        topic, payload, retain = _published(relay)[0]
+        assert topic == "bambuddy/printers/01S00C000000003/plate_clear"
+        assert payload["awaiting"] is True
+        assert payload["printer_id"] == 3
+        assert payload["printer_name"] == "P1S"
+        assert payload["printer_serial"] == "01S00C000000003"
+        # Retained so a subscriber that connects later learns the current state
+        # instead of waiting for the next transition.
+        assert retain is True
+
+    @pytest.mark.asyncio
+    async def test_honours_the_configured_topic_prefix(self):
+        relay = _relay()
+        relay.topic_prefix = "farm/bambuddy"
+
+        await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", False)
+
+        topic, payload, _ = _published(relay)[0]
+        assert topic == "farm/bambuddy/printers/01S00C000000003/plate_clear"
+        assert payload["awaiting"] is False
+
+    @pytest.mark.asyncio
+    async def test_silent_when_relay_is_disabled(self):
+        relay = _relay()
+        relay.enabled = False
+
+        await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
+
+        relay.client.publish.assert_not_called()
+
+
+class TestEdgeTriggering:
+    """The setter is re-asserted routinely (the queue clears the gate on every
+    dispatch), so outward-facing emissions must fire on transitions only."""
+
+    def _manager(self) -> PrinterManager:
+        manager = PrinterManager()
+        loop = MagicMock()
+        loop.is_running.return_value = True
+        manager._loop = loop
+        return manager
+
+    def test_emits_on_the_rising_edge(self):
+        manager = self._manager()
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, True)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert len(emitted) == 1
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+    def test_does_not_re_emit_when_already_awaiting(self):
+        manager = self._manager()
+        manager._awaiting_plate_clear.add(7)
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, True)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert emitted == []
+        # Persistence and the WebSocket broadcast are idempotent and stay unconditional.
+        assert len(scheduled.call_args_list) == 2
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+    def test_does_not_emit_when_clearing_a_gate_that_was_never_up(self):
+        manager = self._manager()
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, False)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert emitted == []
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+    def test_emits_on_the_falling_edge(self):
+        manager = self._manager()
+        manager._awaiting_plate_clear.add(7)
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, False)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert len(emitted) == 1
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+
+class TestEmitFanOut:
+    @pytest.mark.asyncio
+    async def test_rising_edge_publishes_and_notifies(self):
+        manager = PrinterManager()
+        manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
+
+        publish = AsyncMock()
+        notify = AsyncMock()
+        with (
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_plate_clear_required",
+                notify,
+            ),
+        ):
+            await manager._emit_plate_clear_change(7, True)
+
+        publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", True)
+        assert notify.await_count == 1
+        assert notify.await_args.args[:2] == (7, "X1C")
+
+    @pytest.mark.asyncio
+    async def test_falling_edge_publishes_but_does_not_notify(self):
+        manager = PrinterManager()
+        manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
+
+        publish = AsyncMock()
+        notify = AsyncMock()
+        with (
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_plate_clear_required",
+                notify,
+            ),
+        ):
+            await manager._emit_plate_clear_change(7, False)
+
+        publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", False)
+        notify.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_unknown_printer_is_a_no_op(self):
+        manager = PrinterManager()
+
+        publish = AsyncMock()
+        with patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish):
+            await manager._emit_plate_clear_change(999, True)
+
+        publish.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_mqtt_failure_does_not_block_the_notification(self):
+        manager = PrinterManager()
+        manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
+
+        notify = AsyncMock()
+        with (
+            patch(
+                "backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state",
+                AsyncMock(side_effect=RuntimeError("broker down")),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_plate_clear_required",
+                notify,
+            ),
+        ):
+            await manager._emit_plate_clear_change(7, True)
+
+        assert notify.await_count == 1

+ 20 - 4
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -105,6 +105,18 @@ def _fake_state(**overrides):
     return SimpleNamespace(**base)
 
 
+def _scheduled_names(mock) -> list[str]:
+    """Coroutine names passed to the patched ``_schedule_async``.
+
+    Asserting on names rather than a bare call count keeps this file pinned to
+    #1128's contract (persist + broadcast on every flag mutation) without
+    breaking every time another emission is hung off the same setter — #2525
+    added an edge-triggered MQTT/notification relay, which is covered by its
+    own test module.
+    """
+    return [call.args[0].__qualname__.rsplit(".", 1)[-1] for call in mock.call_args_list]
+
+
 class TestSchedulingFromSetAwaitingPlateClear:
     """The hook from the public flag-mutation method into the broadcast."""
 
@@ -119,8 +131,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
         with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
             manager.set_awaiting_plate_clear(7, True)
 
-        # Two coroutines: persist + broadcast. Order doesn't matter.
-        assert scheduled.call_count == 2
+        # Persist + broadcast, in either order.
+        names = _scheduled_names(scheduled)
+        assert "_persist_awaiting_plate_clear" in names
+        assert "_broadcast_status_change" in names
 
     def test_does_not_schedule_when_no_loop_attached(self, manager):
         """Sync unit-test path (no loop attached): nothing must be
@@ -159,8 +173,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
             scheduled.reset_mock()
             manager.set_awaiting_plate_clear(7, False)
 
-        # Each flip = persist + broadcast = 2 calls.
-        assert scheduled.call_count == 2
+        # The False flip persists and broadcasts just like the True flip did.
+        names = _scheduled_names(scheduled)
+        assert "_persist_awaiting_plate_clear" in names
+        assert "_broadcast_status_change" in names
 
 
 class TestBroadcastStatusChange:

+ 71 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -48,6 +48,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
     on_ams_ht_humidity_high: false,
     on_ams_ht_temperature_high: false,
     on_plate_not_empty: true,
+    on_plate_clear_required: false,
     on_bed_cooled: false,
     on_first_layer_complete: false,
     on_queue_job_added: false,
@@ -223,6 +224,76 @@ describe('AddNotificationModal — ntfy Priority (#990)', () => {
   });
 });
 
+describe('AddNotificationModal — plate clear required (#2525)', () => {
+  it('renders the toggle off by default', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    await screen.findByDisplayValue('My ntfy');
+
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i));
+    expect(toggle).toBeDefined();
+    expect(toggle).toHaveAttribute('aria-checked', 'false');
+  });
+
+  it('pre-fills the toggle from the existing provider value', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ on_plate_clear_required: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    await screen.findByDisplayValue('My ntfy');
+
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
+    expect(toggle).toHaveAttribute('aria-checked', 'true');
+  });
+
+  it('persists on_plate_clear_required on save', async () => {
+    let captured: unknown = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = await request.json();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+
+    await screen.findByDisplayValue('My ntfy');
+
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
+    await user.click(toggle);
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+    const payload = captured as Record<string, unknown>;
+    expect(payload.on_plate_clear_required).toBe(true);
+  });
+
+  it('lists the event in the ntfy priority section once enabled', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ on_plate_clear_required: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    const sectionHeader = await screen.findByText(/ntfy priority/i);
+    const sectionRoot = sectionHeader.closest('div')!;
+    expect(within(sectionRoot).getByText(/plate clear required/i)).toBeInTheDocument();
+  });
+});
+
 describe('AddNotificationModal — stock alert toggles', () => {
   it('renders Inventory Alerts section with both stock alert toggles', async () => {
     render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);

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

@@ -2493,6 +2493,7 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   on_plate_not_empty: boolean;
+  on_plate_clear_required: boolean;
   // Bed cooled
   on_bed_cooled: boolean;
   // First layer complete
@@ -2552,6 +2553,7 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  on_plate_clear_required?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
   // First layer complete
@@ -2604,6 +2606,7 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  on_plate_clear_required?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
   // First layer complete

+ 10 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -43,6 +43,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
   const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
+  const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
 
@@ -188,6 +189,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_maintenance_due: onMaintenanceDue,
       on_stock_reorder_alert: onStockReorderAlert,
       on_stock_break_alert: onStockBreakAlert,
+      on_plate_clear_required: onPlateClearRequired,
       on_bed_cooled: onBedCooled,
       on_first_layer_complete: onFirstLayerComplete,
     };
@@ -579,6 +581,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.plateClearRequiredDescription')}</span>
+                  </div>
+                  <Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
+                </div>
                 <div className="flex items-center justify-between col-span-2">
                   <div>
                     <span className="text-sm text-white">{t('notifications.bedCooled')}</span>
@@ -652,6 +661,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
+              if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -123,6 +123,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_print_complete && (
               <span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">{t('notifications.complete')}</span>
             )}
+            {provider.on_plate_clear_required && (
+              <span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs rounded">{t('notifications.plateClear')}</span>
+            )}
             {provider.on_print_failed && (
               <span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
             )}
@@ -288,6 +291,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.plateClearRequired')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.plateClearRequiredDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_plate_clear_required ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_plate_clear_required: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <div>
                     <p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>

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

@@ -5498,6 +5498,9 @@ export default {
     printStarted: 'Druck gestartet',
     plateNotEmpty: 'Platte nicht leer',
     plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
+    plateClearRequired: 'Platte freigeben',
+    plateClearRequiredDescription: 'Druck beendet, Warteschlange wartet auf Bestätigung',
+    plateClear: 'Platte freigeben',
     printCompleted: 'Druck abgeschlossen',
     bedCooledLabel: 'Bett abgekühlt',
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',

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

@@ -5542,6 +5542,9 @@ export default {
     printStarted: 'Print Started',
     plateNotEmpty: 'Plate Not Empty',
     plateNotEmptyDescription: 'Objects detected before print',
+    plateClearRequired: 'Plate Clear Required',
+    plateClearRequiredDescription: 'Print finished, queue waits for plate confirmation',
+    plateClear: 'Plate Clear',
     printCompleted: 'Print Completed',
     bedCooledLabel: 'Bed Cooled',
     bedCooledDescription: 'Bed cooled below threshold after print',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -5507,6 +5507,9 @@ export default {
     printStarted: 'Impresión iniciada',
     plateNotEmpty: 'Cama no vacía',
     plateNotEmptyDescription: 'Objetos detectados antes de la impresión',
+    plateClearRequired: 'Confirmar cama libre',
+    plateClearRequiredDescription: 'Impresión terminada, la cola espera confirmación',
+    plateClear: 'Cama libre',
     printCompleted: 'Impresión completada',
     bedCooledLabel: 'Cama enfriada',
     bedCooledDescription: 'La cama se enfrió por debajo del umbral tras la impresión',

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

@@ -5488,6 +5488,9 @@ export default {
     printStarted: 'Impression démarrée',
     plateNotEmpty: 'Plateau non vide',
     plateNotEmptyDescription: 'Objets détectés avant l\'impression',
+    plateClearRequired: 'Plateau à libérer',
+    plateClearRequiredDescription: 'Impression terminée, la file attend la confirmation',
+    plateClear: 'Plateau libre',
     printCompleted: 'Impression terminée',
     bedCooledLabel: 'Plateau refroidi',
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',

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

@@ -5487,6 +5487,9 @@ export default {
     printStarted: 'Stampa avviata',
     plateNotEmpty: 'Piatto non vuoto',
     plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
+    plateClearRequired: 'Conferma piatto libero',
+    plateClearRequiredDescription: 'Stampa finita, la coda attende conferma',
+    plateClear: 'Piatto libero',
     printCompleted: 'Stampa completata',
     bedCooledLabel: 'Piatto raffreddato',
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',

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

@@ -5499,6 +5499,9 @@ export default {
     printStarted: '印刷開始',
     plateNotEmpty: 'プレートが空でない',
     plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',
+    plateClearRequired: 'プレート確認が必要',
+    plateClearRequiredDescription: '印刷完了、キューはプレート確認を待機中',
+    plateClear: 'プレート確認',
     printCompleted: '印刷完了',
     bedCooledLabel: 'ベッド冷却済み',
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -5219,6 +5219,9 @@ export default {
     printStarted: '인쇄 시작됨',
     plateNotEmpty: '플레이트 비어 있지 않음',
     plateNotEmptyDescription: '인쇄 전 개체 감지됨',
+    plateClearRequired: '플레이트 비움 확인 필요',
+    plateClearRequiredDescription: '인쇄 완료, 대기열이 플레이트 확인을 기다림',
+    plateClear: '플레이트 확인',
     printCompleted: '인쇄 완료됨',
     bedCooledLabel: '베드 냉각됨',
     bedCooledDescription: '인쇄 후 베드가 임계값 이하로 냉각됨',

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

@@ -5487,6 +5487,9 @@ export default {
     printStarted: 'Impressão Iniciada',
     plateNotEmpty: 'Mesa Não Vazia',
     plateNotEmptyDescription: 'Objetos detectados antes da impressão',
+    plateClearRequired: 'Confirmar mesa livre',
+    plateClearRequiredDescription: 'Impressão concluída, a fila aguarda confirmação',
+    plateClear: 'Mesa livre',
     printCompleted: 'Impressão Concluída',
     bedCooledLabel: 'Mesa Resfriada',
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -5206,6 +5206,9 @@ export default {
     printStarted: "Печать началась",
     plateNotEmpty: "Пластина не пуста",
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
+    plateClearRequired: "Требуется подтверждение стола",
+    plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
+    plateClear: "Стол свободен",
     printCompleted: "Печать завершена",
     bedCooledLabel: "Стол остыл",
     bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -5455,6 +5455,9 @@ export default {
     printStarted: 'Baskı Başladı',
     plateNotEmpty: 'Plaka Boş Değil',
     plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
+    plateClearRequired: 'Tabla onayı gerekli',
+    plateClearRequiredDescription: 'Baskı bitti, kuyruk tabla onayını bekliyor',
+    plateClear: 'Tabla onayı',
     printCompleted: 'Baskı Tamamlandı',
     bedCooledLabel: 'Tabla Soğudu',
     bedCooledDescription: 'Baskıdan sonra tabla eşiğin altına soğudu',

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

@@ -5487,6 +5487,9 @@ export default {
     printStarted: '打印已开始',
     plateNotEmpty: '热床非空',
     plateNotEmptyDescription: '打印前检测到物体',
+    plateClearRequired: '需要确认热床已清空',
+    plateClearRequiredDescription: '打印完成,队列等待热床确认',
+    plateClear: '热床确认',
     printCompleted: '打印已完成',
     bedCooledLabel: '热床已冷却',
     bedCooledDescription: '打印后热床温度降至阈值以下',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5487,6 +5487,9 @@ export default {
     printStarted: '列印已開始',
     plateNotEmpty: '熱床非空',
     plateNotEmptyDescription: '列印前偵測到物體',
+    plateClearRequired: '需要確認熱床已清空',
+    plateClearRequiredDescription: '列印完成,佇列等待熱床確認',
+    plateClear: '熱床確認',
     printCompleted: '列印已完成',
     bedCooledLabel: '熱床已冷卻',
     bedCooledDescription: '列印後熱床溫度降至閾值以下',

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-BNeeHAqi.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff