Procházet zdrojové kódy

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 před 1 měsícem
rodič
revize
56accd24de
33 změnil soubory, kde provedl 863 přidání a 16 odebrání
  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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 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
         if expected_state == "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)
     plug.last_checked = utcnow_naive()
     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.
     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
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     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)
     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
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     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)
 
     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
     auto_on: bool = True
     auto_off: bool = True
@@ -160,6 +164,8 @@ class SmartPlugUpdate(BaseModel):
     rest_energy_total_path: str | None = None
     rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
+    # #2629: see SmartPlugBase.controls_printer_power.
+    controls_printer_power: bool | None = None
     enabled: bool | None = None
     auto_on: 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).
         self._report_messages_since_connect: int = 0
         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
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # Handlers receive (topic, payload_bytes) before JSON parsing.
@@ -758,6 +763,55 @@ class BambuMQTTClient:
         time_since_last = time.time() - self._last_message_time
         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
     # status every few seconds — without a cooldown, each poll would
     # force-close the socket before paho has time to reconnect.
@@ -897,6 +951,12 @@ class BambuMQTTClient:
         if rc == 0:
             self.state.connected = True
             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
             self._ams_version_warned = set()
             # 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.
             if msg.topic == self.topic_subscribe:
                 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
             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]
                         if auto_on_plugs:
                             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:
                                 # 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:
                                         service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
                                         await service.turn_on(extra_plug)
@@ -2407,6 +2409,21 @@ class PrintScheduler:
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         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
     # chamber-temperature recommendations BambuStudio ships for the matching
     # 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)
         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
 
@@ -705,10 +710,8 @@ class PrinterManager:
 
         if printer_id in self._clients:
             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)
-                client.state.connected = False
-                client.state.state = "unknown"
                 # Trigger the status change callback to broadcast via WebSocket
                 if self._on_status_change:
                     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_checked = utcnow_naive()
                             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)
 
             await db.commit()
@@ -410,6 +410,7 @@ class SmartPlugManager:
                 plug.password,
                 printer_id,
                 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_body=plug.rest_off_body 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,
         delay_seconds: int,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
@@ -476,8 +478,10 @@ class SmartPlugManager:
             # Mark auto_off_executed in database and update printer status
             if success:
                 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:
             logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
@@ -504,6 +508,7 @@ class SmartPlugManager:
                 plug.password,
                 printer_id,
                 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_body=plug.rest_off_body 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,
         temp_threshold: int,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
@@ -603,8 +609,10 @@ class SmartPlugManager:
                         # Mark auto_off_executed in database and update printer status
                         if success:
                             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
 
@@ -739,7 +747,8 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         if success:
                             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:
                     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(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.raw_data = {}
         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
 
     # ========================================================================

+ 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
             assert mock_plug.id not in manager._pending_off
             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_on_value: null,
   printer_id: 1,
+  controls_printer_power: true,
   enabled: true,
   auto_on: 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', () => {
     it('renders plug even when disabled', () => {
       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_multiplier: number;
   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;
   auto_on: boolean;
   auto_off: boolean;
@@ -2004,6 +2007,8 @@ export interface SmartPlugCreate {
   rest_energy_total_path?: string | null;
   rest_energy_total_multiplier?: number;
   printer_id?: number | null;
+  // #2629
+  controls_printer_power?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
@@ -2072,6 +2077,8 @@ export interface SmartPlugUpdate {
   rest_energy_total_path?: string | null;
   rest_energy_total_multiplier?: number;
   printer_id?: number | null;
+  // #2629
+  controls_printer_power?: boolean;
   enabled?: boolean;
   auto_on?: 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 [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 [error, setError] = useState<string | null>(null);
 
@@ -388,6 +391,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
+      controls_printer_power: controlsPrinterPower,
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       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">
                 {t('smartPlugs.linkingDescription')}
               </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>
           )}
 

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

@@ -317,6 +317,29 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </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) */}
               {plug.plug_type !== 'mqtt' && (
                 <>

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

@@ -5279,6 +5279,8 @@ export default {
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
     autoOffPersistent: 'Aktiviert lassen',
     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',
     autoOffAfterDryingDescription: 'Ausschalten, wenn AMS-Trocknung abgeschlossen ist',
     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)',
     autoOffPersistent: 'Keep Enabled',
     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',
     autoOffAfterDryingDescription: 'Turn off when AMS drying completes',
     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)',
     autoOffPersistent: 'Mantener activado',
     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',
     autoOffAfterDryingDescription: 'Apagar cuando se completa el secado del AMS',
     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)',
     autoOffPersistent: 'Garder activé',
     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',
     autoOffAfterDryingDescription: 'Éteindre à la fin du séchage de l\'AMS',
     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)',
     autoOffPersistent: 'Mantieni attivo',
     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',
     autoOffAfterDryingDescription: 'Spegni al termine dell\'asciugatura AMS',
     delayAfterDryingMinutes: 'Ritardo dopo asciugatura (minuti)',

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

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

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

@@ -5010,6 +5010,8 @@ export default {
     autoOffDescription: '인쇄 완료 시 끄기 (1회)',
     autoOffPersistent: '계속 활성화',
     autoOffPersistentDescription: '1회성 대신 인쇄 사이에 활성화 유지',
+    controlsPrinterPower: '프린터에 전원 공급',
+    controlsPrinterPowerDescription: '이 플러그가 액세서리(필터 팬, 조명)에만 전원을 공급한다면 끄세요. 그렇지 않으면 플러그를 끌 때 프린터가 오프라인으로 표시됩니다.',
     turnOffDelayMode: '끄기 지연 모드',
     time: '시간',
     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)',
     autoOffPersistent: 'Manter ativado',
     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',
     autoOffAfterDryingDescription: 'Desligar quando a secagem do AMS terminar',
     delayAfterDryingMinutes: 'Atraso após secagem (minutos)',

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

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

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

@@ -5243,6 +5243,8 @@ export default {
     autoOffDescription: 'Baskı tamamlandığında kapat (tek seferlik)',
     autoOffPersistent: 'Etkin Tut',
     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',
     autoOffAfterDryingDescription: 'AMS kurutması tamamlandığında kapat',
     delayAfterDryingMinutes: 'Kurutma gecikmesi (dakika)',

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

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

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

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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-D0xVmIdo.js


+ 1 - 1
static/index.html

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

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