mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user