Browse Source

Fix multi-plug automation only working for first plug (#903)

  When multiple smart plugs were assigned to the same printer, only the
  first plug's automation triggered. All automation paths (print start
  auto-on, print complete auto-off, queue auto-off, scheduler power-on)
  now iterate every plug linked to the printer. Also fix queue auto-off
  hardcoded to Tasmota instead of using the correct service for the plug
  type.
maziggy 1 month ago
parent
commit
8c00b1b75f

+ 1 - 0
CHANGELOG.md

@@ -32,6 +32,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Kiosk Screen Blanks on Boot** — The touchscreen display would blank immediately after the RPi booted, requiring a touch to wake. Added `consoleblank=0` to the kernel cmdline to disable Linux console blanking during the Plymouth-to-labwc transition, and changed the `wlr-randr` anti-blank loop to fire immediately instead of sleeping 60 seconds first.
 - **Queue Widget Ignores Plate-Clear Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — The "Clear Plate & Start Next" button on printer cards appeared even when "Require plate-clear confirmation" was disabled in Settings → Queue. The backend correctly auto-dispatched without waiting, but the frontend widget always showed the prompt. The widget now respects the setting and shows a passive queue link instead when plate-clear confirmation is disabled.
 - **Ghost Jobs From SQLite Lock on Print Completion** ([#897](https://github.com/maziggy/bambuddy/issues/897)) — When a print finished, the queue status update (`printing` → `completed`) could fail silently if the SQLite database was locked by another writer (e.g. the runtime tracker). The failed commit left the job permanently stuck in `printing` status — a "ghost job" that caused the UI to show false double-assignments when the next job started. The critical queue status commit now retries up to 3 times with backoff on SQLite lock errors (PostgreSQL is unaffected — it uses row-level locking). Additionally, the runtime tracker was holding a single long transaction across all printers; it now commits per-printer to minimize lock hold time.
+- **Multi-Plug Automation Only Works for First Plug** ([#903](https://github.com/maziggy/bambuddy/issues/903)) — When multiple smart plugs were assigned to the same printer (e.g. a TUYA printer plug and a particle filter plug via Home Assistant), only the first plug's automation worked. The auto-on at print start, auto-off at print completion, and queue auto-off all queried for a single plug instead of iterating all plugs linked to the printer. All automation paths now control every assigned plug. Also fixed the queue auto-off path which was hardcoded to Tasmota instead of using the correct service for the plug type (HA, MQTT, REST).
 - **AMS Slot Changes Fail Until Reconnect** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After a keep-alive timeout, paho-mqtt auto-reconnects but the new session can be half-broken: the printer continues sending status updates but silently ignores commands. The developer mode probe detected this (no response, leaving `developer_mode` as `null`), but had no timeout or recovery — one unanswered probe permanently blocked retries. Added a 10-second probe timeout with one retry; after two consecutive unanswered probes, Bambuddy force-closes the socket to trigger a clean reconnect with a fresh session. Additionally, the developer mode probe was firing on every auto-reconnect, which destabilized some firmware MQTT brokers (A1/P1 series) — causing a reconnect → probe → disconnect feedback loop. The probe result is now cached across reconnects and only runs once on the first connection, with a 5-second delay after connect to let the session stabilize.
 - **WebSocket Crash on Printers Without `fun` Field** ([#873](https://github.com/maziggy/bambuddy/issues/873)) — Connecting to printers that don't send the MQTT `fun` field (A1, P1 series, X1Plus firmware) caused a repeating `'str' object has no attribute 'get'` crash in the WebSocket handler, showing the printer as offline with missing AMS and SD card info. The developer mode probe introduced in 0.2.3b1 published an MQTT message inside `_update_state()` between overwriting `raw_data` with the full MQTT dict (where `vt_tray` is a raw dict) and restoring the previously normalized list — the `publish()` call released the GIL, letting the event loop read the un-normalized dict and iterate over string keys instead of spool dicts. Fixed by normalizing `vt_tray` dict→list in the MQTT data before assignment, and moving preserved field restoration before the probe. Added defensive normalization in `printer_state_to_dict` as a belt-and-suspenders guard.
 

+ 19 - 13
backend/app/main.py

@@ -2551,25 +2551,31 @@ async def on_print_complete(printer_id: int, data: dict):
             if queue_auto_off:
                 async with async_session() as db:
                     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plug = result.scalar_one_or_none()
-                if plug and plug.enabled:
+                    plugs = list(result.scalars().all())
+                enabled_plugs = [p for p in plugs if p.enabled]
+                if enabled_plugs:
                     logger.info("Auto-off requested for printer %s, waiting for cooldown...", printer_id)
 
-                    async def cooldown_and_poweroff(pid: int, plug_id: int):
+                    async def cooldown_and_poweroff(pid: int, plug_ids: list[int]):
                         # Wait for nozzle to cool down
                         await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
-                        # Re-fetch plug in new session
+                        # Re-fetch plugs in new session and turn off each one
                         async with async_session() as new_db:
-                            result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
-                            p = result.scalar_one_or_none()
-                            if p and p.enabled:
-                                success = await tasmota_service.turn_off(p)
-                                if success:
-                                    logger.info("Powered off printer %s via smart plug '%s'", pid, p.name)
-                                else:
-                                    logger.warning("Failed to power off printer %s via smart plug", pid)
+                            for plug_id in plug_ids:
+                                try:
+                                    result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+                                    p = result.scalar_one_or_none()
+                                    if p and p.enabled:
+                                        service = await smart_plug_manager.get_service_for_plug(p, new_db)
+                                        success = await service.turn_off(p)
+                                        if success:
+                                            logger.info("Powered off printer %s via smart plug '%s'", pid, p.name)
+                                        else:
+                                            logger.warning("Failed to power off plug '%s' for printer %s", p.name, pid)
+                                except Exception as e:
+                                    logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e)
 
-                    asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id))
+                    asyncio.create_task(cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]))
     except Exception as e:
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 

+ 36 - 12
backend/app/services/print_scheduler.py

@@ -166,11 +166,23 @@ class PrintScheduler:
 
                     # If printer not connected, try to power on via smart plug
                     if not printer_connected:
-                        plug = await self._get_smart_plug(db, item.printer_id)
-                        if plug and plug.auto_on and plug.enabled:
-                            logger.info("Printer %s offline, attempting to power on via smart plug", item.printer_id)
-                            powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
+                        plugs = await self._get_smart_plugs(db, item.printer_id)
+                        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)
                             if powered_on:
+                                # Also turn on any remaining auto_on plugs (e.g., filter)
+                                for extra_plug in auto_on_plugs[1:]:
+                                    try:
+                                        service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
+                                        await service.turn_on(extra_plug)
+                                        logger.info(
+                                            "Also powered on plug '%s' for printer %s", extra_plug.name, item.printer_id
+                                        )
+                                    except Exception as e:
+                                        logger.warning("Failed to power on extra plug '%s': %s", extra_plug.name, e)
                                 printer_connected = True
                                 printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
@@ -1425,10 +1437,10 @@ class PrintScheduler:
                 printer_manager.send_drying_command(printer_id, ams_id, 0, 0, mode=0)
         self._drying_in_progress.pop(printer_id, None)
 
-    async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
-        """Get the smart plug associated with a printer."""
+    async def _get_smart_plugs(self, db: AsyncSession, printer_id: int) -> list[SmartPlug]:
+        """Get all smart plugs associated with a printer."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        return result.scalar_one_or_none()
+        return list(result.scalars().all())
 
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
         """Turn on smart plug and wait for printer to connect.
@@ -1509,14 +1521,26 @@ class PrintScheduler:
         if not item.auto_off_after:
             return
 
-        plug = await self._get_smart_plug(db, item.printer_id)
-        if plug and plug.enabled:
+        plugs = await self._get_smart_plugs(db, item.printer_id)
+        plug_ids = [p.id for p in plugs if p.enabled]
+        if plug_ids:
             logger.info("Auto-off: Waiting for printer %s to cool down before power off...", item.printer_id)
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
-            logger.info("Auto-off: Powering off printer %s", item.printer_id)
-            service = await smart_plug_manager.get_service_for_plug(plug, db)
-            await service.turn_off(plug)
+            # Re-fetch plugs in a fresh session after the long cooldown wait
+            async with async_session() as new_db:
+                for plug_id in plug_ids:
+                    try:
+                        result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+                        plug = result.scalar_one_or_none()
+                        if plug and plug.enabled:
+                            logger.info("Auto-off: Powering off plug '%s' for printer %s", plug.name, item.printer_id)
+                            service = await smart_plug_manager.get_service_for_plug(plug, new_db)
+                            await service.turn_off(plug)
+                    except Exception as e:
+                        logger.warning(
+                            "Auto-off: Failed to power off plug %s for printer %s: %s", plug_id, item.printer_id, e
+                        )
 
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
         """Get a human-readable name for a queue item."""

+ 58 - 69
backend/app/services/smart_plug_manager.py

@@ -134,102 +134,91 @@ class SmartPlugManager:
 
             await db.commit()
 
-    async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None":
-        """Get the main (non-script) smart plug linked to a printer.
-
-        When multiple plugs are assigned (e.g., a power plug + secondary HA switch),
-        returns the main power plug for automation control.
-        """
+    async def _get_plugs_for_printer(self, printer_id: int, db: AsyncSession) -> list["SmartPlug"]:
+        """Get all smart plugs linked to a printer for automation control."""
         from backend.app.models.smart_plug import SmartPlug
 
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        plugs = result.scalars().all()
-
-        if not plugs:
-            return None
-
-        # Prefer non-script, non-secondary plugs (main power plug)
-        for plug in plugs:
-            is_script = (
-                plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
-            )
-            if not is_script:
-                return plug
-
-        # All are scripts, return the first one
-        return plugs[0]
+        return list(result.scalars().all())
 
     async def on_print_start(self, printer_id: int, db: AsyncSession):
-        """Called when a print starts - turn on plug if configured."""
-        plug = await self._get_plug_for_printer(printer_id, db)
+        """Called when a print starts - turn on all plugs linked to this printer."""
+        plugs = await self._get_plugs_for_printer(printer_id, db)
 
-        if not plug:
+        if not plugs:
             return
 
-        if not plug.enabled:
-            logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
-            return
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
+                continue
 
-        if not plug.auto_on:
-            logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
-            return
+            if not plug.auto_on:
+                logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
+                continue
 
-        # Cancel any pending off task
-        self._cancel_pending_off(plug.id)
+            # Cancel any pending off task
+            self._cancel_pending_off(plug.id)
 
-        # Turn on the plug
-        logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
-        service = await self.get_service_for_plug(plug, db)
-        success = await service.turn_on(plug)
+            # Turn on the plug
+            logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
+            try:
+                service = await self.get_service_for_plug(plug, db)
+                success = await service.turn_on(plug)
 
-        if success:
-            # Update last state and reset auto_off_executed
-            plug.last_state = "ON"
-            plug.last_checked = datetime.now(timezone.utc)
-            plug.auto_off_executed = False  # Reset flag when turning on
-            await db.commit()
+                if success:
+                    plug.last_state = "ON"
+                    plug.last_checked = datetime.now(timezone.utc)
+                    plug.auto_off_executed = False  # Reset flag when turning on
+            except Exception as e:
+                logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
+
+        await db.commit()
 
     async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
-        """Called when a print completes - schedule turn off if configured.
+        """Called when a print completes - schedule turn off for all plugs linked to this printer.
 
         Only triggers auto-off on successful completion (status='completed').
         Failed prints keep the printer powered on for user investigation.
         """
-        plug = await self._get_plug_for_printer(printer_id, db)
-
-        if not plug:
+        # Only auto-off on successful completion, not on failures
+        if status != "completed":
+            logger.info(
+                "Print on printer %s ended with status '%s', skipping auto-off to allow investigation",
+                printer_id,
+                status,
+            )
             return
 
-        if not plug.enabled:
-            logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
-            return
+        plugs = await self._get_plugs_for_printer(printer_id, db)
 
-        if not plug.auto_off:
-            logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
+        if not plugs:
             return
 
-        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
-        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
-            logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
-            return
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
+                continue
+
+            if not plug.auto_off:
+                logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
+                continue
+
+            # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+            if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+                logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
+                continue
 
-        # Only auto-off on successful completion, not on failures
-        # This allows the user to investigate errors before power-off
-        if status != "completed":
             logger.info(
-                f"Print on printer {printer_id} ended with status '{status}', "
-                f"skipping auto-off for plug '{plug.name}' to allow investigation"
+                "Print completed successfully on printer %s, scheduling turn-off for plug '%s'",
+                printer_id,
+                plug.name,
             )
-            return
-
-        logger.info(
-            "Print completed successfully on printer %s, scheduling turn-off for plug '%s'", printer_id, plug.name
-        )
 
-        if plug.off_delay_mode == "time":
-            self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
-        elif plug.off_delay_mode == "temperature":
-            self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
+            if plug.off_delay_mode == "time":
+                self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
+            elif plug.off_delay_mode == "temperature":
+                self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
 
     def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
         """Schedule turn-off after delay."""

+ 49 - 89
backend/tests/unit/services/test_smart_plug_manager.py

@@ -58,10 +58,10 @@ class TestSmartPlugManager:
     async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
         """Verify plug is turned ON when print starts with auto_on enabled."""
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
             mock_tasmota.turn_on = AsyncMock(return_value=True)
 
             await manager.on_print_start(printer_id=1, db=mock_db)
@@ -74,10 +74,10 @@ class TestSmartPlugManager:
         mock_plug.auto_on = False
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
             mock_tasmota.turn_on = AsyncMock()
 
             await manager.on_print_start(printer_id=1, db=mock_db)
@@ -90,10 +90,10 @@ class TestSmartPlugManager:
         mock_plug.enabled = False
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
             mock_tasmota.turn_on = AsyncMock()
 
             await manager.on_print_start(printer_id=1, db=mock_db)
@@ -104,10 +104,10 @@ class TestSmartPlugManager:
     async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):
         """Verify graceful handling when no plug is linked to printer."""
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
         ):
-            mock_get_plug.return_value = None
+            mock_get_plug.return_value = []
             mock_tasmota.turn_on = AsyncMock()
 
             # Should not raise any exception
@@ -123,11 +123,11 @@ class TestSmartPlugManager:
         manager._pending_off[mock_plug.id] = mock_task
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
             mock_tasmota.turn_on = AsyncMock(return_value=True)
 
             await manager.on_print_start(printer_id=1, db=mock_db)
@@ -141,10 +141,10 @@ class TestSmartPlugManager:
         mock_plug.auto_off_executed = True
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
             mock_tasmota.turn_on = AsyncMock(return_value=True)
 
             await manager.on_print_start(printer_id=1, db=mock_db)
@@ -162,10 +162,10 @@ class TestSmartPlugManager:
         mock_plug.off_delay_minutes = 5
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_schedule_delayed_off") as mock_schedule,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
@@ -178,10 +178,10 @@ class TestSmartPlugManager:
         mock_plug.off_temp_threshold = 70
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
@@ -196,11 +196,11 @@ class TestSmartPlugManager:
         mock_plug.auto_off = False
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_schedule_delayed_off") as mock_schedule,
             patch.object(manager, "_schedule_temp_based_off") as mock_temp,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
@@ -213,10 +213,10 @@ class TestSmartPlugManager:
         mock_plug.enabled = False
 
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_schedule_delayed_off") as mock_schedule,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
@@ -226,10 +226,10 @@ class TestSmartPlugManager:
     async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):
         """Verify auto-off does NOT trigger on failed prints for investigation."""
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_schedule_delayed_off") as mock_schedule,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="failed", db=mock_db)
 
@@ -239,10 +239,10 @@ class TestSmartPlugManager:
     async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):
         """Verify auto-off does NOT trigger on aborted prints."""
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
             patch.object(manager, "_schedule_delayed_off") as mock_schedule,
         ):
-            mock_get_plug.return_value = mock_plug
+            mock_get_plug.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db)
 
@@ -325,96 +325,56 @@ class TestSmartPlugManager:
             mock_loop.assert_not_called()  # Should not call _schedule_loop
 
 
-class TestGetPlugForPrinter:
-    """Tests for _get_plug_for_printer with multiple plugs per printer."""
+class TestGetPlugsForPrinter:
+    """Tests for _get_plugs_for_printer — returns all plugs for a printer (#903)."""
 
     @pytest.fixture
     def manager(self):
         return SmartPlugManager()
 
     @pytest.mark.asyncio
-    async def test_returns_none_when_no_plugs(self, manager):
-        """Verify None is returned when no plugs are linked to printer."""
+    async def test_returns_empty_list_when_no_plugs(self, manager):
+        """Verify empty list is returned when no plugs are linked to printer."""
         mock_db = AsyncMock()
         mock_result = MagicMock()
         mock_result.scalars.return_value.all.return_value = []
         mock_db.execute = AsyncMock(return_value=mock_result)
 
-        result = await manager._get_plug_for_printer(1, mock_db)
-        assert result is None
+        result = await manager._get_plugs_for_printer(1, mock_db)
+        assert result == []
 
     @pytest.mark.asyncio
-    async def test_returns_single_plug(self, manager):
-        """Verify single plug is returned directly."""
+    async def test_returns_single_plug_as_list(self, manager):
+        """Verify single plug is returned in a list."""
         plug = MagicMock()
         plug.plug_type = "tasmota"
-        plug.ha_entity_id = None
 
         mock_db = AsyncMock()
         mock_result = MagicMock()
         mock_result.scalars.return_value.all.return_value = [plug]
         mock_db.execute = AsyncMock(return_value=mock_result)
 
-        result = await manager._get_plug_for_printer(1, mock_db)
-        assert result is plug
+        result = await manager._get_plugs_for_printer(1, mock_db)
+        assert result == [plug]
 
     @pytest.mark.asyncio
-    async def test_prefers_main_plug_over_script(self, manager):
-        """Verify main power plug is returned when both main and script exist."""
-        script_plug = MagicMock()
-        script_plug.plug_type = "homeassistant"
-        script_plug.ha_entity_id = "script.pre_print"
+    async def test_returns_all_plugs(self, manager):
+        """Verify all plugs are returned when multiple exist (#903)."""
+        plug1 = MagicMock()
+        plug1.plug_type = "homeassistant"
+        plug1.ha_entity_id = "switch.printer"
 
-        main_plug = MagicMock()
-        main_plug.plug_type = "tasmota"
-        main_plug.ha_entity_id = None
+        plug2 = MagicMock()
+        plug2.plug_type = "homeassistant"
+        plug2.ha_entity_id = "switch.filter"
 
         mock_db = AsyncMock()
         mock_result = MagicMock()
-        mock_result.scalars.return_value.all.return_value = [script_plug, main_plug]
+        mock_result.scalars.return_value.all.return_value = [plug1, plug2]
         mock_db.execute = AsyncMock(return_value=mock_result)
 
-        result = await manager._get_plug_for_printer(1, mock_db)
-        assert result is main_plug
-
-    @pytest.mark.asyncio
-    async def test_handles_multiple_non_script_plugs(self, manager):
-        """Verify no crash when multiple non-script plugs exist (e.g., Tasmota + HA switch)."""
-        tasmota_plug = MagicMock()
-        tasmota_plug.plug_type = "tasmota"
-        tasmota_plug.ha_entity_id = None
-
-        ha_switch = MagicMock()
-        ha_switch.plug_type = "homeassistant"
-        ha_switch.ha_entity_id = "switch.bathroom"
-
-        mock_db = AsyncMock()
-        mock_result = MagicMock()
-        mock_result.scalars.return_value.all.return_value = [tasmota_plug, ha_switch]
-        mock_db.execute = AsyncMock(return_value=mock_result)
-
-        result = await manager._get_plug_for_printer(1, mock_db)
-        # Should return first non-script plug (tasmota), not crash
-        assert result is tasmota_plug
-
-    @pytest.mark.asyncio
-    async def test_returns_first_script_when_all_are_scripts(self, manager):
-        """Verify first script is returned when only scripts are linked."""
-        script1 = MagicMock()
-        script1.plug_type = "homeassistant"
-        script1.ha_entity_id = "script.heat_chamber"
-
-        script2 = MagicMock()
-        script2.plug_type = "homeassistant"
-        script2.ha_entity_id = "script.exhaust_fan"
-
-        mock_db = AsyncMock()
-        mock_result = MagicMock()
-        mock_result.scalars.return_value.all.return_value = [script1, script2]
-        mock_db.execute = AsyncMock(return_value=mock_result)
-
-        result = await manager._get_plug_for_printer(1, mock_db)
-        assert result is script1
+        result = await manager._get_plugs_for_printer(1, mock_db)
+        assert result == [plug1, plug2]
 
 
 class TestAutoOffPersistent:
@@ -517,10 +477,10 @@ class TestAutoOffPersistent:
 
         # Step 1: Print starts — plug turns on
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
             patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
         ):
-            mock_get.return_value = mock_plug
+            mock_get.return_value = [mock_plug]
             mock_service = AsyncMock()
             mock_service.turn_on = AsyncMock(return_value=True)
             mock_svc.return_value = mock_service
@@ -532,10 +492,10 @@ class TestAutoOffPersistent:
 
         # Step 2: Print completes — auto-off is scheduled
         with (
-            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
             patch.object(manager, "_schedule_delayed_off") as mock_schedule,
         ):
-            mock_get.return_value = mock_plug
+            mock_get.return_value = [mock_plug]
 
             await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)