smart_plug_manager.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  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.services.homeassistant import homeassistant_service
  9. from backend.app.services.printer_manager import printer_manager
  10. from backend.app.services.rest_smart_plug import rest_smart_plug_service
  11. from backend.app.services.tasmota import tasmota_service
  12. if TYPE_CHECKING:
  13. from backend.app.models.smart_plug import SmartPlug
  14. logger = logging.getLogger(__name__)
  15. class SmartPlugManager:
  16. """Manages smart plug automation and delayed turn-off."""
  17. def __init__(self):
  18. self._pending_off: dict[int, asyncio.Task] = {} # plug_id -> task
  19. self._loop: asyncio.AbstractEventLoop | None = None
  20. self._scheduler_task: asyncio.Task | None = None
  21. self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed
  22. async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
  23. """Get the appropriate service for the plug type.
  24. For HA plugs, configures the service with current settings from DB.
  25. """
  26. if plug.plug_type == "homeassistant":
  27. # Configure HA service with current settings
  28. await self._configure_ha_service(db)
  29. return homeassistant_service
  30. if plug.plug_type == "rest":
  31. return rest_smart_plug_service
  32. return tasmota_service
  33. async def _configure_ha_service(self, db: AsyncSession | None = None):
  34. """Configure the HA service with URL and token from settings."""
  35. from backend.app.api.routes.settings import get_homeassistant_settings
  36. try:
  37. if db:
  38. # Use provided session
  39. ha_settings = await get_homeassistant_settings(db)
  40. else:
  41. # Create new session
  42. from backend.app.core.database import async_session
  43. async with async_session() as session:
  44. ha_settings = await get_homeassistant_settings(session)
  45. homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
  46. except Exception as e:
  47. logger.warning("Failed to configure HA service: %s", e)
  48. def set_event_loop(self, loop: asyncio.AbstractEventLoop):
  49. """Set the event loop for async operations."""
  50. self._loop = loop
  51. def start_scheduler(self):
  52. """Start the background scheduler for time-based plug control."""
  53. if self._scheduler_task is None:
  54. self._scheduler_task = asyncio.create_task(self._schedule_loop())
  55. logger.info("Smart plug scheduler started")
  56. def stop_scheduler(self):
  57. """Stop the background scheduler."""
  58. if self._scheduler_task:
  59. self._scheduler_task.cancel()
  60. self._scheduler_task = None
  61. logger.info("Smart plug scheduler stopped")
  62. async def _schedule_loop(self):
  63. """Background loop that checks scheduled on/off times every minute."""
  64. while True:
  65. try:
  66. await self._check_schedules()
  67. except Exception as e:
  68. logger.error("Error in schedule check: %s", e)
  69. # Wait until the next minute
  70. await asyncio.sleep(60)
  71. async def _check_schedules(self):
  72. """Check all plugs for scheduled on/off times."""
  73. from backend.app.core.database import async_session
  74. from backend.app.models.smart_plug import SmartPlug
  75. current_time = datetime.now().strftime("%H:%M")
  76. async with async_session() as db:
  77. result = await db.execute(
  78. select(SmartPlug).where(
  79. SmartPlug.enabled.is_(True),
  80. SmartPlug.schedule_enabled.is_(True),
  81. )
  82. )
  83. plugs = result.scalars().all()
  84. for plug in plugs:
  85. service = await self.get_service_for_plug(plug, db)
  86. # Check if we should turn on
  87. if plug.schedule_on_time == current_time:
  88. last_check = self._last_schedule_check.get(plug.id)
  89. if last_check != f"on:{current_time}":
  90. logger.info("Schedule: Turning on plug '%s' at %s", plug.name, current_time)
  91. success = await service.turn_on(plug)
  92. if success:
  93. plug.last_state = "ON"
  94. plug.last_checked = datetime.now(timezone.utc)
  95. self._last_schedule_check[plug.id] = f"on:{current_time}"
  96. # Check if we should turn off
  97. if plug.schedule_off_time == current_time:
  98. last_check = self._last_schedule_check.get(plug.id)
  99. if last_check != f"off:{current_time}":
  100. logger.info("Schedule: Turning off plug '%s' at %s", plug.name, current_time)
  101. success = await service.turn_off(plug)
  102. if success:
  103. plug.last_state = "OFF"
  104. plug.last_checked = datetime.now(timezone.utc)
  105. self._last_schedule_check[plug.id] = f"off:{current_time}"
  106. # Mark printer offline if linked
  107. if plug.printer_id:
  108. printer_manager.mark_printer_offline(plug.printer_id)
  109. await db.commit()
  110. async def _get_plugs_for_printer(self, printer_id: int, db: AsyncSession) -> list["SmartPlug"]:
  111. """Get all smart plugs linked to a printer for automation control."""
  112. from backend.app.models.smart_plug import SmartPlug
  113. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  114. return list(result.scalars().all())
  115. async def on_print_start(self, printer_id: int, db: AsyncSession):
  116. """Called when a print starts - turn on all plugs linked to this printer."""
  117. plugs = await self._get_plugs_for_printer(printer_id, db)
  118. if not plugs:
  119. return
  120. for plug in plugs:
  121. if not plug.enabled:
  122. logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
  123. continue
  124. if not plug.auto_on:
  125. logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
  126. continue
  127. # Cancel any pending off task
  128. self._cancel_pending_off(plug.id)
  129. # Turn on the plug
  130. logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
  131. try:
  132. service = await self.get_service_for_plug(plug, db)
  133. success = await service.turn_on(plug)
  134. if success:
  135. plug.last_state = "ON"
  136. plug.last_checked = datetime.now(timezone.utc)
  137. plug.auto_off_executed = False # Reset flag when turning on
  138. except Exception as e:
  139. logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
  140. await db.commit()
  141. async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
  142. """Called when a print completes - schedule turn off for all plugs linked to this printer.
  143. Only triggers auto-off on successful completion (status='completed').
  144. Failed prints keep the printer powered on for user investigation.
  145. """
  146. # Only auto-off on successful completion, not on failures
  147. if status != "completed":
  148. logger.info(
  149. "Print on printer %s ended with status '%s', skipping auto-off to allow investigation",
  150. printer_id,
  151. status,
  152. )
  153. return
  154. plugs = await self._get_plugs_for_printer(printer_id, db)
  155. if not plugs:
  156. return
  157. for plug in plugs:
  158. if not plug.enabled:
  159. logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
  160. continue
  161. if not plug.auto_off:
  162. logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
  163. continue
  164. # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
  165. if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
  166. logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
  167. continue
  168. logger.info(
  169. "Print completed successfully on printer %s, scheduling turn-off for plug '%s'",
  170. printer_id,
  171. plug.name,
  172. )
  173. if plug.off_delay_mode == "time":
  174. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  175. elif plug.off_delay_mode == "temperature":
  176. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  177. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  178. """Schedule turn-off after delay."""
  179. # Cancel any existing task for this plug
  180. self._cancel_pending_off(plug.id)
  181. logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
  182. # Mark as pending in database (survives restarts)
  183. asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
  184. task = asyncio.create_task(
  185. self._delayed_off(
  186. plug.id,
  187. plug.plug_type,
  188. plug.ip_address,
  189. plug.ha_entity_id,
  190. plug.username,
  191. plug.password,
  192. printer_id,
  193. delay_seconds,
  194. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  195. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  196. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  197. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  198. )
  199. )
  200. self._pending_off[plug.id] = task
  201. async def _delayed_off(
  202. self,
  203. plug_id: int,
  204. plug_type: str,
  205. ip_address: str | None,
  206. ha_entity_id: str | None,
  207. username: str | None,
  208. password: str | None,
  209. printer_id: int,
  210. delay_seconds: int,
  211. *,
  212. rest_off_url: str | None = None,
  213. rest_off_body: str | None = None,
  214. rest_method: str | None = None,
  215. rest_headers: str | None = None,
  216. ):
  217. """Wait and turn off."""
  218. try:
  219. await asyncio.sleep(delay_seconds)
  220. # Create a minimal plug-like object for the service
  221. class PlugInfo:
  222. def __init__(self):
  223. self.plug_type = plug_type
  224. self.ip_address = ip_address
  225. self.ha_entity_id = ha_entity_id
  226. self.username = username
  227. self.password = password
  228. self.name = f"plug_{plug_id}"
  229. # REST fields
  230. self.rest_off_url = rest_off_url
  231. self.rest_off_body = rest_off_body
  232. self.rest_method = rest_method
  233. self.rest_headers = rest_headers
  234. plug_info = PlugInfo()
  235. service = await self.get_service_for_plug(plug_info)
  236. success = await service.turn_off(plug_info)
  237. logger.info("Turned off plug %s after time delay", plug_id)
  238. # Mark auto_off_executed in database and update printer status
  239. if success:
  240. await self._mark_auto_off_executed(plug_id)
  241. # Mark the printer as offline immediately
  242. printer_manager.mark_printer_offline(printer_id)
  243. except asyncio.CancelledError:
  244. logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
  245. finally:
  246. self._pending_off.pop(plug_id, None)
  247. def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
  248. """Monitor temperature and turn off when below threshold."""
  249. # Cancel any existing task for this plug
  250. self._cancel_pending_off(plug.id)
  251. logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
  252. # Mark as pending in database (survives restarts)
  253. asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
  254. task = asyncio.create_task(
  255. self._temp_based_off(
  256. plug.id,
  257. plug.plug_type,
  258. plug.ip_address,
  259. plug.ha_entity_id,
  260. plug.username,
  261. plug.password,
  262. printer_id,
  263. temp_threshold,
  264. rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
  265. rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
  266. rest_method=plug.rest_method if plug.plug_type == "rest" else None,
  267. rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
  268. )
  269. )
  270. self._pending_off[plug.id] = task
  271. async def _temp_based_off(
  272. self,
  273. plug_id: int,
  274. plug_type: str,
  275. ip_address: str | None,
  276. ha_entity_id: str | None,
  277. username: str | None,
  278. password: str | None,
  279. printer_id: int,
  280. temp_threshold: int,
  281. *,
  282. rest_off_url: str | None = None,
  283. rest_off_body: str | None = None,
  284. rest_method: str | None = None,
  285. rest_headers: str | None = None,
  286. ):
  287. """Poll temperature until below threshold, then turn off.
  288. For dual-extruder printers (H2 series), checks both nozzles.
  289. """
  290. try:
  291. check_interval = 10 # seconds
  292. max_wait = 3600 # 1 hour max
  293. elapsed = 0
  294. while elapsed < max_wait:
  295. status = printer_manager.get_status(printer_id)
  296. if status:
  297. temps = status.temperatures or {}
  298. nozzle_temp = temps.get("nozzle", 999)
  299. # Check second nozzle for dual-extruder printers (H2 series)
  300. nozzle_2_temp = temps.get("nozzle_2")
  301. # Get the maximum temperature across all nozzles
  302. max_nozzle_temp = nozzle_temp
  303. if nozzle_2_temp is not None:
  304. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  305. logger.info(
  306. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  307. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  308. f"threshold={temp_threshold}°C"
  309. )
  310. else:
  311. logger.info(
  312. "Temp check plug %s: nozzle=%s°C, threshold=%s°C", plug_id, nozzle_temp, temp_threshold
  313. )
  314. if max_nozzle_temp < temp_threshold:
  315. # All nozzles are below threshold, turn off
  316. class PlugInfo:
  317. def __init__(self):
  318. self.plug_type = plug_type
  319. self.ip_address = ip_address
  320. self.ha_entity_id = ha_entity_id
  321. self.username = username
  322. self.password = password
  323. self.name = f"plug_{plug_id}"
  324. # REST fields
  325. self.rest_off_url = rest_off_url
  326. self.rest_off_body = rest_off_body
  327. self.rest_method = rest_method
  328. self.rest_headers = rest_headers
  329. plug_info = PlugInfo()
  330. service = await self.get_service_for_plug(plug_info)
  331. success = await service.turn_off(plug_info)
  332. logger.info(
  333. f"Turned off plug {plug_id} after nozzle temp dropped to "
  334. f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
  335. )
  336. # Mark auto_off_executed in database and update printer status
  337. if success:
  338. await self._mark_auto_off_executed(plug_id)
  339. # Mark the printer as offline immediately
  340. printer_manager.mark_printer_offline(printer_id)
  341. break
  342. await asyncio.sleep(check_interval)
  343. elapsed += check_interval
  344. if elapsed >= max_wait:
  345. logger.warning("Temperature-based turn-off timed out for plug %s after %ss", plug_id, max_wait)
  346. except asyncio.CancelledError:
  347. logger.debug("Temperature-based turn-off cancelled for plug %s", plug_id)
  348. finally:
  349. self._pending_off.pop(plug_id, None)
  350. async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
  351. """Mark a plug as having a pending auto-off (survives restarts)."""
  352. try:
  353. from backend.app.core.database import async_session
  354. from backend.app.models.smart_plug import SmartPlug
  355. async with async_session() as db:
  356. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  357. plug = result.scalar_one_or_none()
  358. if plug:
  359. plug.auto_off_pending = pending
  360. plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
  361. await db.commit()
  362. logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
  363. except Exception as e:
  364. logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
  365. async def _mark_auto_off_executed(self, plug_id: int):
  366. """Disable auto-off after it was executed (one-shot behavior unless persistent)."""
  367. try:
  368. from backend.app.core.database import async_session
  369. from backend.app.models.smart_plug import SmartPlug
  370. async with async_session() as db:
  371. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  372. plug = result.scalar_one_or_none()
  373. if plug:
  374. if not plug.auto_off_persistent:
  375. plug.auto_off = False # Disable auto-off (one-shot behavior)
  376. plug.auto_off_executed = False # Reset the flag
  377. plug.auto_off_pending = False # Clear pending state
  378. plug.auto_off_pending_since = None
  379. plug.last_state = "OFF"
  380. plug.last_checked = datetime.now(timezone.utc)
  381. await db.commit()
  382. if plug.auto_off_persistent:
  383. logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
  384. else:
  385. logger.info("Auto-off executed and disabled for plug %s", plug_id)
  386. except Exception as e:
  387. logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
  388. def _cancel_pending_off(self, plug_id: int):
  389. """Cancel any pending off task for this plug."""
  390. if plug_id in self._pending_off:
  391. logger.debug("Cancelling pending turn-off for plug %s", plug_id)
  392. self._pending_off[plug_id].cancel()
  393. del self._pending_off[plug_id]
  394. # Clear pending state in database
  395. asyncio.create_task(self._mark_auto_off_pending(plug_id, False))
  396. def cancel_all_pending(self):
  397. """Cancel all pending turn-off tasks."""
  398. for plug_id in list(self._pending_off.keys()):
  399. self._cancel_pending_off(plug_id)
  400. async def resume_pending_auto_offs(self):
  401. """Resume any pending auto-offs that were interrupted by a restart.
  402. Called on startup to check for plugs that had auto-off pending but
  403. never completed (e.g., due to service restart).
  404. """
  405. try:
  406. from backend.app.core.database import async_session
  407. from backend.app.models.smart_plug import SmartPlug
  408. async with async_session() as db:
  409. # Find all plugs with pending auto-off
  410. result = await db.execute(
  411. select(SmartPlug).where(
  412. SmartPlug.auto_off_pending.is_(True),
  413. SmartPlug.printer_id.isnot(None),
  414. )
  415. )
  416. pending_plugs = result.scalars().all()
  417. for plug in pending_plugs:
  418. # Check how long it's been pending (timeout after 2 hours)
  419. if plug.auto_off_pending_since:
  420. pending_since = plug.auto_off_pending_since
  421. if pending_since.tzinfo is None:
  422. pending_since = pending_since.replace(tzinfo=timezone.utc)
  423. elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
  424. if elapsed > 7200: # 2 hours
  425. logger.warning(
  426. f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
  427. f"clearing stale pending state"
  428. )
  429. plug.auto_off_pending = False
  430. plug.auto_off_pending_since = None
  431. await db.commit()
  432. continue
  433. logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id)
  434. # Resume the appropriate off mode
  435. if plug.off_delay_mode == "temperature":
  436. self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
  437. else:
  438. # For time mode, just turn off immediately since delay already passed
  439. logger.info("Time-based auto-off was pending, turning off plug '%s' now", plug.name)
  440. service = await self.get_service_for_plug(plug, db)
  441. success = await service.turn_off(plug)
  442. if success:
  443. await self._mark_auto_off_executed(plug.id)
  444. printer_manager.mark_printer_offline(plug.printer_id)
  445. if pending_plugs:
  446. logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
  447. except Exception as e:
  448. logger.warning("Failed to resume pending auto-offs: %s", e)
  449. # Global singleton
  450. smart_plug_manager = SmartPlugManager()