smart_plug_manager.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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. def set_event_loop(self, loop: asyncio.AbstractEventLoop):
  19. """Set the event loop for async operations."""
  20. self._loop = loop
  21. async def _get_plug_for_printer(
  22. self, printer_id: int, db: AsyncSession
  23. ) -> "SmartPlug | None":
  24. """Get the smart plug linked to a printer."""
  25. from backend.app.models.smart_plug import SmartPlug
  26. result = await db.execute(
  27. select(SmartPlug).where(SmartPlug.printer_id == printer_id)
  28. )
  29. return result.scalar_one_or_none()
  30. async def on_print_start(self, printer_id: int, db: AsyncSession):
  31. """Called when a print starts - turn on plug if configured."""
  32. plug = await self._get_plug_for_printer(printer_id, db)
  33. if not plug:
  34. return
  35. if not plug.enabled:
  36. logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-on")
  37. return
  38. if not plug.auto_on:
  39. logger.debug(f"Smart plug '{plug.name}' auto_on is disabled")
  40. return
  41. # Cancel any pending off task
  42. self._cancel_pending_off(plug.id)
  43. # Turn on the plug
  44. logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
  45. success = await tasmota_service.turn_on(plug)
  46. if success:
  47. # Update last state and reset auto_off_executed
  48. plug.last_state = "ON"
  49. plug.last_checked = datetime.utcnow()
  50. plug.auto_off_executed = False # Reset flag when turning on
  51. await db.commit()
  52. async def on_print_complete(
  53. self, printer_id: int, status: str, db: AsyncSession
  54. ):
  55. """Called when a print completes - schedule turn off if configured.
  56. Only triggers auto-off on successful completion (status='completed').
  57. Failed prints keep the printer powered on for user investigation.
  58. """
  59. plug = await self._get_plug_for_printer(printer_id, db)
  60. if not plug:
  61. return
  62. if not plug.enabled:
  63. logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-off")
  64. return
  65. if not plug.auto_off:
  66. logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
  67. return
  68. # Only auto-off on successful completion, not on failures
  69. # This allows the user to investigate errors before power-off
  70. if status != "completed":
  71. logger.info(
  72. f"Print on printer {printer_id} ended with status '{status}', "
  73. f"skipping auto-off for plug '{plug.name}' to allow investigation"
  74. )
  75. return
  76. logger.info(
  77. f"Print completed successfully on printer {printer_id}, "
  78. f"scheduling turn-off for plug '{plug.name}'"
  79. )
  80. if plug.off_delay_mode == "time":
  81. self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
  82. elif plug.off_delay_mode == "temperature":
  83. self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
  84. def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
  85. """Schedule turn-off after delay."""
  86. # Cancel any existing task for this plug
  87. self._cancel_pending_off(plug.id)
  88. logger.info(
  89. f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
  90. )
  91. task = asyncio.create_task(
  92. self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, printer_id, delay_seconds)
  93. )
  94. self._pending_off[plug.id] = task
  95. async def _delayed_off(
  96. self,
  97. plug_id: int,
  98. ip_address: str,
  99. username: str | None,
  100. password: str | None,
  101. printer_id: int,
  102. delay_seconds: int,
  103. ):
  104. """Wait and turn off."""
  105. try:
  106. await asyncio.sleep(delay_seconds)
  107. # Create a minimal plug-like object for the tasmota service
  108. class PlugInfo:
  109. def __init__(self):
  110. self.ip_address = ip_address
  111. self.username = username
  112. self.password = password
  113. self.name = f"plug_{plug_id}"
  114. plug_info = PlugInfo()
  115. success = await tasmota_service.turn_off(plug_info)
  116. logger.info(f"Turned off plug {plug_id} after time delay")
  117. # Mark auto_off_executed in database and update printer status
  118. if success:
  119. await self._mark_auto_off_executed(plug_id)
  120. # Mark the printer as offline immediately
  121. printer_manager.mark_printer_offline(printer_id)
  122. except asyncio.CancelledError:
  123. logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
  124. finally:
  125. self._pending_off.pop(plug_id, None)
  126. def _schedule_temp_based_off(
  127. self, plug: "SmartPlug", printer_id: int, temp_threshold: int
  128. ):
  129. """Monitor temperature and turn off when below threshold."""
  130. # Cancel any existing task for this plug
  131. self._cancel_pending_off(plug.id)
  132. logger.info(
  133. f"Scheduling temperature-based turn-off for plug '{plug.name}' "
  134. f"(threshold: {temp_threshold}°C)"
  135. )
  136. task = asyncio.create_task(
  137. self._temp_based_off(
  138. plug.id,
  139. plug.ip_address,
  140. plug.username,
  141. plug.password,
  142. printer_id,
  143. temp_threshold,
  144. )
  145. )
  146. self._pending_off[plug.id] = task
  147. async def _temp_based_off(
  148. self,
  149. plug_id: int,
  150. ip_address: str,
  151. username: str | None,
  152. password: str | None,
  153. printer_id: int,
  154. temp_threshold: int,
  155. ):
  156. """Poll temperature until below threshold, then turn off.
  157. For dual-extruder printers (H2 series), checks both nozzles.
  158. """
  159. try:
  160. check_interval = 10 # seconds
  161. max_wait = 3600 # 1 hour max
  162. elapsed = 0
  163. while elapsed < max_wait:
  164. status = printer_manager.get_status(printer_id)
  165. if status:
  166. temps = status.temperatures or {}
  167. nozzle_temp = temps.get("nozzle", 999)
  168. # Check second nozzle for dual-extruder printers (H2 series)
  169. nozzle_2_temp = temps.get("nozzle_2")
  170. # Get the maximum temperature across all nozzles
  171. max_nozzle_temp = nozzle_temp
  172. if nozzle_2_temp is not None:
  173. max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
  174. logger.info(
  175. f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
  176. f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
  177. f"threshold={temp_threshold}°C"
  178. )
  179. else:
  180. logger.info(
  181. f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, "
  182. f"threshold={temp_threshold}°C"
  183. )
  184. if max_nozzle_temp < temp_threshold:
  185. # All nozzles are below threshold, turn off
  186. class PlugInfo:
  187. def __init__(self):
  188. self.ip_address = ip_address
  189. self.username = username
  190. self.password = password
  191. self.name = f"plug_{plug_id}"
  192. plug_info = PlugInfo()
  193. success = await tasmota_service.turn_off(plug_info)
  194. logger.info(
  195. f"Turned off plug {plug_id} after nozzle temp dropped to "
  196. f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
  197. )
  198. # Mark auto_off_executed in database and update printer status
  199. if success:
  200. await self._mark_auto_off_executed(plug_id)
  201. # Mark the printer as offline immediately
  202. printer_manager.mark_printer_offline(printer_id)
  203. break
  204. await asyncio.sleep(check_interval)
  205. elapsed += check_interval
  206. if elapsed >= max_wait:
  207. logger.warning(
  208. f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s"
  209. )
  210. except asyncio.CancelledError:
  211. logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
  212. finally:
  213. self._pending_off.pop(plug_id, None)
  214. async def _mark_auto_off_executed(self, plug_id: int):
  215. """Disable auto-off after it was executed (one-shot behavior)."""
  216. try:
  217. from backend.app.core.database import async_session
  218. from backend.app.models.smart_plug import SmartPlug
  219. async with async_session() as db:
  220. result = await db.execute(
  221. select(SmartPlug).where(SmartPlug.id == plug_id)
  222. )
  223. plug = result.scalar_one_or_none()
  224. if plug:
  225. plug.auto_off = False # Disable auto-off (one-shot behavior)
  226. plug.auto_off_executed = False # Reset the flag
  227. plug.last_state = "OFF"
  228. plug.last_checked = datetime.utcnow()
  229. await db.commit()
  230. logger.info(f"Auto-off executed and disabled for plug {plug_id}")
  231. except Exception as e:
  232. logger.warning(f"Failed to update plug {plug_id} after auto-off: {e}")
  233. def _cancel_pending_off(self, plug_id: int):
  234. """Cancel any pending off task for this plug."""
  235. if plug_id in self._pending_off:
  236. logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
  237. self._pending_off[plug_id].cancel()
  238. del self._pending_off[plug_id]
  239. def cancel_all_pending(self):
  240. """Cancel all pending turn-off tasks."""
  241. for plug_id in list(self._pending_off.keys()):
  242. self._cancel_pending_off(plug_id)
  243. # Global singleton
  244. smart_plug_manager = SmartPlugManager()