smart_plug_manager.py 42 KB

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