Просмотр исходного кода

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 1 месяц назад
Родитель
Сommit
8fd1f884dc
31 измененных файлов с 647 добавлено и 7 удалено
  1. 2 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/notifications.py
  3. 12 0
      backend/app/core/database.py
  4. 7 1
      backend/app/main.py
  5. 2 0
      backend/app/models/notification.py
  6. 6 0
      backend/app/models/notification_template.py
  7. 4 0
      backend/app/schemas/notification.py
  8. 46 1
      backend/app/services/mqtt_relay.py
  9. 33 0
      backend/app/services/notification_service.py
  10. 46 0
      backend/app/services/printer_manager.py
  11. 79 0
      backend/tests/integration/test_plate_clear_notification.py
  12. 253 0
      backend/tests/unit/test_plate_clear_mqtt_notification.py
  13. 20 4
      backend/tests/unit/test_printer_manager_status_broadcast.py
  14. 71 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  15. 3 0
      frontend/src/api/client.ts
  16. 10 0
      frontend/src/components/AddNotificationModal.tsx
  17. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  18. 3 0
      frontend/src/i18n/locales/de.ts
  19. 3 0
      frontend/src/i18n/locales/en.ts
  20. 3 0
      frontend/src/i18n/locales/es.ts
  21. 3 0
      frontend/src/i18n/locales/fr.ts
  22. 3 0
      frontend/src/i18n/locales/it.ts
  23. 3 0
      frontend/src/i18n/locales/ja.ts
  24. 3 0
      frontend/src/i18n/locales/ko.ts
  25. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 3 0
      frontend/src/i18n/locales/ru.ts
  27. 3 0
      frontend/src/i18n/locales/tr.ts
  28. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  29. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  30. 0 0
      static/assets/index-BNeeHAqi.js
  31. 1 1
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 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,
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
         "on_plate_not_empty": provider.on_plate_not_empty,
+        "on_plate_clear_required": provider.on_plate_clear_required,
         # Bed cooled
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
         # First layer complete
@@ -139,6 +140,7 @@ async def create_notification_provider(
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         # Build plate detection
         # Build plate detection
         on_plate_not_empty=provider_data.on_plate_not_empty,
         on_plate_not_empty=provider_data.on_plate_not_empty,
+        on_plate_clear_required=provider_data.on_plate_clear_required,
         # Bed cooled
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
         # 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 print_archives ADD COLUMN library_file_id INTEGER")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets 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_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),
     ("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:
     try:
         printer_info = printer_manager.get_printer(printer_id)
         printer_info = printer_manager.get_printer(printer_id)
         if printer_info:
         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:
     except Exception:
         pass  # Don't fail status callback if MQTT fails
         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
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     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
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold 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",
         "title_template": "Plate Not Empty - Print Paused",
         "body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
         "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",
         "event_type": "filament_low",
         "name": "Filament Low",
         "name": "Filament Low",

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

@@ -63,6 +63,9 @@ class NotificationProviderBase(BaseModel):
 
 
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     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
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
     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
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     on_plate_not_empty: bool | None = None
+    on_plate_clear_required: bool | None = None
 
 
     # Event triggers - Bed cooled
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None
     on_bed_cooled: bool | None = None

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

@@ -240,7 +240,14 @@ class MQTTRelayService:
     # Printer Events
     # 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)."""
         """Publish printer status change (throttled to 1 update/sec per printer)."""
         if not self.enabled or not self.connected:
         if not self.enabled or not self.connected:
             return
             return
@@ -275,6 +282,13 @@ class MQTTRelayService:
             "big_fan1_speed": state.big_fan1_speed,
             "big_fan1_speed": state.big_fan1_speed,
             "big_fan2_speed": state.big_fan2_speed,
             "big_fan2_speed": state.big_fan2_speed,
             "heatbreak_fan_speed": state.heatbreak_fan_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(
         self._publish(
@@ -283,6 +297,37 @@ class MQTTRelayService:
             retain=True,
             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):
     async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
         """Publish printer came online event."""
         """Publish printer came online event."""
         if not self.enabled or not self.connected:
         if not self.enabled or not self.connected:

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

@@ -1381,6 +1381,39 @@ class NotificationService:
             variables=variables,
             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(
     async def on_filament_low(
         self,
         self,
         printer_id: int,
         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
         UI without it. Centralised here so every current AND future caller is
         covered without each one having to remember to broadcast.
         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:
         if awaiting:
             self._awaiting_plate_clear.add(printer_id)
             self._awaiting_plate_clear.add(printer_id)
         else:
         else:
@@ -386,6 +393,45 @@ class PrinterManager:
         if self._loop and self._loop.is_running():
         if self._loop and self._loop.is_running():
             self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
             self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
             self._schedule_async(self._broadcast_status_change(printer_id))
             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:
     async def _broadcast_status_change(self, printer_id: int) -> None:
         """Emit a ``printer_status`` WebSocket update for this printer (#1128).
         """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)
     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:
 class TestSchedulingFromSetAwaitingPlateClear:
     """The hook from the public flag-mutation method into the broadcast."""
     """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:
         with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
             manager.set_awaiting_plate_clear(7, True)
             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):
     def test_does_not_schedule_when_no_loop_attached(self, manager):
         """Sync unit-test path (no loop attached): nothing must be
         """Sync unit-test path (no loop attached): nothing must be
@@ -159,8 +173,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
             scheduled.reset_mock()
             scheduled.reset_mock()
             manager.set_awaiting_plate_clear(7, False)
             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:
 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_humidity_high: false,
     on_ams_ht_temperature_high: false,
     on_ams_ht_temperature_high: false,
     on_plate_not_empty: true,
     on_plate_not_empty: true,
+    on_plate_clear_required: false,
     on_bed_cooled: false,
     on_bed_cooled: false,
     on_first_layer_complete: false,
     on_first_layer_complete: false,
     on_queue_job_added: 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', () => {
 describe('AddNotificationModal — stock alert toggles', () => {
   it('renders Inventory Alerts section with both stock alert toggles', async () => {
   it('renders Inventory Alerts section with both stock alert toggles', async () => {
     render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
     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;
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty: boolean;
   on_plate_not_empty: boolean;
+  on_plate_clear_required: boolean;
   // Bed cooled
   // Bed cooled
   on_bed_cooled: boolean;
   on_bed_cooled: boolean;
   // First layer complete
   // First layer complete
@@ -2552,6 +2553,7 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty?: boolean;
   on_plate_not_empty?: boolean;
+  on_plate_clear_required?: boolean;
   // Bed cooled
   // Bed cooled
   on_bed_cooled?: boolean;
   on_bed_cooled?: boolean;
   // First layer complete
   // First layer complete
@@ -2604,6 +2606,7 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty?: boolean;
   on_plate_not_empty?: boolean;
+  on_plate_clear_required?: boolean;
   // Bed cooled
   // Bed cooled
   on_bed_cooled?: boolean;
   on_bed_cooled?: boolean;
   // First layer complete
   // 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 [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
   const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_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 [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? 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_maintenance_due: onMaintenanceDue,
       on_stock_reorder_alert: onStockReorderAlert,
       on_stock_reorder_alert: onStockReorderAlert,
       on_stock_break_alert: onStockBreakAlert,
       on_stock_break_alert: onStockBreakAlert,
+      on_plate_clear_required: onPlateClearRequired,
       on_bed_cooled: onBedCooled,
       on_bed_cooled: onBedCooled,
       on_first_layer_complete: onFirstLayerComplete,
       on_first_layer_complete: onFirstLayerComplete,
     };
     };
@@ -579,6 +581,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
                 </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 className="flex items-center justify-between col-span-2">
                   <div>
                   <div>
                     <span className="text-sm text-white">{t('notifications.bedCooled')}</span>
                     <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 (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               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 (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 (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 (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               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 && (
             {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>
               <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 && (
             {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>
               <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>
 
 
+                <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 className="flex items-center justify-between">
                   <div>
                   <div>
                     <p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>
                     <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',
     printStarted: 'Druck gestartet',
     plateNotEmpty: 'Platte nicht leer',
     plateNotEmpty: 'Platte nicht leer',
     plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
     plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
+    plateClearRequired: 'Platte freigeben',
+    plateClearRequiredDescription: 'Druck beendet, Warteschlange wartet auf Bestätigung',
+    plateClear: 'Platte freigeben',
     printCompleted: 'Druck abgeschlossen',
     printCompleted: 'Druck abgeschlossen',
     bedCooledLabel: 'Bett abgekühlt',
     bedCooledLabel: 'Bett abgekühlt',
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert 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',
     printStarted: 'Print Started',
     plateNotEmpty: 'Plate Not Empty',
     plateNotEmpty: 'Plate Not Empty',
     plateNotEmptyDescription: 'Objects detected before print',
     plateNotEmptyDescription: 'Objects detected before print',
+    plateClearRequired: 'Plate Clear Required',
+    plateClearRequiredDescription: 'Print finished, queue waits for plate confirmation',
+    plateClear: 'Plate Clear',
     printCompleted: 'Print Completed',
     printCompleted: 'Print Completed',
     bedCooledLabel: 'Bed Cooled',
     bedCooledLabel: 'Bed Cooled',
     bedCooledDescription: 'Bed cooled below threshold after print',
     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',
     printStarted: 'Impresión iniciada',
     plateNotEmpty: 'Cama no vacía',
     plateNotEmpty: 'Cama no vacía',
     plateNotEmptyDescription: 'Objetos detectados antes de la impresión',
     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',
     printCompleted: 'Impresión completada',
     bedCooledLabel: 'Cama enfriada',
     bedCooledLabel: 'Cama enfriada',
     bedCooledDescription: 'La cama se enfrió por debajo del umbral tras la impresión',
     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',
     printStarted: 'Impression démarrée',
     plateNotEmpty: 'Plateau non vide',
     plateNotEmpty: 'Plateau non vide',
     plateNotEmptyDescription: 'Objets détectés avant l\'impression',
     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',
     printCompleted: 'Impression terminée',
     bedCooledLabel: 'Plateau refroidi',
     bedCooledLabel: 'Plateau refroidi',
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
     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',
     printStarted: 'Stampa avviata',
     plateNotEmpty: 'Piatto non vuoto',
     plateNotEmpty: 'Piatto non vuoto',
     plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
     plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
+    plateClearRequired: 'Conferma piatto libero',
+    plateClearRequiredDescription: 'Stampa finita, la coda attende conferma',
+    plateClear: 'Piatto libero',
     printCompleted: 'Stampa completata',
     printCompleted: 'Stampa completata',
     bedCooledLabel: 'Piatto raffreddato',
     bedCooledLabel: 'Piatto raffreddato',
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',

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

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

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

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

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

@@ -5487,6 +5487,9 @@ export default {
     printStarted: 'Impressão Iniciada',
     printStarted: 'Impressão Iniciada',
     plateNotEmpty: 'Mesa Não Vazia',
     plateNotEmpty: 'Mesa Não Vazia',
     plateNotEmptyDescription: 'Objetos detectados antes da impressão',
     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',
     printCompleted: 'Impressão Concluída',
     bedCooledLabel: 'Mesa Resfriada',
     bedCooledLabel: 'Mesa Resfriada',
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
     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: "Печать началась",
     printStarted: "Печать началась",
     plateNotEmpty: "Пластина не пуста",
     plateNotEmpty: "Пластина не пуста",
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
+    plateClearRequired: "Требуется подтверждение стола",
+    plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
+    plateClear: "Стол свободен",
     printCompleted: "Печать завершена",
     printCompleted: "Печать завершена",
     bedCooledLabel: "Стол остыл",
     bedCooledLabel: "Стол остыл",
     bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
     bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",

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

@@ -5455,6 +5455,9 @@ export default {
     printStarted: 'Baskı Başladı',
     printStarted: 'Baskı Başladı',
     plateNotEmpty: 'Plaka Boş Değil',
     plateNotEmpty: 'Plaka Boş Değil',
     plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
     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ı',
     printCompleted: 'Baskı Tamamlandı',
     bedCooledLabel: 'Tabla Soğudu',
     bedCooledLabel: 'Tabla Soğudu',
     bedCooledDescription: 'Baskıdan sonra tabla eşiğin altına 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: '打印已开始',
     printStarted: '打印已开始',
     plateNotEmpty: '热床非空',
     plateNotEmpty: '热床非空',
     plateNotEmptyDescription: '打印前检测到物体',
     plateNotEmptyDescription: '打印前检测到物体',
+    plateClearRequired: '需要确认热床已清空',
+    plateClearRequiredDescription: '打印完成,队列等待热床确认',
+    plateClear: '热床确认',
     printCompleted: '打印已完成',
     printCompleted: '打印已完成',
     bedCooledLabel: '热床已冷却',
     bedCooledLabel: '热床已冷却',
     bedCooledDescription: '打印后热床温度降至阈值以下',
     bedCooledDescription: '打印后热床温度降至阈值以下',

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

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BNeeHAqi.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов