smart_plug_manager.py 45 KB

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