smart_plug_manager.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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. # Only auto-off on successful completion, not on failures
  158. # This allows the user to investigate errors before power-off
  159. if status != "completed":
  160. logger.info(
  161. f"Print on printer {printer_id} ended with status '{status}', "
  162. f"skipping auto-off for plug '{plug.name}' to allow investigation"
  163. )
  164. return
  165. logger.info(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
  166. if plug.off_delay_mode == "time":
  167. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  168. elif plug.off_delay_mode == "temperature":
  169. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  170. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  171. """Schedule turn-off after delay."""
  172. # Cancel any existing task for this plug
  173. self._cancel_pending_off(plug.id)
  174. logger.info(f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds")
  175. # Mark as pending in database (survives restarts)
  176. asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
  177. task = asyncio.create_task(
  178. self._delayed_off(
  179. plug.id,
  180. plug.plug_type,
  181. plug.ip_address,
  182. plug.ha_entity_id,
  183. plug.username,
  184. plug.password,
  185. printer_id,
  186. delay_seconds,
  187. )
  188. )
  189. self._pending_off[plug.id] = task
  190. async def _delayed_off(
  191. self,
  192. plug_id: int,
  193. plug_type: str,
  194. ip_address: str | None,
  195. ha_entity_id: str | None,
  196. username: str | None,
  197. password: str | None,
  198. printer_id: int,
  199. delay_seconds: int,
  200. ):
  201. """Wait and turn off."""
  202. try:
  203. await asyncio.sleep(delay_seconds)
  204. # Create a minimal plug-like object for the service
  205. class PlugInfo:
  206. def __init__(self):
  207. self.plug_type = plug_type
  208. self.ip_address = ip_address
  209. self.ha_entity_id = ha_entity_id
  210. self.username = username
  211. self.password = password
  212. self.name = f"plug_{plug_id}"
  213. plug_info = PlugInfo()
  214. service = await self._get_service_for_plug(plug_info)
  215. success = await service.turn_off(plug_info)
  216. logger.info(f"Turned off plug {plug_id} after time delay")
  217. # Mark auto_off_executed in database and update printer status
  218. if success:
  219. await self._mark_auto_off_executed(plug_id)
  220. # Mark the printer as offline immediately
  221. printer_manager.mark_printer_offline(printer_id)
  222. except asyncio.CancelledError:
  223. logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
  224. finally:
  225. self._pending_off.pop(plug_id, None)
  226. def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
  227. """Monitor temperature and turn off when below threshold."""
  228. # Cancel any existing task for this plug
  229. self._cancel_pending_off(plug.id)
  230. logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
  231. # Mark as pending in database (survives restarts)
  232. asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
  233. task = asyncio.create_task(
  234. self._temp_based_off(
  235. plug.id,
  236. plug.plug_type,
  237. plug.ip_address,
  238. plug.ha_entity_id,
  239. plug.username,
  240. plug.password,
  241. printer_id,
  242. temp_threshold,
  243. )
  244. )
  245. self._pending_off[plug.id] = task
  246. async def _temp_based_off(
  247. self,
  248. plug_id: int,
  249. plug_type: str,
  250. ip_address: str | None,
  251. ha_entity_id: str | None,
  252. username: str | None,
  253. password: str | None,
  254. printer_id: int,
  255. temp_threshold: int,
  256. ):
  257. """Poll temperature until below threshold, then turn off.
  258. For dual-extruder printers (H2 series), checks both nozzles.
  259. """
  260. try:
  261. check_interval = 10 # seconds
  262. max_wait = 3600 # 1 hour max
  263. elapsed = 0
  264. while elapsed < max_wait:
  265. status = printer_manager.get_status(printer_id)
  266. if status:
  267. temps = status.temperatures or {}
  268. nozzle_temp = temps.get("nozzle", 999)
  269. # Check second nozzle for dual-extruder printers (H2 series)
  270. nozzle_2_temp = temps.get("nozzle_2")
  271. # Get the maximum temperature across all nozzles
  272. max_nozzle_temp = nozzle_temp
  273. if nozzle_2_temp is not None:
  274. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  275. logger.info(
  276. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  277. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  278. f"threshold={temp_threshold}°C"
  279. )
  280. else:
  281. logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
  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(f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s")
  309. except asyncio.CancelledError:
  310. logger.debug(f"Temperature-based turn-off cancelled for plug {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.utcnow() if pending else None
  324. await db.commit()
  325. logger.debug(f"Marked plug {plug_id} auto_off_pending={pending}")
  326. except Exception as e:
  327. logger.warning(f"Failed to update plug {plug_id} pending state: {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.utcnow()
  343. await db.commit()
  344. logger.info(f"Auto-off executed and disabled for plug {plug_id}")
  345. except Exception as e:
  346. logger.warning(f"Failed to update plug {plug_id} after auto-off: {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(f"Cancelling pending turn-off for plug {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. elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
  380. if elapsed > 7200: # 2 hours
  381. logger.warning(
  382. f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
  383. f"clearing stale pending state"
  384. )
  385. plug.auto_off_pending = False
  386. plug.auto_off_pending_since = None
  387. await db.commit()
  388. continue
  389. logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
  390. # Resume the appropriate off mode
  391. if plug.off_delay_mode == "temperature":
  392. self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
  393. else:
  394. # For time mode, just turn off immediately since delay already passed
  395. logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
  396. service = await self._get_service_for_plug(plug, db)
  397. success = await service.turn_off(plug)
  398. if success:
  399. await self._mark_auto_off_executed(plug.id)
  400. printer_manager.mark_printer_offline(plug.printer_id)
  401. if pending_plugs:
  402. logger.info(f"Resumed {len(pending_plugs)} pending auto-off(s)")
  403. except Exception as e:
  404. logger.warning(f"Failed to resume pending auto-offs: {e}")
  405. # Global singleton
  406. smart_plug_manager = SmartPlugManager()