smart_plug_manager.py 18 KB

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