From 8c00b1b75faef2033ce6f33a51e7f17df496b901 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 7 Apr 2026 09:44:48 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + backend/app/main.py | 32 ++-- backend/app/services/print_scheduler.py | 48 ++++-- backend/app/services/smart_plug_manager.py | 131 ++++++++--------- .../unit/services/test_smart_plug_manager.py | 138 +++++++----------- 5 files changed, 165 insertions(+), 185 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8037b1a..d65a04a58 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/backend/app/main.py b/backend/app/main.py index 2da5f3011..1e371d766 100644 --- a/backend/app/main.py +++ b/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}") diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 2cd974727..f38118d69 100644 --- a/backend/app/services/print_scheduler.py +++ b/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.""" diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 24045e672..a93885f02 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/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: - return - - if not plug.enabled: - logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name) - return - - if not plug.auto_off: - logger.debug("Smart plug '%s' auto_off is disabled", plug.name) - 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 - # 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 on printer %s ended with status '%s', skipping auto-off to allow investigation", + printer_id, + status, ) return - logger.info( - "Print completed successfully on printer %s, scheduling turn-off for plug '%s'", printer_id, plug.name - ) + plugs = await self._get_plugs_for_printer(printer_id, db) - 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 not plugs: + 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 + + 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) def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int): """Schedule turn-off after delay.""" diff --git a/backend/tests/unit/services/test_smart_plug_manager.py b/backend/tests/unit/services/test_smart_plug_manager.py index 93addc09e..2a55240c2 100644 --- a/backend/tests/unit/services/test_smart_plug_manager.py +++ b/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)