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.
This commit is contained in:
maziggy
2026-04-07 09:44:48 +02:00
parent 2d9a56b3d0
commit 8c00b1b75f
5 changed files with 165 additions and 185 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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."""
+60 -71
View File
@@ -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."""
@@ -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)