smart_plug_manager.py 21 KB

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