smart_plug_manager.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. """Manager for smart plug automation and delayed turn-off."""
  2. import asyncio
  3. import logging
  4. from datetime import datetime, timezone
  5. from typing import TYPE_CHECKING
  6. from sqlalchemy import select
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from backend.app.core.tasks import spawn_background_task
  9. from backend.app.services.homeassistant import homeassistant_service
  10. from backend.app.services.printer_manager import printer_manager
  11. from backend.app.services.rest_smart_plug import rest_smart_plug_service
  12. from backend.app.services.tasmota import tasmota_service
  13. if TYPE_CHECKING:
  14. from backend.app.models.smart_plug import SmartPlug
  15. logger = logging.getLogger(__name__)
  16. class SmartPlugManager:
  17. """Manages smart plug automation and delayed turn-off."""
  18. def __init__(self):
  19. self._pending_off: dict[int, asyncio.Task] = {} # plug_id -> task
  20. self._loop: asyncio.AbstractEventLoop | None = None
  21. self._scheduler_task: asyncio.Task | None = None
  22. self._snapshot_task: asyncio.Task | None = None
  23. self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed
  24. async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
  25. """Get the appropriate service for the plug type.
  26. For HA plugs, configures the service with current settings from DB.
  27. """
  28. if plug.plug_type == "homeassistant":
  29. # Configure HA service with current settings
  30. await self._configure_ha_service(db)
  31. return homeassistant_service
  32. if plug.plug_type == "rest":
  33. return rest_smart_plug_service
  34. return tasmota_service
  35. async def _configure_ha_service(self, db: AsyncSession | None = None):
  36. """Configure the HA service with URL and token from settings."""
  37. from backend.app.api.routes.settings import get_homeassistant_settings
  38. try:
  39. if db:
  40. # Use provided session
  41. ha_settings = await get_homeassistant_settings(db)
  42. else:
  43. # Create new session
  44. from backend.app.core.database import async_session
  45. async with async_session() as session:
  46. ha_settings = await get_homeassistant_settings(session)
  47. homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
  48. except Exception as e:
  49. logger.warning("Failed to configure HA service: %s", e)
  50. def set_event_loop(self, loop: asyncio.AbstractEventLoop):
  51. """Set the event loop for async operations."""
  52. self._loop = loop
  53. def start_scheduler(self):
  54. """Start the background scheduler for time-based plug control."""
  55. if self._scheduler_task is None:
  56. self._scheduler_task = asyncio.create_task(self._schedule_loop())
  57. logger.info("Smart plug scheduler started")
  58. if self._snapshot_task is None:
  59. self._snapshot_task = asyncio.create_task(self._snapshot_loop())
  60. logger.info("Smart plug energy snapshot loop started")
  61. def stop_scheduler(self):
  62. """Stop the background scheduler."""
  63. if self._scheduler_task:
  64. self._scheduler_task.cancel()
  65. self._scheduler_task = None
  66. logger.info("Smart plug scheduler stopped")
  67. if self._snapshot_task:
  68. self._snapshot_task.cancel()
  69. self._snapshot_task = None
  70. logger.info("Smart plug energy snapshot loop stopped")
  71. async def _schedule_loop(self):
  72. """Background loop that checks scheduled on/off times every minute."""
  73. while True:
  74. try:
  75. await self._check_schedules()
  76. except Exception as e:
  77. logger.error("Error in schedule check: %s", e)
  78. # Wait until the next minute
  79. await asyncio.sleep(60)
  80. async def _snapshot_loop(self):
  81. """Background loop that captures each plug's lifetime energy counter hourly.
  82. Powers date-range queries in "total consumption" energy mode (#941). Takes
  83. a snapshot shortly after startup so the first bucket isn't empty, then
  84. every hour.
  85. """
  86. # Short warm-up delay so other services finish booting; still gives us
  87. # an initial snapshot well before the first hour mark.
  88. await asyncio.sleep(30)
  89. while True:
  90. try:
  91. await self._capture_energy_snapshots()
  92. except Exception as e:
  93. logger.error("Error in energy snapshot capture: %s", e)
  94. await asyncio.sleep(3600) # 1 hour
  95. async def _capture_energy_snapshots(self):
  96. """Capture one energy snapshot row per plug with a usable lifetime counter."""
  97. from datetime import timezone
  98. from backend.app.core.database import async_session
  99. from backend.app.models.smart_plug import SmartPlug
  100. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  101. async with async_session() as db:
  102. plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))
  103. plugs = list(plugs_result.scalars().all())
  104. if not plugs:
  105. return
  106. now = datetime.now(timezone.utc)
  107. captured = 0
  108. for plug in plugs:
  109. # MQTT plugs only publish a "today" counter that resets at midnight —
  110. # they can never feed cumulative snapshots, so skip them outright to
  111. # avoid a noisy tasmota-service fallback attempt on an IP-less plug.
  112. if plug.plug_type == "mqtt":
  113. continue
  114. try:
  115. service = await self.get_service_for_plug(plug, db)
  116. energy = await service.get_energy(plug)
  117. except Exception as e:
  118. logger.debug("Snapshot: failed to read energy from plug %s: %s", plug.id, e)
  119. continue
  120. if not energy:
  121. continue
  122. lifetime = energy.get("total")
  123. if lifetime is None:
  124. # MQTT / REST plugs that only expose "today" can't be used for
  125. # cumulative snapshots — skip them.
  126. continue
  127. db.add(
  128. SmartPlugEnergySnapshot(
  129. plug_id=plug.id,
  130. recorded_at=now,
  131. lifetime_kwh=float(lifetime),
  132. )
  133. )
  134. captured += 1
  135. if captured:
  136. await db.commit()
  137. logger.info("Captured %d energy snapshot(s)", captured)
  138. async def _check_schedules(self):
  139. """Check all plugs for scheduled on/off times."""
  140. from backend.app.core.database import async_session
  141. from backend.app.models.smart_plug import SmartPlug
  142. current_time = datetime.now().strftime("%H:%M")
  143. async with async_session() as db:
  144. result = await db.execute(
  145. select(SmartPlug).where(
  146. SmartPlug.enabled.is_(True),
  147. SmartPlug.schedule_enabled.is_(True),
  148. )
  149. )
  150. plugs = result.scalars().all()
  151. for plug in plugs:
  152. service = await self.get_service_for_plug(plug, db)
  153. # Check if we should turn on
  154. if plug.schedule_on_time == current_time:
  155. last_check = self._last_schedule_check.get(plug.id)
  156. if last_check != f"on:{current_time}":
  157. logger.info("Schedule: Turning on plug '%s' at %s", plug.name, current_time)
  158. success = await service.turn_on(plug)
  159. if success:
  160. plug.last_state = "ON"
  161. plug.last_checked = datetime.now(timezone.utc)
  162. self._last_schedule_check[plug.id] = f"on:{current_time}"
  163. # Check if we should turn off
  164. if plug.schedule_off_time == current_time:
  165. last_check = self._last_schedule_check.get(plug.id)
  166. if last_check != f"off:{current_time}":
  167. logger.info("Schedule: Turning off plug '%s' at %s", plug.name, current_time)
  168. success = await service.turn_off(plug)
  169. if success:
  170. plug.last_state = "OFF"
  171. plug.last_checked = datetime.now(timezone.utc)
  172. self._last_schedule_check[plug.id] = f"off:{current_time}"
  173. # Mark printer offline if linked
  174. if plug.printer_id:
  175. printer_manager.mark_printer_offline(plug.printer_id)
  176. await db.commit()
  177. async def _get_plugs_for_printer(self, printer_id: int, db: AsyncSession) -> list["SmartPlug"]:
  178. """Get all smart plugs linked to a printer for automation control."""
  179. from backend.app.models.smart_plug import SmartPlug
  180. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  181. return list(result.scalars().all())
  182. async def on_print_start(self, printer_id: int, db: AsyncSession):
  183. """Called when a print starts - turn on all plugs linked to this printer."""
  184. plugs = await self._get_plugs_for_printer(printer_id, db)
  185. if not plugs:
  186. return
  187. for plug in plugs:
  188. if not plug.enabled:
  189. logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
  190. continue
  191. if not plug.auto_on:
  192. logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
  193. continue
  194. # Cancel any pending off task
  195. self._cancel_pending_off(plug.id)
  196. # Turn on the plug
  197. logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
  198. try:
  199. service = await self.get_service_for_plug(plug, db)
  200. success = await service.turn_on(plug)
  201. if success:
  202. plug.last_state = "ON"
  203. plug.last_checked = datetime.now(timezone.utc)
  204. plug.auto_off_executed = False # Reset flag when turning on
  205. except Exception as e:
  206. logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
  207. await db.commit()
  208. async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
  209. """Called when a print completes - schedule turn off for all plugs linked to this printer.
  210. Only triggers auto-off on successful completion (status='completed').
  211. Failed prints keep the printer powered on for user investigation.
  212. """
  213. # Only auto-off on successful completion, not on failures
  214. if status != "completed":
  215. logger.info(
  216. "Print on printer %s ended with status '%s', skipping auto-off to allow investigation",
  217. printer_id,
  218. status,
  219. )
  220. return
  221. plugs = await self._get_plugs_for_printer(printer_id, db)
  222. if not plugs:
  223. return
  224. for plug in plugs:
  225. if not plug.enabled:
  226. logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
  227. continue
  228. if not plug.auto_off:
  229. logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
  230. continue
  231. # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
  232. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  233. logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
  234. continue
  235. logger.info(
  236. "Print completed successfully on printer %s, scheduling turn-off for plug '%s'",
  237. printer_id,
  238. plug.name,
  239. )
  240. if plug.off_delay_mode == "time":
  241. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  242. elif plug.off_delay_mode == "temperature":
  243. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  244. async def on_drying_complete(self, printer_id: int, db: AsyncSession):
  245. """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when
  246. an AMS drying cycle finishes on this printer (#1349).
  247. Mirrors :meth:`on_print_complete` but uses the drying-specific
  248. toggle and delay. Iterates every plug linked to the printer and
  249. fires only on the ones the user has opted-in via the per-plug
  250. toggle. Always uses the time-delay branch — temperature-based
  251. cooldown is about the printer's hotend, which isn't meaningful
  252. after a drying cycle (AMS chamber is the thing that's hot, and
  253. Bambuddy doesn't track its temperature).
  254. """
  255. plugs = await self._get_plugs_for_printer(printer_id, db)
  256. if not plugs:
  257. return
  258. for plug in plugs:
  259. if not plug.enabled:
  260. logger.debug("Smart plug '%s' is disabled, skipping drying auto-off", plug.name)
  261. continue
  262. if not plug.auto_off_after_drying:
  263. logger.debug("Smart plug '%s' auto_off_after_drying is disabled, skipping", plug.name)
  264. continue
  265. # HA script entities can only be triggered, not turned off — same
  266. # guard the print-finish path uses.
  267. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  268. logger.debug("Smart plug '%s' is a HA script entity, skipping drying auto-off", plug.name)
  269. continue
  270. logger.info(
  271. "Drying completed on printer %s, scheduling turn-off for plug '%s' in %d min",
  272. printer_id,
  273. plug.name,
  274. plug.off_delay_after_drying_minutes,
  275. )
  276. self._schedule_delayed_off(plug, printer_id, plug.off_delay_after_drying_minutes * 60)
  277. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  278. """Schedule turn-off after delay."""
  279. # Cancel any existing task for this plug
  280. self._cancel_pending_off(plug.id)
  281. logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
  282. # Mark as pending in database (survives restarts)
  283. spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
  284. task = asyncio.create_task(
  285. self._delayed_off(
  286. plug.id,
  287. plug.plug_type,
  288. plug.ip_address,
  289. plug.ha_entity_id,
  290. plug.username,
  291. plug.password,
  292. printer_id,
  293. delay_seconds,
  294. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  295. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  296. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  297. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  298. )
  299. )
  300. self._pending_off[plug.id] = task
  301. async def _delayed_off(
  302. self,
  303. plug_id: int,
  304. plug_type: str,
  305. ip_address: str | None,
  306. ha_entity_id: str | None,
  307. username: str | None,
  308. password: str | None,
  309. printer_id: int,
  310. delay_seconds: int,
  311. *,
  312. rest_off_url: str | None = None,
  313. rest_off_body: str | None = None,
  314. rest_method: str | None = None,
  315. rest_headers: str | None = None,
  316. ):
  317. """Wait and turn off."""
  318. try:
  319. await asyncio.sleep(delay_seconds)
  320. # Create a minimal plug-like object for the service
  321. class PlugInfo:
  322. def __init__(self):
  323. self.plug_type = plug_type
  324. self.ip_address = ip_address
  325. self.ha_entity_id = ha_entity_id
  326. self.username = username
  327. self.password = password
  328. self.name = f"plug_{plug_id}"
  329. # REST fields
  330. self.rest_off_url = rest_off_url
  331. self.rest_off_body = rest_off_body
  332. self.rest_method = rest_method
  333. self.rest_headers = rest_headers
  334. plug_info = PlugInfo()
  335. service = await self.get_service_for_plug(plug_info)
  336. success = await service.turn_off(plug_info)
  337. logger.info("Turned off plug %s after time delay", plug_id)
  338. # Mark auto_off_executed in database and update printer status
  339. if success:
  340. await self._mark_auto_off_executed(plug_id)
  341. # Mark the printer as offline immediately
  342. printer_manager.mark_printer_offline(printer_id)
  343. except asyncio.CancelledError:
  344. logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
  345. finally:
  346. self._pending_off.pop(plug_id, None)
  347. def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
  348. """Monitor temperature and turn off when below threshold."""
  349. # Cancel any existing task for this plug
  350. self._cancel_pending_off(plug.id)
  351. logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
  352. # Mark as pending in database (survives restarts)
  353. spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
  354. task = asyncio.create_task(
  355. self._temp_based_off(
  356. plug.id,
  357. plug.plug_type,
  358. plug.ip_address,
  359. plug.ha_entity_id,
  360. plug.username,
  361. plug.password,
  362. printer_id,
  363. temp_threshold,
  364. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  365. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  366. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  367. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  368. )
  369. )
  370. self._pending_off[plug.id] = task
  371. async def _temp_based_off(
  372. self,
  373. plug_id: int,
  374. plug_type: str,
  375. ip_address: str | None,
  376. ha_entity_id: str | None,
  377. username: str | None,
  378. password: str | None,
  379. printer_id: int,
  380. temp_threshold: int,
  381. *,
  382. rest_off_url: str | None = None,
  383. rest_off_body: str | None = None,
  384. rest_method: str | None = None,
  385. rest_headers: str | None = None,
  386. ):
  387. """Poll temperature until below threshold, then turn off.
  388. For dual-extruder printers (H2 series), checks both nozzles.
  389. """
  390. try:
  391. check_interval = 10 # seconds
  392. max_wait = 3600 # 1 hour max
  393. elapsed = 0
  394. while elapsed < max_wait:
  395. status = printer_manager.get_status(printer_id)
  396. if status:
  397. temps = status.temperatures or {}
  398. nozzle_temp = temps.get("nozzle", 999)
  399. # Check second nozzle for dual-extruder printers (H2 series)
  400. nozzle_2_temp = temps.get("nozzle_2")
  401. # Get the maximum temperature across all nozzles
  402. max_nozzle_temp = nozzle_temp
  403. if nozzle_2_temp is not None:
  404. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  405. logger.info(
  406. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  407. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  408. f"threshold={temp_threshold}°C"
  409. )
  410. else:
  411. logger.info(
  412. "Temp check plug %s: nozzle=%s°C, threshold=%s°C", plug_id, nozzle_temp, temp_threshold
  413. )
  414. if max_nozzle_temp < temp_threshold:
  415. # All nozzles are below threshold, turn off
  416. class PlugInfo:
  417. def __init__(self):
  418. self.plug_type = plug_type
  419. self.ip_address = ip_address
  420. self.ha_entity_id = ha_entity_id
  421. self.username = username
  422. self.password = password
  423. self.name = f"plug_{plug_id}"
  424. # REST fields
  425. self.rest_off_url = rest_off_url
  426. self.rest_off_body = rest_off_body
  427. self.rest_method = rest_method
  428. self.rest_headers = rest_headers
  429. plug_info = PlugInfo()
  430. service = await self.get_service_for_plug(plug_info)
  431. success = await service.turn_off(plug_info)
  432. logger.info(
  433. f"Turned off plug {plug_id} after nozzle temp dropped to "
  434. f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
  435. )
  436. # Mark auto_off_executed in database and update printer status
  437. if success:
  438. await self._mark_auto_off_executed(plug_id)
  439. # Mark the printer as offline immediately
  440. printer_manager.mark_printer_offline(printer_id)
  441. break
  442. await asyncio.sleep(check_interval)
  443. elapsed += check_interval
  444. if elapsed >= max_wait:
  445. logger.warning("Temperature-based turn-off timed out for plug %s after %ss", plug_id, max_wait)
  446. except asyncio.CancelledError:
  447. logger.debug("Temperature-based turn-off cancelled for plug %s", plug_id)
  448. finally:
  449. self._pending_off.pop(plug_id, None)
  450. async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
  451. """Mark a plug as having a pending auto-off (survives restarts)."""
  452. try:
  453. from backend.app.core.database import async_session
  454. from backend.app.models.smart_plug import SmartPlug
  455. async with async_session() as db:
  456. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  457. plug = result.scalar_one_or_none()
  458. if plug:
  459. plug.auto_off_pending = pending
  460. plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
  461. await db.commit()
  462. logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
  463. except Exception as e:
  464. logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
  465. async def _mark_auto_off_executed(self, plug_id: int):
  466. """Disable auto-off after it was executed (one-shot behavior unless persistent)."""
  467. try:
  468. from backend.app.core.database import async_session
  469. from backend.app.models.smart_plug import SmartPlug
  470. async with async_session() as db:
  471. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  472. plug = result.scalar_one_or_none()
  473. if plug:
  474. if not plug.auto_off_persistent:
  475. plug.auto_off = False # Disable auto-off (one-shot behavior)
  476. plug.auto_off_executed = False # Reset the flag
  477. plug.auto_off_pending = False # Clear pending state
  478. plug.auto_off_pending_since = None
  479. plug.last_state = "OFF"
  480. plug.last_checked = datetime.now(timezone.utc)
  481. await db.commit()
  482. if plug.auto_off_persistent:
  483. logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
  484. else:
  485. logger.info("Auto-off executed and disabled for plug %s", plug_id)
  486. except Exception as e:
  487. logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
  488. def _cancel_pending_off(self, plug_id: int):
  489. """Cancel any pending off task for this plug."""
  490. if plug_id in self._pending_off:
  491. logger.debug("Cancelling pending turn-off for plug %s", plug_id)
  492. self._pending_off[plug_id].cancel()
  493. del self._pending_off[plug_id]
  494. # Clear pending state in database
  495. spawn_background_task(self._mark_auto_off_pending(plug_id, False), name=f"plug-auto-off-pending-{plug_id}")
  496. def cancel_all_pending(self):
  497. """Cancel all pending turn-off tasks."""
  498. for plug_id in list(self._pending_off.keys()):
  499. self._cancel_pending_off(plug_id)
  500. async def resume_pending_auto_offs(self):
  501. """Resume any pending auto-offs that were interrupted by a restart.
  502. Called on startup to check for plugs that had auto-off pending but
  503. never completed (e.g., due to service restart).
  504. """
  505. try:
  506. from backend.app.core.database import async_session
  507. from backend.app.models.smart_plug import SmartPlug
  508. async with async_session() as db:
  509. # Find all plugs with pending auto-off
  510. result = await db.execute(
  511. select(SmartPlug).where(
  512. SmartPlug.auto_off_pending.is_(True),
  513. SmartPlug.printer_id.isnot(None),
  514. )
  515. )
  516. pending_plugs = result.scalars().all()
  517. for plug in pending_plugs:
  518. # Check how long it's been pending (timeout after 2 hours)
  519. if plug.auto_off_pending_since:
  520. pending_since = plug.auto_off_pending_since
  521. if pending_since.tzinfo is None:
  522. pending_since = pending_since.replace(tzinfo=timezone.utc)
  523. elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
  524. if elapsed > 7200: # 2 hours
  525. logger.warning(
  526. f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
  527. f"clearing stale pending state"
  528. )
  529. plug.auto_off_pending = False
  530. plug.auto_off_pending_since = None
  531. await db.commit()
  532. continue
  533. logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id)
  534. # Resume the appropriate off mode
  535. if plug.off_delay_mode == "temperature":
  536. self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
  537. else:
  538. # For time mode, just turn off immediately since delay already passed
  539. logger.info("Time-based auto-off was pending, turning off plug '%s' now", plug.name)
  540. service = await self.get_service_for_plug(plug, db)
  541. success = await service.turn_off(plug)
  542. if success:
  543. await self._mark_auto_off_executed(plug.id)
  544. printer_manager.mark_printer_offline(plug.printer_id)
  545. if pending_plugs:
  546. logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
  547. except Exception as e:
  548. logger.warning("Failed to resume pending auto-offs: %s", e)
  549. # Global singleton
  550. smart_plug_manager = SmartPlugManager()