Bladeren bron

fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)

An end-of-print auto-off on a plug that powers a filter fan marked the linked
printer offline and forced its state to "unknown". The mark was unrecoverable:
connected heals on the next MQTT message but state does not (only frames
carrying gcode_state rewrite it, and steady-state push_status frames are
partial), so the printer stayed "unknown" until a manual Force Refresh and the
queue never dispatched to it again.

The offline mark is now an explicit presumption: mark_power_off records the
state it overwrites and _on_message undoes it as soon as the printer sends
another report on its own topic, since inbound traffic proves the power was
never cut. A reconnect discards the saved state, so a genuine power cut is
unaffected. Each plug also gains a controls_printer_power flag (default true,
backfilled) that gates all five power-off paths, and the queue's power-on step
now picks the flagged plug instead of whichever linked plug came first.
maziggy 1 maand geleden
bovenliggende
commit
56accd24de
33 gewijzigde bestanden met toevoegingen van 863 en 16 verwijderingen
  1. 0 0
      CHANGELOG.md
  2. 3 2
      backend/app/api/routes/smart_plugs.py
  3. 14 0
      backend/app/core/database.py
  4. 8 0
      backend/app/models/smart_plug.py
  5. 6 0
      backend/app/schemas/smart_plug.py
  6. 65 0
      backend/app/services/bambu_mqtt.py
  7. 20 3
      backend/app/services/print_scheduler.py
  8. 6 3
      backend/app/services/printer_manager.py
  9. 16 7
      backend/app/services/smart_plug_manager.py
  10. 148 0
      backend/tests/unit/services/test_bambu_mqtt.py
  11. 13 0
      backend/tests/unit/services/test_printer_manager.py
  12. 155 0
      backend/tests/unit/services/test_smart_plug_manager.py
  13. 76 0
      backend/tests/unit/test_accessory_plug_queue_stall_2629.py
  14. 42 0
      backend/tests/unit/test_scheduler_power_plug_pick_2629.py
  15. 184 0
      backend/tests/unit/test_smart_plug_power_flag_migration_2629.py
  16. 28 0
      frontend/src/__tests__/components/SmartPlugCard.test.tsx
  17. 7 0
      frontend/src/api/client.ts
  18. 24 0
      frontend/src/components/AddSmartPlugModal.tsx
  19. 23 0
      frontend/src/components/SmartPlugCard.tsx
  20. 2 0
      frontend/src/i18n/locales/de.ts
  21. 2 0
      frontend/src/i18n/locales/en.ts
  22. 2 0
      frontend/src/i18n/locales/es.ts
  23. 2 0
      frontend/src/i18n/locales/fr.ts
  24. 2 0
      frontend/src/i18n/locales/it.ts
  25. 2 0
      frontend/src/i18n/locales/ja.ts
  26. 2 0
      frontend/src/i18n/locales/ko.ts
  27. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 2 0
      frontend/src/i18n/locales/ru.ts
  29. 2 0
      frontend/src/i18n/locales/tr.ts
  30. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 0 0
      static/assets/index-D0xVmIdo.js
  33. 1 1
      static/index.html

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 3 - 2
backend/app/api/routes/smart_plugs.py

@@ -583,8 +583,9 @@ async def control_smart_plug(
         plug.last_state = expected_state
         plug.last_state = expected_state
         if expected_state == "ON":
         if expected_state == "ON":
             plug.auto_off_executed = False  # Reset flag when manually turning on
             plug.auto_off_executed = False  # Reset flag when manually turning on
-        elif expected_state == "OFF" and plug.printer_id:
-            # Mark printer offline immediately for faster UI update
+        elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
+            # Mark printer offline immediately for faster UI update. Skipped for
+            # accessory plugs, which are linked to a printer but don't feed it (#2629).
             printer_manager.mark_printer_offline(plug.printer_id)
             printer_manager.mark_printer_offline(plug.printer_id)
     plug.last_checked = utcnow_naive()
     plug.last_checked = utcnow_naive()
     await db.commit()
     await db.commit()

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

@@ -3751,6 +3751,20 @@ async def run_migrations(conn):
     # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
     # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
     await _migrate_scope_run_filament_to_plate(conn)
     await _migrate_scope_run_filament_to_plate(conn)
 
 
+    # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
+    # whether a plug actually feeds the printer's own power — only then may an
+    # auto-off mark the printer offline. Defaults to true so existing plugs
+    # keep the previous behaviour; accessory plugs (filter fan, lights) are
+    # opted out by the user. BOOLEAN literals differ per dialect (SQLite has
+    # no true/false keyword), so the default is dialect-branched.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
+        )
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 8 - 0
backend/app/models/smart_plug.py

@@ -83,6 +83,14 @@ class SmartPlug(Base):
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
 
+    # Whether this plug actually feeds the printer's own power (#2629). The
+    # printer link is also used for accessories that merely follow the print
+    # cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
+    # really cuts printer power may mark the printer offline on auto-off;
+    # doing it for an accessory blanks the printer state and stalls the queue.
+    # Defaults to True so existing plugs keep their previous behaviour.
+    controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+
     # Automation settings
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start

+ 6 - 0
backend/app/schemas/smart_plug.py

@@ -66,6 +66,10 @@ class SmartPlugBase(BaseModel):
     rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
     rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
 
 
     printer_id: int | None = None
     printer_id: int | None = None
+    # #2629: only a plug that really feeds the printer may mark it offline when
+    # it switches off. Accessory plugs (filter fan, lights) are linked to a
+    # printer purely to follow the print cycle.
+    controls_printer_power: bool = True
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
     auto_off: bool = True
     auto_off: bool = True
@@ -160,6 +164,8 @@ class SmartPlugUpdate(BaseModel):
     rest_energy_total_path: str | None = None
     rest_energy_total_path: str | None = None
     rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
     printer_id: int | None = None
+    # #2629: see SmartPlugBase.controls_printer_power.
+    controls_printer_power: bool | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_on: bool | None = None
     auto_off: bool | None = None
     auto_off: bool | None = None

+ 65 - 0
backend/app/services/bambu_mqtt.py

@@ -652,6 +652,11 @@ class BambuMQTTClient:
         # to once per client lifetime so the stale loop doesn't spam it (#1465).
         # to once per client lifetime so the stale loop doesn't spam it (#1465).
         self._report_messages_since_connect: int = 0
         self._report_messages_since_connect: int = 0
         self._zero_report_hint_logged: bool = False
         self._zero_report_hint_logged: bool = False
+        # Set by mark_power_off() to the gcode_state held just before we
+        # optimistically forced the printer to "unknown" (#2629). Restored on
+        # the next inbound message, because message traffic proves the power
+        # was never actually cut. None whenever no power-off is presumed.
+        self._state_before_power_off: str | None = None
         # Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
         # Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # Handlers receive (topic, payload_bytes) before JSON parsing.
         # Handlers receive (topic, payload_bytes) before JSON parsing.
@@ -758,6 +763,55 @@ class BambuMQTTClient:
         time_since_last = time.time() - self._last_message_time
         time_since_last = time.time() - self._last_message_time
         return time_since_last > self.STALE_TIMEOUT
         return time_since_last > self.STALE_TIMEOUT
 
 
+    def mark_power_off(self) -> bool:
+        """Presume the printer lost power (smart plug switched off).
+
+        Optimistic: it skips the MQTT stale timeout so the UI updates at once.
+        The presumption is undone by ``_on_message`` if the printer keeps
+        talking — inbound traffic proves the power was never cut (#2629).
+        Returns True when the state was actually changed.
+        """
+        if not self.state.connected:
+            return False
+        previous = self.state.state
+        # Blank the state BEFORE recording what to restore. This runs on the
+        # event loop while _on_message runs on the paho thread, and the restore
+        # is a two-step (read saved state, compare against "unknown"). Writing
+        # "unknown" first means an interleaved message either sees no saved
+        # state yet (and skips, leaving the next message to restore) or sees a
+        # consistent pair — never a saved state paired with a live state it
+        # then discards, which would strand the printer on "unknown".
+        self.state.connected = False
+        self.state.state = "unknown"
+        # Only the first mark wins: a second call before any message arrives
+        # must not overwrite the real state with the "unknown" it just wrote.
+        # Nothing to restore if the state was already blank.
+        if self._state_before_power_off is None and previous not in ("", "unknown"):
+            self._state_before_power_off = previous
+        return True
+
+    def _restore_state_after_false_power_off(self) -> bool:
+        """Undo a presumed power-off once the printer proves it is alive.
+
+        ``connected`` self-heals on the next message, but ``state`` does not:
+        it is only rewritten when a payload carries ``gcode_state``, and the
+        steady-state ``push_status`` frames are partial. Without this the
+        forced "unknown" sticks until a full pushall (a manual Force Refresh),
+        and the queue scheduler treats the printer as not idle the whole time
+        (#2629). Returns True when a state was restored.
+        """
+        previous = self._state_before_power_off
+        self._state_before_power_off = None
+        if previous is None or self.state.state != "unknown":
+            return False
+        logger.info(
+            "[%s] Printer still responding after presumed power-off — restoring state %s",
+            self.serial_number,
+            previous,
+        )
+        self.state.state = previous
+        return True
+
     # Minimum seconds between stale reconnect attempts.  Frontend polls
     # Minimum seconds between stale reconnect attempts.  Frontend polls
     # status every few seconds — without a cooldown, each poll would
     # status every few seconds — without a cooldown, each poll would
     # force-close the socket before paho has time to reconnect.
     # force-close the socket before paho has time to reconnect.
@@ -897,6 +951,12 @@ class BambuMQTTClient:
         if rc == 0:
         if rc == 0:
             self.state.connected = True
             self.state.connected = True
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
+            # A dropped-and-restored MQTT session means the presumed power-off was
+            # real (or at least that the printer restarted): there is nothing
+            # legitimate left to restore, and the printer will send a full status
+            # push shortly. Dropping the saved state keeps a stale one from being
+            # broadcast ahead of the first real report (#2629, #1679).
+            self._state_before_power_off = None
             # Reset per-connection warning state so warnings fire once per (re)connection
             # Reset per-connection warning state so warnings fire once per (re)connection
             self._ams_version_warned = set()
             self._ams_version_warned = set()
             # Preserve cached developer_mode across auto-reconnects to avoid
             # Preserve cached developer_mode across auto-reconnects to avoid
@@ -1063,6 +1123,11 @@ class BambuMQTTClient:
             # "printer never sent a report" apart from a mid-session quiet gap.
             # "printer never sent a report" apart from a mid-session quiet gap.
             if msg.topic == self.topic_subscribe:
             if msg.topic == self.topic_subscribe:
                 self._report_messages_since_connect += 1
                 self._report_messages_since_connect += 1
+                # Only report-topic traffic proves the *printer* is alive — the
+                # request topic also carries slicer/Bambuddy commands.
+                if self._state_before_power_off is not None:
+                    if self._restore_state_after_false_power_off() and self.on_state_change:
+                        self.on_state_change(self.state)
 
 
             # Log message if logging is enabled
             # Log message if logging is enabled
             if self._logging_enabled:
             if self._logging_enabled:

+ 20 - 3
backend/app/services/print_scheduler.py

@@ -518,11 +518,13 @@ class PrintScheduler:
                         auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
                         auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
                         if auto_on_plugs:
                         if auto_on_plugs:
                             logger.info("Printer %s offline, attempting to power on via smart plug(s)", item.printer_id)
                             logger.info("Printer %s offline, attempting to power on via smart plug(s)", item.printer_id)
-                            # Power on using the first auto_on plug (the printer power plug)
-                            powered_on = await self._power_on_and_wait(auto_on_plugs[0], item.printer_id, db)
+                            # Power on using the plug that actually feeds the printer, and
+                            # wait for it to boot on that one only (#2629).
+                            primary_plug = self._pick_power_plug(auto_on_plugs)
+                            powered_on = await self._power_on_and_wait(primary_plug, item.printer_id, db)
                             if powered_on:
                             if powered_on:
                                 # Also turn on any remaining auto_on plugs (e.g., filter)
                                 # Also turn on any remaining auto_on plugs (e.g., filter)
-                                for extra_plug in auto_on_plugs[1:]:
+                                for extra_plug in [p for p in auto_on_plugs if p.id != primary_plug.id]:
                                     try:
                                     try:
                                         service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
                                         service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
                                         await service.turn_on(extra_plug)
                                         await service.turn_on(extra_plug)
@@ -2407,6 +2409,21 @@ class PrintScheduler:
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return list(result.scalars().all())
         return list(result.scalars().all())
 
 
+    @staticmethod
+    def _pick_power_plug(auto_on_plugs: list[SmartPlug]) -> SmartPlug:
+        """Pick the plug to power-cycle a printer back online with (#2629).
+
+        Only a plug flagged ``controls_printer_power`` can actually bring the
+        printer back; waiting for a boot on an accessory (filter fan, lights)
+        just burns the power-on timeout and fails the dispatch. Falls back to
+        the first plug when none is flagged, which is the pre-#2629 behaviour.
+        Callers must pass a non-empty list.
+        """
+        for plug in auto_on_plugs:
+            if plug.controls_printer_power:
+                return plug
+        return auto_on_plugs[0]
+
     # Bundled defaults for preheat_filament_targets (#1468). Values are the
     # Bundled defaults for preheat_filament_targets (#1468). Values are the
     # chamber-temperature recommendations BambuStudio ships for the matching
     # chamber-temperature recommendations BambuStudio ships for the matching
     # filament profile; users can override via Settings → Workflow → Preheat
     # filament profile; users can override via Settings → Workflow → Preheat

+ 6 - 3
backend/app/services/printer_manager.py

@@ -698,6 +698,11 @@ class PrinterManager:
 
 
         This is used when we know the printer power was cut (e.g., smart plug turned off)
         This is used when we know the printer power was cut (e.g., smart plug turned off)
         to immediately update the UI without waiting for MQTT timeout.
         to immediately update the UI without waiting for MQTT timeout.
+
+        The mark is a presumption, not a fact: the plug may not actually feed
+        the printer. ``BambuMQTTClient.mark_power_off`` records the state it
+        overwrites so the client can undo it as soon as the printer sends
+        another report (#2629).
         """
         """
         import logging
         import logging
 
 
@@ -705,10 +710,8 @@ class PrinterManager:
 
 
         if printer_id in self._clients:
         if printer_id in self._clients:
             client = self._clients[printer_id]
             client = self._clients[printer_id]
-            if client.state.connected:
+            if client.mark_power_off():
                 logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
                 logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
-                client.state.connected = False
-                client.state.state = "unknown"
                 # Trigger the status change callback to broadcast via WebSocket
                 # Trigger the status change callback to broadcast via WebSocket
                 if self._on_status_change:
                 if self._on_status_change:
                     self._schedule_async(self._on_status_change(printer_id, client.state))
                     self._schedule_async(self._on_status_change(printer_id, client.state))

+ 16 - 7
backend/app/services/smart_plug_manager.py

@@ -215,8 +215,8 @@ class SmartPlugManager:
                             plug.last_state = "OFF"
                             plug.last_state = "OFF"
                             plug.last_checked = utcnow_naive()
                             plug.last_checked = utcnow_naive()
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
-                            # Mark printer offline if linked
-                            if plug.printer_id:
+                            # Mark printer offline if this plug feeds it (#2629)
+                            if plug.printer_id and plug.controls_printer_power:
                                 printer_manager.mark_printer_offline(plug.printer_id)
                                 printer_manager.mark_printer_offline(plug.printer_id)
 
 
             await db.commit()
             await db.commit()
@@ -410,6 +410,7 @@ class SmartPlugManager:
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
                 delay_seconds,
                 delay_seconds,
+                controls_printer_power=plug.controls_printer_power,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
@@ -429,6 +430,7 @@ class SmartPlugManager:
         printer_id: int,
         printer_id: int,
         delay_seconds: int,
         delay_seconds: int,
         *,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
         rest_method: str | None = None,
@@ -476,8 +478,10 @@ class SmartPlugManager:
             # Mark auto_off_executed in database and update printer status
             # Mark auto_off_executed in database and update printer status
             if success:
             if success:
                 await self._mark_auto_off_executed(plug_id)
                 await self._mark_auto_off_executed(plug_id)
-                # Mark the printer as offline immediately
-                printer_manager.mark_printer_offline(printer_id)
+                # Mark the printer as offline immediately — but only when this
+                # plug actually feeds the printer (#2629).
+                if controls_printer_power:
+                    printer_manager.mark_printer_offline(printer_id)
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
             logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
@@ -504,6 +508,7 @@ class SmartPlugManager:
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
                 temp_threshold,
                 temp_threshold,
+                controls_printer_power=plug.controls_printer_power,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
@@ -523,6 +528,7 @@ class SmartPlugManager:
         printer_id: int,
         printer_id: int,
         temp_threshold: int,
         temp_threshold: int,
         *,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
         rest_method: str | None = None,
@@ -603,8 +609,10 @@ class SmartPlugManager:
                         # Mark auto_off_executed in database and update printer status
                         # Mark auto_off_executed in database and update printer status
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug_id)
                             await self._mark_auto_off_executed(plug_id)
-                            # Mark the printer as offline immediately
-                            printer_manager.mark_printer_offline(printer_id)
+                            # Mark the printer as offline immediately — but only
+                            # when this plug actually feeds the printer (#2629).
+                            if controls_printer_power:
+                                printer_manager.mark_printer_offline(printer_id)
 
 
                         break
                         break
 
 
@@ -739,7 +747,8 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug.id)
                             await self._mark_auto_off_executed(plug.id)
-                            printer_manager.mark_printer_offline(plug.printer_id)
+                            if plug.controls_printer_power:
+                                printer_manager.mark_printer_offline(plug.printer_id)
 
 
                 if pending_plugs:
                 if pending_plugs:
                     logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
                     logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))

+ 148 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -6320,3 +6320,151 @@ class TestLastLayerFinishPhotoTrigger:
 
 
         assert len(events) == 1
         assert len(events) == 1
         assert len(completion_events) == 1
         assert len(completion_events) == 1
+
+
+class TestPresumedPowerOffRecovery:
+    """#2629: a smart-plug turn-off marks the printer offline optimistically.
+
+    When the plug does not actually feed the printer, the printer keeps
+    publishing — and the forced 'unknown' state must be undone, or it sticks
+    until the next full pushall and the queue scheduler stalls forever.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client.state.state = "FINISH"
+        return client
+
+    @staticmethod
+    def _report(client, payload):
+        """Feed a report-topic message through the real _on_message path."""
+
+        class _Msg:
+            def __init__(self, topic, data):
+                self.topic = topic
+                self.payload = json.dumps(data).encode()
+
+        client._on_message(None, None, _Msg(client.topic_subscribe, payload))
+
+    def test_mark_power_off_blanks_state_and_remembers_it(self, mqtt_client):
+        assert mqtt_client.mark_power_off() is True
+
+        assert mqtt_client.state.connected is False
+        assert mqtt_client.state.state == "unknown"
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_mark_power_off_noop_when_already_disconnected(self, mqtt_client):
+        mqtt_client.state.connected = False
+
+        assert mqtt_client.mark_power_off() is False
+        assert mqtt_client._state_before_power_off is None
+
+    def test_second_mark_does_not_overwrite_saved_state(self, mqtt_client):
+        mqtt_client.mark_power_off()
+        # Something flips connected back (a partial message) before the second mark
+        mqtt_client.state.connected = True
+        mqtt_client.mark_power_off()
+
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_partial_report_restores_state(self, mqtt_client):
+        """The steady-state push_status carries no gcode_state — the pre-off
+        state must come back anyway, otherwise 'unknown' is permanent."""
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.connected is True
+        assert mqtt_client.state.state == "FINISH"
+        assert mqtt_client._state_before_power_off is None
+
+    def test_restore_broadcasts_state_change(self, mqtt_client):
+        broadcasts = []
+        mqtt_client.on_state_change = lambda state: broadcasts.append(state.state)
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert "FINISH" in broadcasts
+
+    def test_fresh_gcode_state_wins_over_restored_state(self, mqtt_client):
+        """A report that does carry gcode_state is authoritative."""
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"gcode_state": "IDLE"}})
+
+        assert mqtt_client.state.state == "IDLE"
+
+    def test_restore_happens_only_once(self, mqtt_client):
+        """After recovery a later genuine blank must not be undone by a stale
+        saved state."""
+        mqtt_client.mark_power_off()
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        # Printer really loses power now: state blanked, nothing to restore from
+        mqtt_client.state.state = "unknown"
+        assert mqtt_client._restore_state_after_false_power_off() is False
+        assert mqtt_client.state.state == "unknown"
+
+    def test_request_topic_traffic_does_not_restore(self, mqtt_client):
+        """Only the printer's own report topic proves it is alive; the request
+        topic also carries slicer/Bambuddy commands."""
+        mqtt_client.mark_power_off()
+
+        class _Msg:
+            topic = mqtt_client.topic_publish
+            payload = json.dumps({"print": {"command": "project_file"}}).encode()
+
+        mqtt_client._on_message(None, None, _Msg())
+
+        assert mqtt_client.state.state == "unknown"
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_reconnect_discards_saved_state(self, mqtt_client):
+        """A real power cut drops the MQTT session; on reconnect the saved state
+        is stale and must not be broadcast ahead of the printer's first report."""
+        from unittest.mock import MagicMock
+
+        mqtt_client.mark_power_off()
+
+        paho = MagicMock()
+        paho.subscribe.return_value = (0, 1)  # (MQTT_ERR_SUCCESS, mid)
+        mqtt_client._on_connect(paho, None, {}, 0)
+
+        assert mqtt_client._state_before_power_off is None
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.state == "unknown"
+
+    def test_already_unknown_state_is_not_saved(self, mqtt_client):
+        """A printer that never reported has nothing to restore — saving
+        'unknown' would make the recovery broadcast a no-op state change."""
+        mqtt_client.state.state = "unknown"
+
+        assert mqtt_client.mark_power_off() is True
+        assert mqtt_client._state_before_power_off is None
+
+    def test_message_interleaved_with_mark_does_not_strand_unknown(self, mqtt_client):
+        """mark_power_off runs on the event loop, _on_message on the paho
+        thread. A message landing mid-mark must not consume the saved state and
+        leave the printer stuck on 'unknown' — the next message must recover."""
+        # Simulate the worst interleaving: a report is processed after the state
+        # was blanked but before the previous state was recorded.
+        mqtt_client.state.connected = False
+        mqtt_client.state.state = "unknown"
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+        # ...now the rest of the mark completes.
+        mqtt_client._state_before_power_off = "FINISH"
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.state == "FINISH"

+ 13 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -52,6 +52,19 @@ class TestPrinterManager:
         client.state.temperatures = {"nozzle": 25, "bed": 25}
         client.state.temperatures = {"nozzle": 25, "bed": 25}
         client.state.raw_data = {}
         client.state.raw_data = {}
         client.logging_enabled = False
         client.logging_enabled = False
+
+        # mark_power_off is real logic on BambuMQTTClient (#2629) — mirror it so
+        # the manager tests still exercise the state transition they assert on.
+        # The real implementation (and its recovery path) is covered in
+        # test_bambu_mqtt.py::TestPresumedPowerOffRecovery.
+        def _mark_power_off():
+            if not client.state.connected:
+                return False
+            client.state.connected = False
+            client.state.state = "unknown"
+            return True
+
+        client.mark_power_off.side_effect = _mark_power_off
         return client
         return client
 
 
     # ========================================================================
     # ========================================================================

+ 155 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -1051,3 +1051,158 @@ class TestActivePrintGuard:
             mock_task.cancel.assert_called_once()  # cancelled despite auto_on=False
             mock_task.cancel.assert_called_once()  # cancelled despite auto_on=False
             assert mock_plug.id not in manager._pending_off
             assert mock_plug.id not in manager._pending_off
             mock_tasmota.turn_on.assert_not_called()  # but not powered on
             mock_tasmota.turn_on.assert_not_called()  # but not powered on
+
+
+class TestAccessoryPlugDoesNotMarkPrinterOffline:
+    """#2629 — a plug linked to a printer is not necessarily its power supply.
+
+    Filter fans, chamber lights and enclosure heaters are linked so they follow
+    the print cycle. Marking the printer offline when one of those switches off
+    blanks the printer state and stalls the queue until a manual Force Refresh.
+    """
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.fixture
+    def accessory_plug(self):
+        plug = MagicMock()
+        plug.id = 1
+        plug.name = "BentoBox Filter"
+        plug.ip_address = "192.168.1.100"
+        plug.username = None
+        plug.password = None
+        plug.enabled = True
+        plug.auto_off = True
+        plug.off_delay_mode = "time"
+        plug.off_delay_minutes = 1
+        plug.off_temp_threshold = 70
+        plug.printer_id = 1
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+        plug.controls_printer_power = False
+        return plug
+
+    @pytest.mark.asyncio
+    async def test_delayed_off_skips_offline_mark_for_accessory(self, manager):
+        mock_service = AsyncMock()
+        mock_service.turn_off = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
+        ):
+            mock_pm.is_print_active.return_value = False
+
+            await manager._delayed_off(
+                1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, controls_printer_power=False
+            )
+
+            mock_service.turn_off.assert_awaited_once()  # the plug still switches off
+            mock_pm.mark_printer_offline.assert_not_called()  # but the printer is untouched
+
+    @pytest.mark.asyncio
+    async def test_temp_based_off_skips_offline_mark_for_accessory(self, manager):
+        mock_service = AsyncMock()
+        mock_service.turn_off = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
+        ):
+            mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
+            mock_pm.is_print_active.return_value = False
+
+            await manager._temp_based_off(
+                1,
+                "tasmota",
+                "1.2.3.4",
+                None,
+                None,
+                None,
+                printer_id=1,
+                temp_threshold=55,
+                controls_printer_power=False,
+            )
+
+            mock_service.turn_off.assert_awaited_once()
+            mock_pm.mark_printer_offline.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_schedulers_forward_the_flag(self, manager, accessory_plug):
+        """The flag lives on the plug row; both schedulers must pass it into the
+        detached task, which only receives primitives."""
+        with (
+            patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
+            patch.object(manager, "_delayed_off", new_callable=AsyncMock) as mock_delayed,
+            patch.object(manager, "_temp_based_off", new_callable=AsyncMock) as mock_temp,
+        ):
+            manager._schedule_delayed_off(accessory_plug, 1, 60)
+            manager._schedule_temp_based_off(accessory_plug, 1, 70)
+
+            assert mock_delayed.call_args.kwargs["controls_printer_power"] is False
+            assert mock_temp.call_args.kwargs["controls_printer_power"] is False
+
+    @pytest.mark.asyncio
+    async def test_scheduled_off_skips_offline_mark_for_accessory(self, manager, accessory_plug):
+        """The time-of-day schedule path has its own turn-off + offline mark."""
+        accessory_plug.schedule_enabled = True
+        accessory_plug.schedule_on_time = None
+        accessory_plug.schedule_off_time = "22:00"
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+        ):
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "22:00"
+            mock_datetime.now.return_value = mock_now
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [accessory_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+
+            await manager._check_schedules()
+
+            mock_tasmota.turn_off.assert_awaited_once_with(accessory_plug)
+            mock_pm.mark_printer_offline.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_scheduled_off_still_marks_offline_for_power_plug(self, manager, accessory_plug):
+        """Default (a plug that really feeds the printer) keeps the old behaviour."""
+        accessory_plug.controls_printer_power = True
+        accessory_plug.schedule_enabled = True
+        accessory_plug.schedule_on_time = None
+        accessory_plug.schedule_off_time = "22:00"
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+        ):
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "22:00"
+            mock_datetime.now.return_value = mock_now
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [accessory_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+
+            await manager._check_schedules()
+
+            mock_pm.mark_printer_offline.assert_called_once_with(1)

+ 76 - 0
backend/tests/unit/test_accessory_plug_queue_stall_2629.py

@@ -0,0 +1,76 @@
+"""End-to-end regression test for the #2629 queue stall.
+
+Exercises the real objects rather than mocks: a real ``BambuMQTTClient``
+registered on the real ``printer_manager`` singleton, driven through the real
+``_on_message`` path, and read back through the scheduler's own idle check.
+That chain — presume power off, printer keeps talking, scheduler sees it as
+dispatchable again — is what actually broke for the reporter, and no single
+unit test covers it.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.services.printer_manager import printer_manager
+
+PRINTER_ID = 9629  # unlikely to collide with any other test's registrations
+
+
+class _Msg:
+    def __init__(self, topic: str, data: dict):
+        self.topic = topic
+        self.payload = json.dumps(data).encode()
+
+
+@pytest.fixture
+def registered_client():
+    """A connected client sitting on FINISH, as after a completed print."""
+    client = BambuMQTTClient(ip_address="10.0.0.5", serial_number="SER2629", access_code="12345678")
+    client.state.connected = True
+    client.state.state = "FINISH"
+    printer_manager._clients[PRINTER_ID] = client
+    try:
+        yield client
+    finally:
+        printer_manager._clients.pop(PRINTER_ID, None)
+
+
+def _partial_push(client: BambuMQTTClient) -> None:
+    """A steady-state push_status carrying no gcode_state — the frame shape the
+    reporter's P1S sends between state transitions."""
+    client._on_message(None, None, _Msg(client.topic_subscribe, {"print": {"wifi_signal": "-30dBm"}}))
+
+
+def test_printer_recovers_and_queue_can_dispatch_again(registered_client):
+    scheduler = PrintScheduler()
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is True
+
+    # An accessory plug (filter fan) switches off; Bambuddy presumes power loss.
+    printer_manager.mark_printer_offline(PRINTER_ID)
+    assert printer_manager.get_status(PRINTER_ID).state == "unknown"
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is False
+
+    # The printer never stopped talking.
+    _partial_push(registered_client)
+
+    assert printer_manager.get_status(PRINTER_ID).state == "FINISH"
+    assert printer_manager.is_connected(PRINTER_ID) is True
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is True
+
+
+def test_real_power_cut_still_leaves_printer_unavailable(registered_client):
+    """The recovery must key off actual traffic, not off time passing — a plug
+    that really cut power produces silence, and the printer stays offline."""
+    scheduler = PrintScheduler()
+
+    printer_manager.mark_printer_offline(PRINTER_ID)
+
+    # No messages arrive at all.
+    assert printer_manager.get_status(PRINTER_ID).state == "unknown"
+    assert printer_manager.is_connected(PRINTER_ID) is False
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is False

+ 42 - 0
backend/tests/unit/test_scheduler_power_plug_pick_2629.py

@@ -0,0 +1,42 @@
+"""Tests for _pick_power_plug() in the print scheduler (#2629).
+
+A printer can have several plugs linked to it: the one feeding the printer and
+accessories that merely follow the print cycle (filter fan, chamber light). Only
+the former can bring an offline printer back, so the queue's power-on step must
+pick it rather than whichever row came back first.
+"""
+
+from types import SimpleNamespace
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+def _plug(plug_id: int, name: str, controls_printer_power: bool) -> SimpleNamespace:
+    return SimpleNamespace(id=plug_id, name=name, controls_printer_power=controls_printer_power)
+
+
+class TestPickPowerPlug:
+    def test_prefers_power_plug_over_earlier_accessory(self):
+        fan = _plug(1, "BentoBox Filter", False)
+        printer_plug = _plug(2, "P1S Power", True)
+
+        assert PrintScheduler._pick_power_plug([fan, printer_plug]) is printer_plug
+
+    def test_keeps_first_power_plug_when_several_qualify(self):
+        first = _plug(1, "P1S Power", True)
+        second = _plug(2, "Bench Power", True)
+
+        assert PrintScheduler._pick_power_plug([first, second]) is first
+
+    def test_falls_back_to_first_when_none_flagged(self):
+        """Pre-#2629 behaviour for setups where no plug is marked as the power
+        source — powering on may not work, but nothing gets worse."""
+        fan = _plug(1, "BentoBox Filter", False)
+        light = _plug(2, "Chamber Light", False)
+
+        assert PrintScheduler._pick_power_plug([fan, light]) is fan
+
+    def test_single_plug_is_returned_regardless(self):
+        only = _plug(1, "P1S Power", True)
+
+        assert PrintScheduler._pick_power_plug([only]) is only

+ 184 - 0
backend/tests/unit/test_smart_plug_power_flag_migration_2629.py

@@ -0,0 +1,184 @@
+"""Migration test for #2629 — smart_plugs.controls_printer_power.
+
+Existing installs have plugs that were assumed to power their linked printer, so
+the new column must be added *and backfilled to true*: a NULL or false backfill
+would silently stop marking a real printer plug's power-off, which is the
+behaviour users have today.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+LEGACY_SMART_PLUGS = """
+CREATE TABLE smart_plugs (
+    id INTEGER PRIMARY KEY,
+    name VARCHAR(100) NOT NULL,
+    ip_address VARCHAR(45),
+    plug_type VARCHAR(20) DEFAULT 'tasmota',
+    ha_entity_id VARCHAR(100),
+    printer_id INTEGER,
+    enabled BOOLEAN DEFAULT 1,
+    auto_on BOOLEAN DEFAULT 1,
+    auto_off BOOLEAN DEFAULT 1,
+    auto_off_persistent BOOLEAN DEFAULT 0,
+    off_delay_mode VARCHAR(20) DEFAULT 'time',
+    off_delay_minutes INTEGER DEFAULT 5,
+    off_temp_threshold INTEGER DEFAULT 70,
+    show_in_switchbar BOOLEAN DEFAULT 0,
+    show_on_printer_card BOOLEAN DEFAULT 1,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+)
+"""
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """settings.database_url may point at Postgres in dev configs; the test engine
+    is SQLite, so force the dialect both places run_migrations reads it from."""
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture
+async def legacy_engine():
+    """A modern schema with a pre-#2629 smart_plugs table holding one plug."""
+    from backend.app.core.database import Base
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("DROP TABLE smart_plugs"))
+        await conn.execute(text(LEGACY_SMART_PLUGS))
+        await conn.execute(
+            text("INSERT INTO smart_plugs (id, name, plug_type, printer_id) VALUES (1, 'P1S Power', 'tasmota', 1)")
+        )
+    yield engine
+    await engine.dispose()
+
+
+async def test_column_missing_before_migration(legacy_engine):
+    """Sanity check so the assertion below can't pass by accident."""
+    async with legacy_engine.begin() as conn:
+        columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(smart_plugs)"))}
+    assert "controls_printer_power" not in columns
+
+
+async def test_existing_plugs_backfill_to_true(legacy_engine):
+    """An upgraded install must keep marking its printer offline on power-off."""
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with legacy_engine.begin() as conn:
+        result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
+        assert bool(result.scalar_one()) is True
+
+
+async def test_migration_is_idempotent(legacy_engine):
+    """Second boot must not fail on the already-present column."""
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with legacy_engine.begin() as conn:
+        result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
+        assert bool(result.scalar_one()) is True
+
+
+class TestPostgresBranch:
+    """CI runs on SQLite, so the Postgres branch of the dialect switch would be
+    dead code without this. Captures the SQL ``run_migrations`` would emit,
+    mirroring ``test_oidc_icon_migration_pg.py``.
+    """
+
+    @staticmethod
+    async def _capture_sql(is_sqlite_value: bool) -> list[str]:
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.core import database as db_module
+
+        class _AsyncCtxStub:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_exc):
+                return False
+
+        executed_sql: list[str] = []
+
+        async def fake_safe_execute(_conn, sql: str) -> None:
+            executed_sql.append(sql)
+
+        fake_conn = MagicMock()
+        fake_conn.begin_nested = lambda: _AsyncCtxStub()
+        fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
+
+        with (
+            patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
+            patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
+            patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
+            patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
+        ):
+            await db_module.run_migrations(fake_conn)
+
+        return executed_sql
+
+    @pytest.mark.asyncio
+    async def test_pg_branch_uses_true_and_if_not_exists(self):
+        executed = await self._capture_sql(is_sqlite_value=False)
+        stmts = [s for s in executed if "controls_printer_power" in s]
+
+        assert len(stmts) == 1, f"expected exactly one statement, got: {stmts!r}"
+        assert "IF NOT EXISTS" in stmts[0]  # idempotent on PG, which has no _safe_execute retry semantics
+        assert "DEFAULT true" in stmts[0]
+
+    @pytest.mark.asyncio
+    async def test_sqlite_branch_uses_numeric_default(self):
+        """SQLite has no true/false literal — the switch must not be inverted."""
+        executed = await self._capture_sql(is_sqlite_value=True)
+        stmts = [s for s in executed if "controls_printer_power" in s]
+
+        assert len(stmts) == 1
+        assert "DEFAULT 1" in stmts[0]
+        assert "true" not in stmts[0]

+ 28 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -40,6 +40,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   mqtt_state_path: null,
   mqtt_state_path: null,
   mqtt_state_on_value: null,
   mqtt_state_on_value: null,
   printer_id: 1,
   printer_id: 1,
+  controls_printer_power: true,
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
   auto_off: true,
   auto_off: true,
@@ -287,6 +288,33 @@ describe('SmartPlugCard', () => {
     });
     });
   });
   });
 
 
+  describe('powers the printer toggle (#2629)', () => {
+    it('shows the toggle when a printer is linked', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ printer_id: 1, controls_printer_power: false });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Powers the printer')).toBeInTheDocument();
+      });
+    });
+
+    it('hides the toggle when no printer is linked', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ printer_id: null });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Auto On')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Powers the printer')).not.toBeInTheDocument();
+    });
+  });
+
   describe('disabled state', () => {
   describe('disabled state', () => {
     it('renders plug even when disabled', () => {
     it('renders plug even when disabled', () => {
       const plug = createMockPlug({ enabled: false });
       const plug = createMockPlug({ enabled: false });

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

@@ -1928,6 +1928,9 @@ export interface SmartPlug {
   rest_energy_total_path: string | null;
   rest_energy_total_path: string | null;
   rest_energy_total_multiplier: number;
   rest_energy_total_multiplier: number;
   printer_id: number | null;
   printer_id: number | null;
+  // #2629: only a plug that really feeds the printer may mark it offline when
+  // switched off. Accessory plugs follow the print cycle without powering it.
+  controls_printer_power: boolean;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
   auto_off: boolean;
   auto_off: boolean;
@@ -2004,6 +2007,8 @@ export interface SmartPlugCreate {
   rest_energy_total_path?: string | null;
   rest_energy_total_path?: string | null;
   rest_energy_total_multiplier?: number;
   rest_energy_total_multiplier?: number;
   printer_id?: number | null;
   printer_id?: number | null;
+  // #2629
+  controls_printer_power?: boolean;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
   auto_off?: boolean;
@@ -2072,6 +2077,8 @@ export interface SmartPlugUpdate {
   rest_energy_total_path?: string | null;
   rest_energy_total_path?: string | null;
   rest_energy_total_multiplier?: number;
   rest_energy_total_multiplier?: number;
   printer_id?: number | null;
   printer_id?: number | null;
+  // #2629
+  controls_printer_power?: boolean;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
   auto_off?: boolean;

+ 24 - 0
frontend/src/components/AddSmartPlugModal.tsx

@@ -84,6 +84,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
   const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
 
 
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
+  // #2629: defaults to true (a plug linked to a printer usually powers it);
+  // users turn it off for accessories like a filter fan or chamber light.
+  const [controlsPrinterPower, setControlsPrinterPower] = useState(plug?.controls_printer_power ?? true);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
 
 
@@ -388,6 +391,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
+      controls_printer_power: controlsPrinterPower,
       // Power alerts
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_enabled: powerAlertEnabled,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
@@ -1505,6 +1509,26 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               <p className="text-xs text-bambu-gray mt-1">
               <p className="text-xs text-bambu-gray mt-1">
                 {t('smartPlugs.linkingDescription')}
                 {t('smartPlugs.linkingDescription')}
               </p>
               </p>
+
+              {/* Whether the plug feeds the printer itself, or is an accessory
+                  that merely follows the print cycle (#2629). */}
+              {printerId !== null && (
+                <div className="flex items-center justify-between mt-3">
+                  <div className="pr-3">
+                    <p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
+                    <p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer shrink-0">
+                    <input
+                      type="checkbox"
+                      checked={controlsPrinterPower}
+                      onChange={(e) => setControlsPrinterPower(e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
             </div>
             </div>
           )}
           )}
 
 

+ 23 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -317,6 +317,29 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </div>
 
 
+              {/* Powers the printer (#2629) - only meaningful with a linked printer.
+                  When off, switching this plug off no longer marks the printer offline. */}
+              {plug.printer_id != null && (
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <Power className="w-4 h-4 text-bambu-green" />
+                    <div>
+                      <p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
+                      <p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
+                    </div>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={plug.controls_printer_power}
+                      onChange={(e) => updateMutation.mutate({ controls_printer_power: e.target.checked })}
+                      className="sr-only peer"
+                    />
+                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
+
               {/* Automation controls - only for controllable plugs (not MQTT) */}
               {/* Automation controls - only for controllable plugs (not MQTT) */}
               {plug.plug_type !== 'mqtt' && (
               {plug.plug_type !== 'mqtt' && (
                 <>
                 <>

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

@@ -5279,6 +5279,8 @@ export default {
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
     autoOffPersistent: 'Aktiviert lassen',
     autoOffPersistent: 'Aktiviert lassen',
     autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
     autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
+    controlsPrinterPower: 'Versorgt den Drucker',
+    controlsPrinterPowerDescription: 'Deaktivieren, wenn diese Steckdose nur Zubehör versorgt (Filterlüfter, Licht). Sonst wird der Drucker beim Ausschalten als offline markiert.',
     autoOffAfterDrying: 'Automatisch aus nach Trocknung',
     autoOffAfterDrying: 'Automatisch aus nach Trocknung',
     autoOffAfterDryingDescription: 'Ausschalten, wenn AMS-Trocknung abgeschlossen ist',
     autoOffAfterDryingDescription: 'Ausschalten, wenn AMS-Trocknung abgeschlossen ist',
     delayAfterDryingMinutes: 'Verzögerung nach Trocknung (Minuten)',
     delayAfterDryingMinutes: 'Verzögerung nach Trocknung (Minuten)',

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

@@ -5323,6 +5323,8 @@ export default {
     autoOffDescription: 'Turn off when print completes (one-shot)',
     autoOffDescription: 'Turn off when print completes (one-shot)',
     autoOffPersistent: 'Keep Enabled',
     autoOffPersistent: 'Keep Enabled',
     autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
     autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
+    controlsPrinterPower: 'Powers the printer',
+    controlsPrinterPowerDescription: 'Turn off if this plug only powers an accessory (filter fan, lights). Otherwise switching it off marks the printer offline.',
     autoOffAfterDrying: 'Auto Off After Drying',
     autoOffAfterDrying: 'Auto Off After Drying',
     autoOffAfterDryingDescription: 'Turn off when AMS drying completes',
     autoOffAfterDryingDescription: 'Turn off when AMS drying completes',
     delayAfterDryingMinutes: 'Drying delay (minutes)',
     delayAfterDryingMinutes: 'Drying delay (minutes)',

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

@@ -5288,6 +5288,8 @@ export default {
     autoOffDescription: 'Apagar cuando se completa la impresión (una sola vez)',
     autoOffDescription: 'Apagar cuando se completa la impresión (una sola vez)',
     autoOffPersistent: 'Mantener activado',
     autoOffPersistent: 'Mantener activado',
     autoOffPersistentDescription: 'Permanecer activado entre impresiones en lugar de una sola vez',
     autoOffPersistentDescription: 'Permanecer activado entre impresiones en lugar de una sola vez',
+    controlsPrinterPower: 'Alimenta la impresora',
+    controlsPrinterPowerDescription: 'Desactívalo si este enchufe solo alimenta un accesorio (ventilador de filtro, luces). De lo contrario, apagarlo marca la impresora como desconectada.',
     autoOffAfterDrying: 'Apagado automático tras el secado',
     autoOffAfterDrying: 'Apagado automático tras el secado',
     autoOffAfterDryingDescription: 'Apagar cuando se completa el secado del AMS',
     autoOffAfterDryingDescription: 'Apagar cuando se completa el secado del AMS',
     delayAfterDryingMinutes: 'Retardo de secado (minutos)',
     delayAfterDryingMinutes: 'Retardo de secado (minutos)',

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

@@ -5269,6 +5269,8 @@ export default {
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
     autoOffPersistent: 'Garder activé',
     autoOffPersistent: 'Garder activé',
     autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
     autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
+    controlsPrinterPower: 'Alimente l\'imprimante',
+    controlsPrinterPowerDescription: 'Désactivez si cette prise n\'alimente qu\'un accessoire (ventilateur de filtre, éclairage). Sinon, l\'éteindre marque l\'imprimante hors ligne.',
     autoOffAfterDrying: 'Arrêt auto après séchage',
     autoOffAfterDrying: 'Arrêt auto après séchage',
     autoOffAfterDryingDescription: 'Éteindre à la fin du séchage de l\'AMS',
     autoOffAfterDryingDescription: 'Éteindre à la fin du séchage de l\'AMS',
     delayAfterDryingMinutes: 'Délai après séchage (minutes)',
     delayAfterDryingMinutes: 'Délai après séchage (minutes)',

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

@@ -5268,6 +5268,8 @@ export default {
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
     autoOffPersistent: 'Mantieni attivo',
     autoOffPersistent: 'Mantieni attivo',
     autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
     autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
+    controlsPrinterPower: 'Alimenta la stampante',
+    controlsPrinterPowerDescription: 'Disattiva se questa presa alimenta solo un accessorio (ventola del filtro, luci). Altrimenti spegnerla segna la stampante come offline.',
     autoOffAfterDrying: 'Spegni dopo asciugatura',
     autoOffAfterDrying: 'Spegni dopo asciugatura',
     autoOffAfterDryingDescription: 'Spegni al termine dell\'asciugatura AMS',
     autoOffAfterDryingDescription: 'Spegni al termine dell\'asciugatura AMS',
     delayAfterDryingMinutes: 'Ritardo dopo asciugatura (minuti)',
     delayAfterDryingMinutes: 'Ritardo dopo asciugatura (minuti)',

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

@@ -5280,6 +5280,8 @@ export default {
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
     autoOffPersistent: '有効のまま維持',
     autoOffPersistent: '有効のまま維持',
     autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
     autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
+    controlsPrinterPower: 'プリンターに給電',
+    controlsPrinterPowerDescription: 'このプラグがアクセサリー(フィルターファン、照明)のみに給電する場合はオフにします。オンのままだと、電源を切ったときにプリンターがオフラインとして扱われます。',
     autoOffAfterDrying: '乾燥完了後に自動オフ',
     autoOffAfterDrying: '乾燥完了後に自動オフ',
     autoOffAfterDryingDescription: 'AMSの乾燥が完了したらオフにする',
     autoOffAfterDryingDescription: 'AMSの乾燥が完了したらオフにする',
     delayAfterDryingMinutes: '乾燥後の遅延(分)',
     delayAfterDryingMinutes: '乾燥後の遅延(分)',

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

@@ -5010,6 +5010,8 @@ export default {
     autoOffDescription: '인쇄 완료 시 끄기 (1회)',
     autoOffDescription: '인쇄 완료 시 끄기 (1회)',
     autoOffPersistent: '계속 활성화',
     autoOffPersistent: '계속 활성화',
     autoOffPersistentDescription: '1회성 대신 인쇄 사이에 활성화 유지',
     autoOffPersistentDescription: '1회성 대신 인쇄 사이에 활성화 유지',
+    controlsPrinterPower: '프린터에 전원 공급',
+    controlsPrinterPowerDescription: '이 플러그가 액세서리(필터 팬, 조명)에만 전원을 공급한다면 끄세요. 그렇지 않으면 플러그를 끌 때 프린터가 오프라인으로 표시됩니다.',
     turnOffDelayMode: '끄기 지연 모드',
     turnOffDelayMode: '끄기 지연 모드',
     time: '시간',
     time: '시간',
     temp: '온도',
     temp: '온도',

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

@@ -5268,6 +5268,8 @@ export default {
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
     autoOffPersistent: 'Manter ativado',
     autoOffPersistent: 'Manter ativado',
     autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
     autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
+    controlsPrinterPower: 'Alimenta a impressora',
+    controlsPrinterPowerDescription: 'Desative se esta tomada alimenta apenas um acessório (ventoinha do filtro, luzes). Caso contrário, desligá-la marca a impressora como offline.',
     autoOffAfterDrying: 'Desligar Após Secagem',
     autoOffAfterDrying: 'Desligar Após Secagem',
     autoOffAfterDryingDescription: 'Desligar quando a secagem do AMS terminar',
     autoOffAfterDryingDescription: 'Desligar quando a secagem do AMS terminar',
     delayAfterDryingMinutes: 'Atraso após secagem (minutos)',
     delayAfterDryingMinutes: 'Atraso após secagem (minutos)',

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

@@ -4998,6 +4998,8 @@ export default {
     autoOffDescription: "Выключать после завершения печати (однократно)",
     autoOffDescription: "Выключать после завершения печати (однократно)",
     autoOffPersistent: "Оставлять включённым",
     autoOffPersistent: "Оставлять включённым",
     autoOffPersistentDescription: "Не сбрасывать автовыключение после печати",
     autoOffPersistentDescription: "Не сбрасывать автовыключение после печати",
+    controlsPrinterPower: "Питает принтер",
+    controlsPrinterPowerDescription: "Отключите, если эта розетка питает только аксессуар (вентилятор фильтра, подсветку). Иначе её выключение помечает принтер как офлайн.",
     autoOffAfterDrying: "Выключать после сушки",
     autoOffAfterDrying: "Выключать после сушки",
     autoOffAfterDryingDescription: "Выключать после завершения сушки в AMS",
     autoOffAfterDryingDescription: "Выключать после завершения сушки в AMS",
     delayAfterDryingMinutes: "Задержка после сушки (мин)",
     delayAfterDryingMinutes: "Задержка после сушки (мин)",

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

@@ -5243,6 +5243,8 @@ export default {
     autoOffDescription: 'Baskı tamamlandığında kapat (tek seferlik)',
     autoOffDescription: 'Baskı tamamlandığında kapat (tek seferlik)',
     autoOffPersistent: 'Etkin Tut',
     autoOffPersistent: 'Etkin Tut',
     autoOffPersistentDescription: 'Tek seferlik yerine baskılar arasında etkin kal',
     autoOffPersistentDescription: 'Tek seferlik yerine baskılar arasında etkin kal',
+    controlsPrinterPower: 'Yazıcıya güç veriyor',
+    controlsPrinterPowerDescription: 'Bu priz yalnızca bir aksesuara (filtre fanı, aydınlatma) güç veriyorsa kapatın. Aksi halde prizi kapatmak yazıcıyı çevrimdışı olarak işaretler.',
     autoOffAfterDrying: 'Kurutmadan Sonra Otomatik Kapat',
     autoOffAfterDrying: 'Kurutmadan Sonra Otomatik Kapat',
     autoOffAfterDryingDescription: 'AMS kurutması tamamlandığında kapat',
     autoOffAfterDryingDescription: 'AMS kurutması tamamlandığında kapat',
     delayAfterDryingMinutes: 'Kurutma gecikmesi (dakika)',
     delayAfterDryingMinutes: 'Kurutma gecikmesi (dakika)',

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

@@ -5268,6 +5268,8 @@ export default {
     autoOffDescription: '打印完成时关闭(一次性)',
     autoOffDescription: '打印完成时关闭(一次性)',
     autoOffPersistent: '保持启用',
     autoOffPersistent: '保持启用',
     autoOffPersistentDescription: '在打印之间保持启用而非一次性',
     autoOffPersistentDescription: '在打印之间保持启用而非一次性',
+    controlsPrinterPower: '为打印机供电',
+    controlsPrinterPowerDescription: '如果此插座仅为配件(滤芯风扇、灯光)供电,请关闭此选项;否则关闭插座会将打印机标记为离线。',
     autoOffAfterDrying: '干燥完成后自动关闭',
     autoOffAfterDrying: '干燥完成后自动关闭',
     autoOffAfterDryingDescription: 'AMS 干燥完成后关闭',
     autoOffAfterDryingDescription: 'AMS 干燥完成后关闭',
     delayAfterDryingMinutes: '干燥后延迟(分钟)',
     delayAfterDryingMinutes: '干燥后延迟(分钟)',

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

@@ -5268,6 +5268,8 @@ export default {
     autoOffDescription: '列印完成時關閉(一次性)',
     autoOffDescription: '列印完成時關閉(一次性)',
     autoOffPersistent: '保持啟用',
     autoOffPersistent: '保持啟用',
     autoOffPersistentDescription: '在列印之間保持啟用而非一次性',
     autoOffPersistentDescription: '在列印之間保持啟用而非一次性',
+    controlsPrinterPower: '為印表機供電',
+    controlsPrinterPowerDescription: '若此插座僅為配件(濾網風扇、燈光)供電,請關閉此選項;否則關閉插座會將印表機標記為離線。',
     autoOffAfterDrying: '乾燥完成後自動關閉',
     autoOffAfterDrying: '乾燥完成後自動關閉',
     autoOffAfterDryingDescription: 'AMS 乾燥完成後關閉',
     autoOffAfterDryingDescription: 'AMS 乾燥完成後關閉',
     delayAfterDryingMinutes: '乾燥後延遲(分鐘)',
     delayAfterDryingMinutes: '乾燥後延遲(分鐘)',

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

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