smart_plug_manager.py 36 KB

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