smart_plug_manager.py 21 KB

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