smart_plug_manager.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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(f"Failed to configure HA service: {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(f"Error in schedule check: {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(f"Schedule: Turning on plug '{plug.name}' at {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(f"Schedule: Turning off plug '{plug.name}' at {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(f"Smart plug '{plug.name}' is disabled, skipping auto-on")
  127. return
  128. if not plug.auto_on:
  129. logger.debug(f"Smart plug '{plug.name}' auto_on is disabled")
  130. return
  131. # Cancel any pending off task
  132. self._cancel_pending_off(plug.id)
  133. # Turn on the plug
  134. logger.info(f"Print started on printer {printer_id}, turning on plug '{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(f"Smart plug '{plug.name}' is disabled, skipping auto-off")
  153. return
  154. if not plug.auto_off:
  155. logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
  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(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
  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(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
  170. if plug.off_delay_mode == "time":
  171. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  172. elif plug.off_delay_mode == "temperature":
  173. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  174. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  175. """Schedule turn-off after delay."""
  176. # Cancel any existing task for this plug
  177. self._cancel_pending_off(plug.id)
  178. logger.info(f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds")
  179. # Mark as pending in database (survives restarts)
  180. asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
  181. task = asyncio.create_task(
  182. self._delayed_off(
  183. plug.id,
  184. plug.plug_type,
  185. plug.ip_address,
  186. plug.ha_entity_id,
  187. plug.username,
  188. plug.password,
  189. printer_id,
  190. delay_seconds,
  191. )
  192. )
  193. self._pending_off[plug.id] = task
  194. async def _delayed_off(
  195. self,
  196. plug_id: int,
  197. plug_type: str,
  198. ip_address: str | None,
  199. ha_entity_id: str | None,
  200. username: str | None,
  201. password: str | None,
  202. printer_id: int,
  203. delay_seconds: int,
  204. ):
  205. """Wait and turn off."""
  206. try:
  207. await asyncio.sleep(delay_seconds)
  208. # Create a minimal plug-like object for the service
  209. class PlugInfo:
  210. def __init__(self):
  211. self.plug_type = plug_type
  212. self.ip_address = ip_address
  213. self.ha_entity_id = ha_entity_id
  214. self.username = username
  215. self.password = password
  216. self.name = f"plug_{plug_id}"
  217. plug_info = PlugInfo()
  218. service = await self.get_service_for_plug(plug_info)
  219. success = await service.turn_off(plug_info)
  220. logger.info(f"Turned off plug {plug_id} after time delay")
  221. # Mark auto_off_executed in database and update printer status
  222. if success:
  223. await self._mark_auto_off_executed(plug_id)
  224. # Mark the printer as offline immediately
  225. printer_manager.mark_printer_offline(printer_id)
  226. except asyncio.CancelledError:
  227. logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
  228. finally:
  229. self._pending_off.pop(plug_id, None)
  230. def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
  231. """Monitor temperature and turn off when below threshold."""
  232. # Cancel any existing task for this plug
  233. self._cancel_pending_off(plug.id)
  234. logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
  235. # Mark as pending in database (survives restarts)
  236. asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
  237. task = asyncio.create_task(
  238. self._temp_based_off(
  239. plug.id,
  240. plug.plug_type,
  241. plug.ip_address,
  242. plug.ha_entity_id,
  243. plug.username,
  244. plug.password,
  245. printer_id,
  246. temp_threshold,
  247. )
  248. )
  249. self._pending_off[plug.id] = task
  250. async def _temp_based_off(
  251. self,
  252. plug_id: int,
  253. plug_type: str,
  254. ip_address: str | None,
  255. ha_entity_id: str | None,
  256. username: str | None,
  257. password: str | None,
  258. printer_id: int,
  259. temp_threshold: int,
  260. ):
  261. """Poll temperature until below threshold, then turn off.
  262. For dual-extruder printers (H2 series), checks both nozzles.
  263. """
  264. try:
  265. check_interval = 10 # seconds
  266. max_wait = 3600 # 1 hour max
  267. elapsed = 0
  268. while elapsed < max_wait:
  269. status = printer_manager.get_status(printer_id)
  270. if status:
  271. temps = status.temperatures or {}
  272. nozzle_temp = temps.get("nozzle", 999)
  273. # Check second nozzle for dual-extruder printers (H2 series)
  274. nozzle_2_temp = temps.get("nozzle_2")
  275. # Get the maximum temperature across all nozzles
  276. max_nozzle_temp = nozzle_temp
  277. if nozzle_2_temp is not None:
  278. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  279. logger.info(
  280. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  281. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  282. f"threshold={temp_threshold}°C"
  283. )
  284. else:
  285. logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
  286. if max_nozzle_temp < temp_threshold:
  287. # All nozzles are below threshold, turn off
  288. class PlugInfo:
  289. def __init__(self):
  290. self.plug_type = plug_type
  291. self.ip_address = ip_address
  292. self.ha_entity_id = ha_entity_id
  293. self.username = username
  294. self.password = password
  295. self.name = f"plug_{plug_id}"
  296. plug_info = PlugInfo()
  297. service = await self.get_service_for_plug(plug_info)
  298. success = await service.turn_off(plug_info)
  299. logger.info(
  300. f"Turned off plug {plug_id} after nozzle temp dropped to "
  301. f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
  302. )
  303. # Mark auto_off_executed in database and update printer status
  304. if success:
  305. await self._mark_auto_off_executed(plug_id)
  306. # Mark the printer as offline immediately
  307. printer_manager.mark_printer_offline(printer_id)
  308. break
  309. await asyncio.sleep(check_interval)
  310. elapsed += check_interval
  311. if elapsed >= max_wait:
  312. logger.warning(f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s")
  313. except asyncio.CancelledError:
  314. logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
  315. finally:
  316. self._pending_off.pop(plug_id, None)
  317. async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
  318. """Mark a plug as having a pending auto-off (survives restarts)."""
  319. try:
  320. from backend.app.core.database import async_session
  321. from backend.app.models.smart_plug import SmartPlug
  322. async with async_session() as db:
  323. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  324. plug = result.scalar_one_or_none()
  325. if plug:
  326. plug.auto_off_pending = pending
  327. plug.auto_off_pending_since = datetime.utcnow() if pending else None
  328. await db.commit()
  329. logger.debug(f"Marked plug {plug_id} auto_off_pending={pending}")
  330. except Exception as e:
  331. logger.warning(f"Failed to update plug {plug_id} pending state: {e}")
  332. async def _mark_auto_off_executed(self, plug_id: int):
  333. """Disable auto-off after it was executed (one-shot behavior)."""
  334. try:
  335. from backend.app.core.database import async_session
  336. from backend.app.models.smart_plug import SmartPlug
  337. async with async_session() as db:
  338. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  339. plug = result.scalar_one_or_none()
  340. if plug:
  341. plug.auto_off = False # Disable auto-off (one-shot behavior)
  342. plug.auto_off_executed = False # Reset the flag
  343. plug.auto_off_pending = False # Clear pending state
  344. plug.auto_off_pending_since = None
  345. plug.last_state = "OFF"
  346. plug.last_checked = datetime.utcnow()
  347. await db.commit()
  348. logger.info(f"Auto-off executed and disabled for plug {plug_id}")
  349. except Exception as e:
  350. logger.warning(f"Failed to update plug {plug_id} after auto-off: {e}")
  351. def _cancel_pending_off(self, plug_id: int):
  352. """Cancel any pending off task for this plug."""
  353. if plug_id in self._pending_off:
  354. logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
  355. self._pending_off[plug_id].cancel()
  356. del self._pending_off[plug_id]
  357. # Clear pending state in database
  358. asyncio.create_task(self._mark_auto_off_pending(plug_id, False))
  359. def cancel_all_pending(self):
  360. """Cancel all pending turn-off tasks."""
  361. for plug_id in list(self._pending_off.keys()):
  362. self._cancel_pending_off(plug_id)
  363. async def resume_pending_auto_offs(self):
  364. """Resume any pending auto-offs that were interrupted by a restart.
  365. Called on startup to check for plugs that had auto-off pending but
  366. never completed (e.g., due to service restart).
  367. """
  368. try:
  369. from backend.app.core.database import async_session
  370. from backend.app.models.smart_plug import SmartPlug
  371. async with async_session() as db:
  372. # Find all plugs with pending auto-off
  373. result = await db.execute(
  374. select(SmartPlug).where(
  375. SmartPlug.auto_off_pending.is_(True),
  376. SmartPlug.printer_id.isnot(None),
  377. )
  378. )
  379. pending_plugs = result.scalars().all()
  380. for plug in pending_plugs:
  381. # Check how long it's been pending (timeout after 2 hours)
  382. if plug.auto_off_pending_since:
  383. elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
  384. if elapsed > 7200: # 2 hours
  385. logger.warning(
  386. f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
  387. f"clearing stale pending state"
  388. )
  389. plug.auto_off_pending = False
  390. plug.auto_off_pending_since = None
  391. await db.commit()
  392. continue
  393. logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
  394. # Resume the appropriate off mode
  395. if plug.off_delay_mode == "temperature":
  396. self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
  397. else:
  398. # For time mode, just turn off immediately since delay already passed
  399. logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
  400. service = await self.get_service_for_plug(plug, db)
  401. success = await service.turn_off(plug)
  402. if success:
  403. await self._mark_auto_off_executed(plug.id)
  404. printer_manager.mark_printer_offline(plug.printer_id)
  405. if pending_plugs:
  406. logger.info(f"Resumed {len(pending_plugs)} pending auto-off(s)")
  407. except Exception as e:
  408. logger.warning(f"Failed to resume pending auto-offs: {e}")
  409. # Global singleton
  410. smart_plug_manager = SmartPlugManager()