smart_plug_manager.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  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. # Cancel any pending off task FIRST — a re-print must abort a
  192. # scheduled auto-off regardless of the plug's auto_on setting
  193. # (#1890). Previously this lived behind the auto_on gate, so a plug
  194. # with auto_on disabled kept its pending off and cut power mid-print.
  195. self._cancel_pending_off(plug.id)
  196. if not plug.auto_on:
  197. logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
  198. continue
  199. # Turn on the plug
  200. logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
  201. try:
  202. service = await self.get_service_for_plug(plug, db)
  203. success = await service.turn_on(plug)
  204. if success:
  205. plug.last_state = "ON"
  206. plug.last_checked = datetime.now(timezone.utc)
  207. plug.auto_off_executed = False # Reset flag when turning on
  208. except Exception as e:
  209. logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
  210. await db.commit()
  211. async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
  212. """Called when a print completes - schedule turn off for all plugs linked to this printer.
  213. Only triggers auto-off on successful completion (status='completed').
  214. Failed prints keep the printer powered on for user investigation.
  215. """
  216. # Only auto-off on successful completion, not on failures
  217. if status != "completed":
  218. logger.info(
  219. "Print on printer %s ended with status '%s', skipping auto-off to allow investigation",
  220. printer_id,
  221. status,
  222. )
  223. return
  224. plugs = await self._get_plugs_for_printer(printer_id, db)
  225. if not plugs:
  226. return
  227. for plug in plugs:
  228. if not plug.enabled:
  229. logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
  230. continue
  231. if not plug.auto_off:
  232. logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
  233. continue
  234. # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
  235. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  236. logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
  237. continue
  238. logger.info(
  239. "Print completed successfully on printer %s, scheduling turn-off for plug '%s'",
  240. printer_id,
  241. plug.name,
  242. )
  243. self._schedule_off_per_mode(plug, printer_id)
  244. def _schedule_off_per_mode(self, plug: "SmartPlug", printer_id: int):
  245. """Schedule an auto-off using the plug's configured off strategy.
  246. Honours the per-plug ``off_delay_mode`` — ``time`` waits
  247. ``off_delay_minutes``; ``temperature`` waits until the nozzle drops
  248. below ``off_temp_threshold`` (#1890 — the queue/scheduler auto-off
  249. paths used to hardcode 50°C / 600s and ignore these settings). Both
  250. branches register a cancellable task in ``_pending_off``, so a re-print
  251. cancels the pending off via :meth:`on_print_start`.
  252. """
  253. if plug.off_delay_mode == "temperature":
  254. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  255. else:
  256. # Default / "time": also the safe fallback for any unexpected value.
  257. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  258. async def schedule_off_after_queue_job(self, printer_id: int, db: AsyncSession):
  259. """Schedule auto-off for a printer after a queue job that opted in.
  260. The print-queue "auto off after this job" toggle (`auto_off_after`) is
  261. a per-job override, independent of the plug's global ``auto_off`` flag —
  262. so unlike :meth:`on_print_complete` this does NOT gate on ``plug.auto_off``.
  263. It still honours ``enabled`` and skips HA-script entities (which can only
  264. be triggered, not turned off), and uses each plug's configured off
  265. strategy via :meth:`_schedule_off_per_mode`. Replaces the three inline
  266. ``wait_for_cooldown(50°C, 600s)`` blocks that ignored plug settings,
  267. fired on the cooldown *timeout* regardless of print state, and could not
  268. be cancelled by a re-print (#1890).
  269. """
  270. plugs = await self._get_plugs_for_printer(printer_id, db)
  271. for plug in plugs:
  272. if not plug.enabled:
  273. logger.debug("Smart plug '%s' is disabled, skipping queue auto-off", plug.name)
  274. continue
  275. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  276. logger.debug("Smart plug '%s' is a HA script entity, skipping queue auto-off", plug.name)
  277. continue
  278. logger.info(
  279. "Queue job finished on printer %s, scheduling turn-off for plug '%s'",
  280. printer_id,
  281. plug.name,
  282. )
  283. self._schedule_off_per_mode(plug, printer_id)
  284. async def on_drying_complete(self, printer_id: int, db: AsyncSession):
  285. """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when
  286. an AMS drying cycle finishes on this printer (#1349).
  287. Mirrors :meth:`on_print_complete` but uses the drying-specific
  288. toggle and delay. Iterates every plug linked to the printer and
  289. fires only on the ones the user has opted-in via the per-plug
  290. toggle. Always uses the time-delay branch — temperature-based
  291. cooldown is about the printer's hotend, which isn't meaningful
  292. after a drying cycle (AMS chamber is the thing that's hot, and
  293. Bambuddy doesn't track its temperature).
  294. """
  295. plugs = await self._get_plugs_for_printer(printer_id, db)
  296. if not plugs:
  297. return
  298. for plug in plugs:
  299. if not plug.enabled:
  300. logger.debug("Smart plug '%s' is disabled, skipping drying auto-off", plug.name)
  301. continue
  302. if not plug.auto_off_after_drying:
  303. logger.debug("Smart plug '%s' auto_off_after_drying is disabled, skipping", plug.name)
  304. continue
  305. # HA script entities can only be triggered, not turned off — same
  306. # guard the print-finish path uses.
  307. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  308. logger.debug("Smart plug '%s' is a HA script entity, skipping drying auto-off", plug.name)
  309. continue
  310. logger.info(
  311. "Drying completed on printer %s, scheduling turn-off for plug '%s' in %d min",
  312. printer_id,
  313. plug.name,
  314. plug.off_delay_after_drying_minutes,
  315. )
  316. self._schedule_delayed_off(plug, printer_id, plug.off_delay_after_drying_minutes * 60)
  317. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  318. """Schedule turn-off after delay."""
  319. # Cancel any existing task for this plug
  320. self._cancel_pending_off(plug.id)
  321. logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
  322. # Mark as pending in database (survives restarts)
  323. spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
  324. task = asyncio.create_task(
  325. self._delayed_off(
  326. plug.id,
  327. plug.plug_type,
  328. plug.ip_address,
  329. plug.ha_entity_id,
  330. plug.username,
  331. plug.password,
  332. printer_id,
  333. delay_seconds,
  334. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  335. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  336. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  337. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  338. )
  339. )
  340. self._pending_off[plug.id] = task
  341. async def _delayed_off(
  342. self,
  343. plug_id: int,
  344. plug_type: str,
  345. ip_address: str | None,
  346. ha_entity_id: str | None,
  347. username: str | None,
  348. password: str | None,
  349. printer_id: int,
  350. delay_seconds: int,
  351. *,
  352. rest_off_url: str | None = None,
  353. rest_off_body: str | None = None,
  354. rest_method: str | None = None,
  355. rest_headers: str | None = None,
  356. ):
  357. """Wait and turn off."""
  358. try:
  359. await asyncio.sleep(delay_seconds)
  360. # #1890: never cut power while a print is loaded / running. The
  361. # delay fires unconditionally after N minutes, so if the user
  362. # re-started (or reprinted) in the meantime, the printer is active
  363. # again — skip the off and clear the pending flag rather than
  364. # killing the print mid-way.
  365. if printer_manager.is_print_active(printer_id):
  366. logger.info(
  367. "Skipping auto-off for plug %s: printer %s is printing again (state=%s)",
  368. plug_id,
  369. printer_id,
  370. getattr(printer_manager.get_status(printer_id), "state", "unknown"),
  371. )
  372. await self._mark_auto_off_pending(plug_id, False)
  373. return
  374. # Create a minimal plug-like object for the service
  375. class PlugInfo:
  376. def __init__(self):
  377. self.plug_type = plug_type
  378. self.ip_address = ip_address
  379. self.ha_entity_id = ha_entity_id
  380. self.username = username
  381. self.password = password
  382. self.name = f"plug_{plug_id}"
  383. # REST fields
  384. self.rest_off_url = rest_off_url
  385. self.rest_off_body = rest_off_body
  386. self.rest_method = rest_method
  387. self.rest_headers = rest_headers
  388. plug_info = PlugInfo()
  389. service = await self.get_service_for_plug(plug_info)
  390. success = await service.turn_off(plug_info)
  391. logger.info("Turned off plug %s after time delay", plug_id)
  392. # Mark auto_off_executed in database and update printer status
  393. if success:
  394. await self._mark_auto_off_executed(plug_id)
  395. # Mark the printer as offline immediately
  396. printer_manager.mark_printer_offline(printer_id)
  397. except asyncio.CancelledError:
  398. logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
  399. finally:
  400. self._pending_off.pop(plug_id, None)
  401. def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
  402. """Monitor temperature and turn off when below threshold."""
  403. # Cancel any existing task for this plug
  404. self._cancel_pending_off(plug.id)
  405. logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
  406. # Mark as pending in database (survives restarts)
  407. spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
  408. task = asyncio.create_task(
  409. self._temp_based_off(
  410. plug.id,
  411. plug.plug_type,
  412. plug.ip_address,
  413. plug.ha_entity_id,
  414. plug.username,
  415. plug.password,
  416. printer_id,
  417. temp_threshold,
  418. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  419. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  420. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  421. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  422. )
  423. )
  424. self._pending_off[plug.id] = task
  425. async def _temp_based_off(
  426. self,
  427. plug_id: int,
  428. plug_type: str,
  429. ip_address: str | None,
  430. ha_entity_id: str | None,
  431. username: str | None,
  432. password: str | None,
  433. printer_id: int,
  434. temp_threshold: int,
  435. *,
  436. rest_off_url: str | None = None,
  437. rest_off_body: str | None = None,
  438. rest_method: str | None = None,
  439. rest_headers: str | None = None,
  440. ):
  441. """Poll temperature until below threshold, then turn off.
  442. For dual-extruder printers (H2 series), checks both nozzles.
  443. """
  444. try:
  445. check_interval = 10 # seconds
  446. max_wait = 3600 # 1 hour max
  447. elapsed = 0
  448. while elapsed < max_wait:
  449. status = printer_manager.get_status(printer_id)
  450. if status:
  451. temps = status.temperatures or {}
  452. nozzle_temp = temps.get("nozzle", 999)
  453. # Check second nozzle for dual-extruder printers (H2 series)
  454. nozzle_2_temp = temps.get("nozzle_2")
  455. # Get the maximum temperature across all nozzles
  456. max_nozzle_temp = nozzle_temp
  457. if nozzle_2_temp is not None:
  458. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  459. logger.info(
  460. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  461. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  462. f"threshold={temp_threshold}°C"
  463. )
  464. else:
  465. logger.info(
  466. "Temp check plug %s: nozzle=%s°C, threshold=%s°C", plug_id, nozzle_temp, temp_threshold
  467. )
  468. if max_nozzle_temp < temp_threshold:
  469. # #1890: the nozzle can dip below the threshold between
  470. # a finished print and a fresh one starting (e.g. a
  471. # touchscreen reprint during the PREPARE/heating phase).
  472. # Guard the turn-off so we never cut power on a loaded
  473. # print; keep polling until it's genuinely idle again.
  474. if printer_manager.is_print_active(printer_id):
  475. logger.info(
  476. "Deferring temp-based auto-off for plug %s: printer %s is printing again (state=%s)",
  477. plug_id,
  478. printer_id,
  479. getattr(printer_manager.get_status(printer_id), "state", "unknown"),
  480. )
  481. await asyncio.sleep(check_interval)
  482. elapsed += check_interval
  483. continue
  484. # All nozzles are below threshold, turn off
  485. class PlugInfo:
  486. def __init__(self):
  487. self.plug_type = plug_type
  488. self.ip_address = ip_address
  489. self.ha_entity_id = ha_entity_id
  490. self.username = username
  491. self.password = password
  492. self.name = f"plug_{plug_id}"
  493. # REST fields
  494. self.rest_off_url = rest_off_url
  495. self.rest_off_body = rest_off_body
  496. self.rest_method = rest_method
  497. self.rest_headers = rest_headers
  498. plug_info = PlugInfo()
  499. service = await self.get_service_for_plug(plug_info)
  500. success = await service.turn_off(plug_info)
  501. logger.info(
  502. f"Turned off plug {plug_id} after nozzle temp dropped to "
  503. f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
  504. )
  505. # Mark auto_off_executed in database and update printer status
  506. if success:
  507. await self._mark_auto_off_executed(plug_id)
  508. # Mark the printer as offline immediately
  509. printer_manager.mark_printer_offline(printer_id)
  510. break
  511. await asyncio.sleep(check_interval)
  512. elapsed += check_interval
  513. if elapsed >= max_wait:
  514. logger.warning("Temperature-based turn-off timed out for plug %s after %ss", plug_id, max_wait)
  515. except asyncio.CancelledError:
  516. logger.debug("Temperature-based turn-off cancelled for plug %s", plug_id)
  517. finally:
  518. self._pending_off.pop(plug_id, None)
  519. async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
  520. """Mark a plug as having a pending auto-off (survives restarts)."""
  521. try:
  522. from backend.app.core.database import async_session
  523. from backend.app.models.smart_plug import SmartPlug
  524. async with async_session() as db:
  525. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  526. plug = result.scalar_one_or_none()
  527. if plug:
  528. plug.auto_off_pending = pending
  529. plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
  530. await db.commit()
  531. logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
  532. except Exception as e:
  533. logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
  534. async def _mark_auto_off_executed(self, plug_id: int):
  535. """Disable auto-off after it was executed (one-shot behavior unless persistent)."""
  536. try:
  537. from backend.app.core.database import async_session
  538. from backend.app.models.smart_plug import SmartPlug
  539. async with async_session() as db:
  540. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  541. plug = result.scalar_one_or_none()
  542. if plug:
  543. if not plug.auto_off_persistent:
  544. plug.auto_off = False # Disable auto-off (one-shot behavior)
  545. plug.auto_off_executed = False # Reset the flag
  546. plug.auto_off_pending = False # Clear pending state
  547. plug.auto_off_pending_since = None
  548. plug.last_state = "OFF"
  549. plug.last_checked = datetime.now(timezone.utc)
  550. await db.commit()
  551. if plug.auto_off_persistent:
  552. logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
  553. else:
  554. logger.info("Auto-off executed and disabled for plug %s", plug_id)
  555. except Exception as e:
  556. logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
  557. def _cancel_pending_off(self, plug_id: int):
  558. """Cancel any pending off task for this plug."""
  559. if plug_id in self._pending_off:
  560. logger.debug("Cancelling pending turn-off for plug %s", plug_id)
  561. self._pending_off[plug_id].cancel()
  562. del self._pending_off[plug_id]
  563. # Clear pending state in database
  564. spawn_background_task(self._mark_auto_off_pending(plug_id, False), name=f"plug-auto-off-pending-{plug_id}")
  565. def cancel_all_pending(self):
  566. """Cancel all pending turn-off tasks."""
  567. for plug_id in list(self._pending_off.keys()):
  568. self._cancel_pending_off(plug_id)
  569. async def resume_pending_auto_offs(self):
  570. """Resume any pending auto-offs that were interrupted by a restart.
  571. Called on startup to check for plugs that had auto-off pending but
  572. never completed (e.g., due to service restart).
  573. """
  574. try:
  575. from backend.app.core.database import async_session
  576. from backend.app.models.smart_plug import SmartPlug
  577. async with async_session() as db:
  578. # Find all plugs with pending auto-off
  579. result = await db.execute(
  580. select(SmartPlug).where(
  581. SmartPlug.auto_off_pending.is_(True),
  582. SmartPlug.printer_id.isnot(None),
  583. )
  584. )
  585. pending_plugs = result.scalars().all()
  586. for plug in pending_plugs:
  587. # Check how long it's been pending (timeout after 2 hours)
  588. if plug.auto_off_pending_since:
  589. pending_since = plug.auto_off_pending_since
  590. if pending_since.tzinfo is None:
  591. pending_since = pending_since.replace(tzinfo=timezone.utc)
  592. elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
  593. if elapsed > 7200: # 2 hours
  594. logger.warning(
  595. f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
  596. f"clearing stale pending state"
  597. )
  598. plug.auto_off_pending = False
  599. plug.auto_off_pending_since = None
  600. await db.commit()
  601. continue
  602. logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id)
  603. # #1890: never resume a power-off onto a live print. If the
  604. # printer started a new print during the downtime, the stale
  605. # pending off must be dropped, not executed — same guard the
  606. # live off-executors use.
  607. if printer_manager.is_print_active(plug.printer_id):
  608. logger.info(
  609. "Not resuming auto-off for plug '%s': printer %s is printing (state=%s); clearing pending",
  610. plug.name,
  611. plug.printer_id,
  612. getattr(printer_manager.get_status(plug.printer_id), "state", "unknown"),
  613. )
  614. plug.auto_off_pending = False
  615. plug.auto_off_pending_since = None
  616. await db.commit()
  617. continue
  618. # Resume the appropriate off mode
  619. if plug.off_delay_mode == "temperature":
  620. self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
  621. else:
  622. # For time mode, just turn off immediately since delay already passed
  623. logger.info("Time-based auto-off was pending, turning off plug '%s' now", plug.name)
  624. service = await self.get_service_for_plug(plug, db)
  625. success = await service.turn_off(plug)
  626. if success:
  627. await self._mark_auto_off_executed(plug.id)
  628. printer_manager.mark_printer_offline(plug.printer_id)
  629. if pending_plugs:
  630. logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
  631. except Exception as e:
  632. logger.warning("Failed to resume pending auto-offs: %s", e)
  633. # Global singleton
  634. smart_plug_manager = SmartPlugManager()