smart_plug_manager.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  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. from backend.app.utils.local_time import next_local_hour, to_naive_utc, utcnow_naive
  14. if TYPE_CHECKING:
  15. from backend.app.models.smart_plug import SmartPlug
  16. logger = logging.getLogger(__name__)
  17. class SmartPlugManager:
  18. """Manages smart plug automation and delayed turn-off."""
  19. def __init__(self):
  20. self._pending_off: dict[int, asyncio.Task] = {} # plug_id -> task
  21. self._loop: asyncio.AbstractEventLoop | None = None
  22. self._scheduler_task: asyncio.Task | None = None
  23. self._snapshot_task: asyncio.Task | None = None
  24. self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed
  25. async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
  26. """Get the appropriate service for the plug type.
  27. For HA plugs, configures the service with current settings from DB.
  28. """
  29. if plug.plug_type == "homeassistant":
  30. # Configure HA service with current settings
  31. await self._configure_ha_service(db)
  32. return homeassistant_service
  33. if plug.plug_type == "rest":
  34. return rest_smart_plug_service
  35. return tasmota_service
  36. async def _configure_ha_service(self, db: AsyncSession | None = None):
  37. """Configure the HA service with URL and token from settings."""
  38. from backend.app.api.routes.settings import get_homeassistant_settings
  39. try:
  40. if db:
  41. # Use provided session
  42. ha_settings = await get_homeassistant_settings(db)
  43. else:
  44. # Create new session
  45. from backend.app.core.database import async_session
  46. async with async_session() as session:
  47. ha_settings = await get_homeassistant_settings(session)
  48. homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
  49. except Exception as e:
  50. logger.warning("Failed to configure HA service: %s", e)
  51. def set_event_loop(self, loop: asyncio.AbstractEventLoop):
  52. """Set the event loop for async operations."""
  53. self._loop = loop
  54. def start_scheduler(self):
  55. """Start the background scheduler for time-based plug control."""
  56. if self._scheduler_task is None:
  57. self._scheduler_task = asyncio.create_task(self._schedule_loop())
  58. logger.info("Smart plug scheduler started")
  59. if self._snapshot_task is None:
  60. self._snapshot_task = asyncio.create_task(self._snapshot_loop())
  61. logger.info("Smart plug energy snapshot loop started")
  62. def stop_scheduler(self):
  63. """Stop the background scheduler."""
  64. if self._scheduler_task:
  65. self._scheduler_task.cancel()
  66. self._scheduler_task = None
  67. logger.info("Smart plug scheduler stopped")
  68. if self._snapshot_task:
  69. self._snapshot_task.cancel()
  70. self._snapshot_task = None
  71. logger.info("Smart plug energy snapshot loop stopped")
  72. async def _schedule_loop(self):
  73. """Background loop that checks scheduled on/off times every minute."""
  74. while True:
  75. try:
  76. await self._check_schedules()
  77. except Exception as e:
  78. logger.error("Error in schedule check: %s", e)
  79. # Wait until the next minute
  80. await asyncio.sleep(60)
  81. async def _snapshot_loop(self):
  82. """Background loop that captures each plug's lifetime energy counter.
  83. Powers date-range queries in "total consumption" energy mode (#941) and,
  84. since #2539, the derived Today / Yesterday figures for every plug that
  85. reports only a cumulative counter.
  86. Ticks on the local hour rather than every 3600s from boot. That is what
  87. makes the derivation exact: a drifting timer leaves the last snapshot
  88. before midnight up to an hour early, and an hour of a printer's draw is
  89. a real number of watt-hours to lose off the day boundary. Aligning to the
  90. *local* hour also lands a tick on local midnight in the half-hour-offset
  91. timezones (India, Nepal), where midnight is not on a UTC hour at all.
  92. """
  93. # Short warm-up delay so other services finish booting; still gives us an
  94. # initial snapshot well before the first boundary.
  95. await asyncio.sleep(30)
  96. while True:
  97. try:
  98. await self._capture_energy_snapshots()
  99. except Exception as e:
  100. logger.error("Error in energy snapshot capture: %s", e)
  101. now = datetime.now(timezone.utc)
  102. delay = (next_local_hour(now) - now).total_seconds()
  103. await asyncio.sleep(max(delay, 60))
  104. async def _capture_energy_snapshots(self):
  105. """Capture one energy snapshot row per plug with a usable lifetime counter."""
  106. from backend.app.core.database import async_session
  107. from backend.app.models.smart_plug import SmartPlug
  108. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  109. async with async_session() as db:
  110. plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))
  111. plugs = list(plugs_result.scalars().all())
  112. if not plugs:
  113. return
  114. # Naive UTC: the column is naive, and asyncpg rejects an aware value
  115. # outright (SQLite quietly drops the offset, which is why this went
  116. # unnoticed — on Postgres the whole capture raised).
  117. now = utcnow_naive()
  118. captured = 0
  119. for plug in plugs:
  120. # MQTT plugs only publish a "today" counter that resets at midnight —
  121. # they can never feed cumulative snapshots, so skip them outright to
  122. # avoid a noisy tasmota-service fallback attempt on an IP-less plug.
  123. if plug.plug_type == "mqtt":
  124. continue
  125. try:
  126. service = await self.get_service_for_plug(plug, db)
  127. energy = await service.get_energy(plug)
  128. except Exception as e:
  129. logger.debug("Snapshot: failed to read energy from plug %s: %s", plug.id, e)
  130. continue
  131. if not energy:
  132. continue
  133. lifetime = energy.get("total")
  134. if lifetime is None:
  135. # The plug exposes no cumulative counter — a REST plug with only
  136. # rest_energy_path set, say. Nothing to snapshot, and its Today
  137. # comes straight from the device anyway.
  138. continue
  139. db.add(
  140. SmartPlugEnergySnapshot(
  141. plug_id=plug.id,
  142. recorded_at=now,
  143. lifetime_kwh=float(lifetime),
  144. )
  145. )
  146. captured += 1
  147. if captured:
  148. await db.commit()
  149. logger.info("Captured %d energy snapshot(s)", captured)
  150. async def _check_schedules(self):
  151. """Check all plugs for scheduled on/off times."""
  152. from backend.app.core.database import async_session
  153. from backend.app.models.smart_plug import SmartPlug
  154. current_time = datetime.now().strftime("%H:%M")
  155. async with async_session() as db:
  156. result = await db.execute(
  157. select(SmartPlug).where(
  158. SmartPlug.enabled.is_(True),
  159. SmartPlug.schedule_enabled.is_(True),
  160. )
  161. )
  162. plugs = result.scalars().all()
  163. for plug in plugs:
  164. service = await self.get_service_for_plug(plug, db)
  165. # Check if we should turn on
  166. if plug.schedule_on_time == current_time:
  167. last_check = self._last_schedule_check.get(plug.id)
  168. if last_check != f"on:{current_time}":
  169. logger.info("Schedule: Turning on plug '%s' at %s", plug.name, current_time)
  170. success = await service.turn_on(plug)
  171. if success:
  172. plug.last_state = "ON"
  173. plug.last_checked = utcnow_naive()
  174. self._last_schedule_check[plug.id] = f"on:{current_time}"
  175. # Check if we should turn off
  176. if plug.schedule_off_time == current_time:
  177. last_check = self._last_schedule_check.get(plug.id)
  178. if last_check != f"off:{current_time}":
  179. logger.info("Schedule: Turning off plug '%s' at %s", plug.name, current_time)
  180. success = await service.turn_off(plug)
  181. if success:
  182. plug.last_state = "OFF"
  183. plug.last_checked = utcnow_naive()
  184. self._last_schedule_check[plug.id] = f"off:{current_time}"
  185. # Mark printer offline if this plug feeds it (#2629)
  186. if plug.printer_id and plug.controls_printer_power:
  187. printer_manager.mark_printer_offline(plug.printer_id)
  188. await db.commit()
  189. async def _get_plugs_for_printer(self, printer_id: int, db: AsyncSession) -> list["SmartPlug"]:
  190. """Get all smart plugs linked to a printer for automation control."""
  191. from backend.app.models.smart_plug import SmartPlug
  192. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  193. return list(result.scalars().all())
  194. async def on_print_start(self, printer_id: int, db: AsyncSession):
  195. """Called when a print starts - turn on all plugs linked to this printer."""
  196. plugs = await self._get_plugs_for_printer(printer_id, db)
  197. if not plugs:
  198. return
  199. for plug in plugs:
  200. if not plug.enabled:
  201. logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
  202. continue
  203. # Cancel any pending off task FIRST — a re-print must abort a
  204. # scheduled auto-off regardless of the plug's auto_on setting
  205. # (#1890). Previously this lived behind the auto_on gate, so a plug
  206. # with auto_on disabled kept its pending off and cut power mid-print.
  207. self._cancel_pending_off(plug.id)
  208. if not plug.auto_on:
  209. logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
  210. continue
  211. # Turn on the plug
  212. logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
  213. try:
  214. service = await self.get_service_for_plug(plug, db)
  215. success = await service.turn_on(plug)
  216. if success:
  217. plug.last_state = "ON"
  218. plug.last_checked = utcnow_naive()
  219. plug.auto_off_executed = False # Reset flag when turning on
  220. except Exception as e:
  221. logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
  222. await db.commit()
  223. async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
  224. """Called when a print completes - schedule turn off for all plugs linked to this printer.
  225. Only triggers auto-off on successful completion (status='completed').
  226. Failed prints keep the printer powered on for user investigation.
  227. """
  228. # Only auto-off on successful completion, not on failures
  229. if status != "completed":
  230. logger.info(
  231. "Print on printer %s ended with status '%s', skipping auto-off to allow investigation",
  232. printer_id,
  233. status,
  234. )
  235. return
  236. plugs = await self._get_plugs_for_printer(printer_id, db)
  237. if not plugs:
  238. return
  239. for plug in plugs:
  240. if not plug.enabled:
  241. logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
  242. continue
  243. if not plug.auto_off:
  244. logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
  245. continue
  246. # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
  247. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  248. logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
  249. continue
  250. logger.info(
  251. "Print completed successfully on printer %s, scheduling turn-off for plug '%s'",
  252. printer_id,
  253. plug.name,
  254. )
  255. self._schedule_off_per_mode(plug, printer_id)
  256. def _schedule_off_per_mode(self, plug: "SmartPlug", printer_id: int):
  257. """Schedule an auto-off using the plug's configured off strategy.
  258. Honours the per-plug ``off_delay_mode`` — ``time`` waits
  259. ``off_delay_minutes``; ``temperature`` waits until the nozzle drops
  260. below ``off_temp_threshold`` (#1890 — the queue/scheduler auto-off
  261. paths used to hardcode 50°C / 600s and ignore these settings). Both
  262. branches register a cancellable task in ``_pending_off``, so a re-print
  263. cancels the pending off via :meth:`on_print_start`.
  264. """
  265. if plug.off_delay_mode == "temperature":
  266. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  267. else:
  268. # Default / "time": also the safe fallback for any unexpected value.
  269. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  270. async def schedule_off_after_queue_job(self, printer_id: int, db: AsyncSession):
  271. """Schedule auto-off for a printer after a queue job that opted in.
  272. The print-queue "auto off after this job" toggle (`auto_off_after`) is
  273. a per-job override, independent of the plug's global ``auto_off`` flag —
  274. so unlike :meth:`on_print_complete` this does NOT gate on ``plug.auto_off``.
  275. It still honours ``enabled`` and skips HA-script entities (which can only
  276. be triggered, not turned off), and uses each plug's configured off
  277. strategy via :meth:`_schedule_off_per_mode`. Replaces the three inline
  278. ``wait_for_cooldown(50°C, 600s)`` blocks that ignored plug settings,
  279. fired on the cooldown *timeout* regardless of print state, and could not
  280. be cancelled by a re-print (#1890).
  281. """
  282. plugs = await self._get_plugs_for_printer(printer_id, db)
  283. for plug in plugs:
  284. if not plug.enabled:
  285. logger.debug("Smart plug '%s' is disabled, skipping queue auto-off", plug.name)
  286. continue
  287. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  288. logger.debug("Smart plug '%s' is a HA script entity, skipping queue auto-off", plug.name)
  289. continue
  290. logger.info(
  291. "Queue job finished on printer %s, scheduling turn-off for plug '%s'",
  292. printer_id,
  293. plug.name,
  294. )
  295. self._schedule_off_per_mode(plug, printer_id)
  296. async def on_drying_complete(self, printer_id: int, db: AsyncSession):
  297. """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when
  298. an AMS drying cycle finishes on this printer (#1349).
  299. Mirrors :meth:`on_print_complete` but uses the drying-specific
  300. toggle and delay. Iterates every plug linked to the printer and
  301. fires only on the ones the user has opted-in via the per-plug
  302. toggle. Always uses the time-delay branch — temperature-based
  303. cooldown is about the printer's hotend, which isn't meaningful
  304. after a drying cycle (AMS chamber is the thing that's hot, and
  305. Bambuddy doesn't track its temperature).
  306. """
  307. plugs = await self._get_plugs_for_printer(printer_id, db)
  308. if not plugs:
  309. return
  310. for plug in plugs:
  311. if not plug.enabled:
  312. logger.debug("Smart plug '%s' is disabled, skipping drying auto-off", plug.name)
  313. continue
  314. if not plug.auto_off_after_drying:
  315. logger.debug("Smart plug '%s' auto_off_after_drying is disabled, skipping", plug.name)
  316. continue
  317. # HA script entities can only be triggered, not turned off — same
  318. # guard the print-finish path uses.
  319. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  320. logger.debug("Smart plug '%s' is a HA script entity, skipping drying auto-off", plug.name)
  321. continue
  322. logger.info(
  323. "Drying completed on printer %s, scheduling turn-off for plug '%s' in %d min",
  324. printer_id,
  325. plug.name,
  326. plug.off_delay_after_drying_minutes,
  327. )
  328. self._schedule_delayed_off(plug, printer_id, plug.off_delay_after_drying_minutes * 60)
  329. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  330. """Schedule turn-off after delay."""
  331. # Cancel any existing task for this plug
  332. self._cancel_pending_off(plug.id)
  333. logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
  334. # Mark as pending in database (survives restarts)
  335. spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
  336. task = asyncio.create_task(
  337. self._delayed_off(
  338. plug.id,
  339. plug.plug_type,
  340. plug.ip_address,
  341. plug.ha_entity_id,
  342. plug.username,
  343. plug.password,
  344. printer_id,
  345. delay_seconds,
  346. controls_printer_power=plug.controls_printer_power,
  347. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  348. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  349. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  350. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  351. )
  352. )
  353. self._pending_off[plug.id] = task
  354. async def _delayed_off(
  355. self,
  356. plug_id: int,
  357. plug_type: str,
  358. ip_address: str | None,
  359. ha_entity_id: str | None,
  360. username: str | None,
  361. password: str | None,
  362. printer_id: int,
  363. delay_seconds: int,
  364. *,
  365. controls_printer_power: bool = True,
  366. rest_off_url: str | None = None,
  367. rest_off_body: str | None = None,
  368. rest_method: str | None = None,
  369. rest_headers: str | None = None,
  370. ):
  371. """Wait and turn off."""
  372. try:
  373. await asyncio.sleep(delay_seconds)
  374. # #1890: never cut power while a print is loaded / running. The
  375. # delay fires unconditionally after N minutes, so if the user
  376. # re-started (or reprinted) in the meantime, the printer is active
  377. # again — skip the off and clear the pending flag rather than
  378. # killing the print mid-way.
  379. if printer_manager.is_print_active(printer_id):
  380. logger.info(
  381. "Skipping auto-off for plug %s: printer %s is printing again (state=%s)",
  382. plug_id,
  383. printer_id,
  384. getattr(printer_manager.get_status(printer_id), "state", "unknown"),
  385. )
  386. await self._mark_auto_off_pending(plug_id, False)
  387. return
  388. # Create a minimal plug-like object for the service
  389. class PlugInfo:
  390. def __init__(self):
  391. self.plug_type = plug_type
  392. self.ip_address = ip_address
  393. self.ha_entity_id = ha_entity_id
  394. self.username = username
  395. self.password = password
  396. self.name = f"plug_{plug_id}"
  397. # REST fields
  398. self.rest_off_url = rest_off_url
  399. self.rest_off_body = rest_off_body
  400. self.rest_method = rest_method
  401. self.rest_headers = rest_headers
  402. plug_info = PlugInfo()
  403. service = await self.get_service_for_plug(plug_info)
  404. success = await service.turn_off(plug_info)
  405. logger.info("Turned off plug %s after time delay", plug_id)
  406. # Mark auto_off_executed in database and update printer status
  407. if success:
  408. await self._mark_auto_off_executed(plug_id)
  409. # Mark the printer as offline immediately — but only when this
  410. # plug actually feeds the printer (#2629).
  411. if controls_printer_power:
  412. printer_manager.mark_printer_offline(printer_id)
  413. except asyncio.CancelledError:
  414. logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
  415. finally:
  416. self._pending_off.pop(plug_id, None)
  417. def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
  418. """Monitor temperature and turn off when below threshold."""
  419. # Cancel any existing task for this plug
  420. self._cancel_pending_off(plug.id)
  421. logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
  422. # Mark as pending in database (survives restarts)
  423. spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
  424. task = asyncio.create_task(
  425. self._temp_based_off(
  426. plug.id,
  427. plug.plug_type,
  428. plug.ip_address,
  429. plug.ha_entity_id,
  430. plug.username,
  431. plug.password,
  432. printer_id,
  433. temp_threshold,
  434. controls_printer_power=plug.controls_printer_power,
  435. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  436. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  437. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  438. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  439. )
  440. )
  441. self._pending_off[plug.id] = task
  442. async def _temp_based_off(
  443. self,
  444. plug_id: int,
  445. plug_type: str,
  446. ip_address: str | None,
  447. ha_entity_id: str | None,
  448. username: str | None,
  449. password: str | None,
  450. printer_id: int,
  451. temp_threshold: int,
  452. *,
  453. controls_printer_power: bool = True,
  454. rest_off_url: str | None = None,
  455. rest_off_body: str | None = None,
  456. rest_method: str | None = None,
  457. rest_headers: str | None = None,
  458. ):
  459. """Poll temperature until below threshold, then turn off.
  460. For dual-extruder printers (H2 series), checks both nozzles.
  461. """
  462. try:
  463. check_interval = 10 # seconds
  464. max_wait = 3600 # 1 hour max
  465. elapsed = 0
  466. while elapsed < max_wait:
  467. status = printer_manager.get_status(printer_id)
  468. if status:
  469. temps = status.temperatures or {}
  470. nozzle_temp = temps.get("nozzle", 999)
  471. # Check second nozzle for dual-extruder printers (H2 series)
  472. nozzle_2_temp = temps.get("nozzle_2")
  473. # Get the maximum temperature across all nozzles
  474. max_nozzle_temp = nozzle_temp
  475. if nozzle_2_temp is not None:
  476. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  477. logger.info(
  478. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  479. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  480. f"threshold={temp_threshold}°C"
  481. )
  482. else:
  483. logger.info(
  484. "Temp check plug %s: nozzle=%s°C, threshold=%s°C", plug_id, nozzle_temp, temp_threshold
  485. )
  486. if max_nozzle_temp < temp_threshold:
  487. # #1890: the nozzle can dip below the threshold between
  488. # a finished print and a fresh one starting (e.g. a
  489. # touchscreen reprint during the PREPARE/heating phase).
  490. # Guard the turn-off so we never cut power on a loaded
  491. # print; keep polling until it's genuinely idle again.
  492. if printer_manager.is_print_active(printer_id):
  493. logger.info(
  494. "Deferring temp-based auto-off for plug %s: printer %s is printing again (state=%s)",
  495. plug_id,
  496. printer_id,
  497. getattr(printer_manager.get_status(printer_id), "state", "unknown"),
  498. )
  499. await asyncio.sleep(check_interval)
  500. elapsed += check_interval
  501. continue
  502. # All nozzles are below threshold, turn off
  503. class PlugInfo:
  504. def __init__(self):
  505. self.plug_type = plug_type
  506. self.ip_address = ip_address
  507. self.ha_entity_id = ha_entity_id
  508. self.username = username
  509. self.password = password
  510. self.name = f"plug_{plug_id}"
  511. # REST fields
  512. self.rest_off_url = rest_off_url
  513. self.rest_off_body = rest_off_body
  514. self.rest_method = rest_method
  515. self.rest_headers = rest_headers
  516. plug_info = PlugInfo()
  517. service = await self.get_service_for_plug(plug_info)
  518. success = await service.turn_off(plug_info)
  519. logger.info(
  520. f"Turned off plug {plug_id} after nozzle temp dropped to "
  521. f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
  522. )
  523. # Mark auto_off_executed in database and update printer status
  524. if success:
  525. await self._mark_auto_off_executed(plug_id)
  526. # Mark the printer as offline immediately — but only
  527. # when this plug actually feeds the printer (#2629).
  528. if controls_printer_power:
  529. printer_manager.mark_printer_offline(printer_id)
  530. break
  531. await asyncio.sleep(check_interval)
  532. elapsed += check_interval
  533. if elapsed >= max_wait:
  534. logger.warning("Temperature-based turn-off timed out for plug %s after %ss", plug_id, max_wait)
  535. except asyncio.CancelledError:
  536. logger.debug("Temperature-based turn-off cancelled for plug %s", plug_id)
  537. finally:
  538. self._pending_off.pop(plug_id, None)
  539. async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
  540. """Mark a plug as having a pending auto-off (survives restarts)."""
  541. try:
  542. from backend.app.core.database import async_session
  543. from backend.app.models.smart_plug import SmartPlug
  544. async with async_session() as db:
  545. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  546. plug = result.scalar_one_or_none()
  547. if plug:
  548. plug.auto_off_pending = pending
  549. plug.auto_off_pending_since = utcnow_naive() if pending else None
  550. await db.commit()
  551. logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
  552. except Exception as e:
  553. logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
  554. async def _mark_auto_off_executed(self, plug_id: int):
  555. """Disable auto-off after it was executed (one-shot behavior unless persistent)."""
  556. try:
  557. from backend.app.core.database import async_session
  558. from backend.app.models.smart_plug import SmartPlug
  559. async with async_session() as db:
  560. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  561. plug = result.scalar_one_or_none()
  562. if plug:
  563. if not plug.auto_off_persistent:
  564. plug.auto_off = False # Disable auto-off (one-shot behavior)
  565. plug.auto_off_executed = False # Reset the flag
  566. plug.auto_off_pending = False # Clear pending state
  567. plug.auto_off_pending_since = None
  568. plug.last_state = "OFF"
  569. plug.last_checked = utcnow_naive()
  570. await db.commit()
  571. if plug.auto_off_persistent:
  572. logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
  573. else:
  574. logger.info("Auto-off executed and disabled for plug %s", plug_id)
  575. except Exception as e:
  576. logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
  577. def _cancel_pending_off(self, plug_id: int):
  578. """Cancel any pending off task for this plug."""
  579. if plug_id in self._pending_off:
  580. logger.debug("Cancelling pending turn-off for plug %s", plug_id)
  581. self._pending_off[plug_id].cancel()
  582. del self._pending_off[plug_id]
  583. # Clear pending state in database
  584. spawn_background_task(self._mark_auto_off_pending(plug_id, False), name=f"plug-auto-off-pending-{plug_id}")
  585. def cancel_all_pending(self):
  586. """Cancel all pending turn-off tasks."""
  587. for plug_id in list(self._pending_off.keys()):
  588. self._cancel_pending_off(plug_id)
  589. async def resume_pending_auto_offs(self):
  590. """Resume any pending auto-offs that were interrupted by a restart.
  591. Called on startup to check for plugs that had auto-off pending but
  592. never completed (e.g., due to service restart).
  593. """
  594. try:
  595. from backend.app.core.database import async_session
  596. from backend.app.models.smart_plug import SmartPlug
  597. async with async_session() as db:
  598. # Find all plugs with pending auto-off
  599. result = await db.execute(
  600. select(SmartPlug).where(
  601. SmartPlug.auto_off_pending.is_(True),
  602. SmartPlug.printer_id.isnot(None),
  603. )
  604. )
  605. pending_plugs = result.scalars().all()
  606. for plug in pending_plugs:
  607. # Check how long it's been pending (timeout after 2 hours)
  608. if plug.auto_off_pending_since:
  609. pending_since = to_naive_utc(plug.auto_off_pending_since)
  610. elapsed = (utcnow_naive() - pending_since).total_seconds()
  611. if elapsed > 7200: # 2 hours
  612. logger.warning(
  613. f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
  614. f"clearing stale pending state"
  615. )
  616. plug.auto_off_pending = False
  617. plug.auto_off_pending_since = None
  618. await db.commit()
  619. continue
  620. logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id)
  621. # #1890: never resume a power-off onto a live print. If the
  622. # printer started a new print during the downtime, the stale
  623. # pending off must be dropped, not executed — same guard the
  624. # live off-executors use.
  625. if printer_manager.is_print_active(plug.printer_id):
  626. logger.info(
  627. "Not resuming auto-off for plug '%s': printer %s is printing (state=%s); clearing pending",
  628. plug.name,
  629. plug.printer_id,
  630. getattr(printer_manager.get_status(plug.printer_id), "state", "unknown"),
  631. )
  632. plug.auto_off_pending = False
  633. plug.auto_off_pending_since = None
  634. await db.commit()
  635. continue
  636. # Resume the appropriate off mode
  637. if plug.off_delay_mode == "temperature":
  638. self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
  639. else:
  640. # For time mode, just turn off immediately since delay already passed
  641. logger.info("Time-based auto-off was pending, turning off plug '%s' now", plug.name)
  642. service = await self.get_service_for_plug(plug, db)
  643. success = await service.turn_off(plug)
  644. if success:
  645. await self._mark_auto_off_executed(plug.id)
  646. if plug.controls_printer_power:
  647. printer_manager.mark_printer_offline(plug.printer_id)
  648. if pending_plugs:
  649. logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
  650. except Exception as e:
  651. logger.warning("Failed to resume pending auto-offs: %s", e)
  652. # Global singleton
  653. smart_plug_manager = SmartPlugManager()