smart_plugs.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. """API routes for smart plug management."""
  2. import logging
  3. from datetime import datetime, timedelta
  4. from fastapi import APIRouter, Body, Depends, HTTPException
  5. from pydantic import BaseModel
  6. from sqlalchemy import select
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from backend.app.core.database import get_db
  9. from backend.app.models.printer import Printer
  10. from backend.app.models.smart_plug import SmartPlug
  11. from backend.app.schemas.smart_plug import (
  12. SmartPlugControl,
  13. SmartPlugCreate,
  14. SmartPlugEnergy,
  15. SmartPlugResponse,
  16. SmartPlugStatus,
  17. SmartPlugTestConnection,
  18. SmartPlugUpdate,
  19. )
  20. from backend.app.services.discovery import tasmota_scanner
  21. from backend.app.services.notification_service import notification_service
  22. from backend.app.services.printer_manager import printer_manager
  23. from backend.app.services.tasmota import tasmota_service
  24. logger = logging.getLogger(__name__)
  25. router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
  26. @router.get("/", response_model=list[SmartPlugResponse])
  27. async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
  28. """List all smart plugs."""
  29. result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
  30. return list(result.scalars().all())
  31. @router.post("/", response_model=SmartPlugResponse)
  32. async def create_smart_plug(
  33. data: SmartPlugCreate,
  34. db: AsyncSession = Depends(get_db),
  35. ):
  36. """Create a new smart plug."""
  37. # Validate printer_id if provided
  38. if data.printer_id:
  39. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  40. if not result.scalar_one_or_none():
  41. raise HTTPException(400, "Printer not found")
  42. # Check if printer already has a plug assigned
  43. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
  44. if result.scalar_one_or_none():
  45. raise HTTPException(400, "This printer already has a smart plug assigned")
  46. plug = SmartPlug(**data.model_dump())
  47. db.add(plug)
  48. await db.commit()
  49. await db.refresh(plug)
  50. logger.info(f"Created smart plug '{plug.name}' at {plug.ip_address}")
  51. return plug
  52. @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
  53. async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  54. """Get the smart plug assigned to a printer."""
  55. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  56. plug = result.scalar_one_or_none()
  57. if not plug:
  58. return None
  59. return plug
  60. # Tasmota Discovery Endpoints
  61. # NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts
  62. class TasmotaScanRequest(BaseModel):
  63. """Request to scan for Tasmota devices."""
  64. from_ip: str | None = None # Starting IP (auto-detected if not provided)
  65. to_ip: str | None = None # Ending IP (auto-detected if not provided)
  66. timeout: float = 1.0 # Connection timeout per host
  67. def get_local_network_range() -> tuple[str, str]:
  68. """Auto-detect local network and return IP range to scan."""
  69. import socket
  70. try:
  71. # Get local IP by connecting to a public DNS (doesn't actually send data)
  72. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  73. s.connect(("8.8.8.8", 80))
  74. local_ip = s.getsockname()[0]
  75. s.close()
  76. # Parse IP and create range (assume /24 subnet)
  77. parts = local_ip.split(".")
  78. base = ".".join(parts[:3])
  79. from_ip = f"{base}.1"
  80. to_ip = f"{base}.254"
  81. logger.info(f"Auto-detected network: {from_ip} - {to_ip} (local IP: {local_ip})")
  82. return from_ip, to_ip
  83. except Exception as e:
  84. logger.error(f"Failed to detect local network: {e}")
  85. # Fallback to common home network
  86. return "192.168.1.1", "192.168.1.254"
  87. class TasmotaScanStatus(BaseModel):
  88. """Tasmota scan status response."""
  89. running: bool
  90. scanned: int
  91. total: int
  92. class DiscoveredTasmotaDevice(BaseModel):
  93. """Discovered Tasmota device."""
  94. ip_address: str
  95. name: str
  96. module: int | None = None
  97. state: str | None = None
  98. discovered_at: str | None = None
  99. @router.post("/discover/scan", response_model=TasmotaScanStatus)
  100. async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
  101. """Start an IP range scan for Tasmota devices.
  102. Auto-detects local network if no IP range provided.
  103. """
  104. import asyncio
  105. # Auto-detect network
  106. from_ip, to_ip = get_local_network_range()
  107. timeout = request.timeout if request else 1.0
  108. # Start scan in background
  109. asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
  110. # Return immediate status
  111. scanned, total = tasmota_scanner.progress
  112. return TasmotaScanStatus(
  113. running=tasmota_scanner.is_running,
  114. scanned=scanned,
  115. total=total,
  116. )
  117. @router.get("/discover/status", response_model=TasmotaScanStatus)
  118. async def get_tasmota_scan_status():
  119. """Get the current Tasmota scan status."""
  120. scanned, total = tasmota_scanner.progress
  121. return TasmotaScanStatus(
  122. running=tasmota_scanner.is_running,
  123. scanned=scanned,
  124. total=total,
  125. )
  126. @router.post("/discover/stop", response_model=TasmotaScanStatus)
  127. async def stop_tasmota_scan():
  128. """Stop the current Tasmota scan."""
  129. tasmota_scanner.stop()
  130. scanned, total = tasmota_scanner.progress
  131. return TasmotaScanStatus(
  132. running=tasmota_scanner.is_running,
  133. scanned=scanned,
  134. total=total,
  135. )
  136. @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
  137. async def get_discovered_tasmota_devices():
  138. """Get list of discovered Tasmota devices."""
  139. return [
  140. DiscoveredTasmotaDevice(
  141. ip_address=d["ip_address"],
  142. name=d["name"],
  143. module=d.get("module"),
  144. state=d.get("state"),
  145. discovered_at=d.get("discovered_at"),
  146. )
  147. for d in tasmota_scanner.discovered_devices
  148. ]
  149. @router.get("/{plug_id}", response_model=SmartPlugResponse)
  150. async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
  151. """Get a specific smart plug."""
  152. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  153. plug = result.scalar_one_or_none()
  154. if not plug:
  155. raise HTTPException(404, "Smart plug not found")
  156. return plug
  157. @router.patch("/{plug_id}", response_model=SmartPlugResponse)
  158. async def update_smart_plug(
  159. plug_id: int,
  160. data: SmartPlugUpdate,
  161. db: AsyncSession = Depends(get_db),
  162. ):
  163. """Update a smart plug."""
  164. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  165. plug = result.scalar_one_or_none()
  166. if not plug:
  167. raise HTTPException(404, "Smart plug not found")
  168. update_data = data.model_dump(exclude_unset=True)
  169. # Validate new printer_id if being changed
  170. if "printer_id" in update_data and update_data["printer_id"]:
  171. new_printer_id = update_data["printer_id"]
  172. # Check printer exists
  173. result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
  174. if not result.scalar_one_or_none():
  175. raise HTTPException(400, "Printer not found")
  176. # Check if that printer already has a different plug assigned
  177. result = await db.execute(
  178. select(SmartPlug).where(
  179. SmartPlug.printer_id == new_printer_id,
  180. SmartPlug.id != plug_id,
  181. )
  182. )
  183. if result.scalar_one_or_none():
  184. raise HTTPException(400, "This printer already has a smart plug assigned")
  185. for field, value in update_data.items():
  186. setattr(plug, field, value)
  187. await db.commit()
  188. await db.refresh(plug)
  189. logger.info(f"Updated smart plug '{plug.name}'")
  190. return plug
  191. @router.delete("/{plug_id}")
  192. async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
  193. """Delete a smart plug."""
  194. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  195. plug = result.scalar_one_or_none()
  196. if not plug:
  197. raise HTTPException(404, "Smart plug not found")
  198. plug_name = plug.name
  199. await db.delete(plug)
  200. await db.commit()
  201. logger.info(f"Deleted smart plug '{plug_name}'")
  202. return {"message": "Smart plug deleted"}
  203. @router.post("/{plug_id}/control")
  204. async def control_smart_plug(
  205. plug_id: int,
  206. control: SmartPlugControl,
  207. db: AsyncSession = Depends(get_db),
  208. ):
  209. """Manual control: on/off/toggle."""
  210. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  211. plug = result.scalar_one_or_none()
  212. if not plug:
  213. raise HTTPException(404, "Smart plug not found")
  214. if control.action == "on":
  215. success = await tasmota_service.turn_on(plug)
  216. expected_state = "ON"
  217. elif control.action == "off":
  218. success = await tasmota_service.turn_off(plug)
  219. expected_state = "OFF"
  220. elif control.action == "toggle":
  221. success = await tasmota_service.toggle(plug)
  222. expected_state = None # Unknown after toggle
  223. else:
  224. raise HTTPException(400, f"Invalid action: {control.action}")
  225. if not success:
  226. raise HTTPException(503, "Failed to communicate with device")
  227. # Update last state and reset auto_off_executed when turning on
  228. if expected_state:
  229. plug.last_state = expected_state
  230. if expected_state == "ON":
  231. plug.auto_off_executed = False # Reset flag when manually turning on
  232. elif expected_state == "OFF" and plug.printer_id:
  233. # Mark printer offline immediately for faster UI update
  234. printer_manager.mark_printer_offline(plug.printer_id)
  235. plug.last_checked = datetime.utcnow()
  236. await db.commit()
  237. return {"success": True, "action": control.action}
  238. @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
  239. async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
  240. """Get current plug status from device including energy data."""
  241. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  242. plug = result.scalar_one_or_none()
  243. if not plug:
  244. raise HTTPException(404, "Smart plug not found")
  245. status = await tasmota_service.get_status(plug)
  246. # Update last state in database
  247. if status["reachable"]:
  248. plug.last_state = status["state"]
  249. plug.last_checked = datetime.utcnow()
  250. await db.commit()
  251. # Fetch energy data if device is reachable
  252. energy_data = None
  253. if status["reachable"]:
  254. energy = await tasmota_service.get_energy(plug)
  255. if energy:
  256. energy_data = SmartPlugEnergy(**energy)
  257. # Check power alerts
  258. await check_power_alerts(plug, energy.get("power"), db)
  259. return SmartPlugStatus(
  260. state=status["state"],
  261. reachable=status["reachable"],
  262. device_name=status.get("device_name"),
  263. energy=energy_data,
  264. )
  265. async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
  266. """Check if power crosses alert thresholds and send notifications."""
  267. if not plug.power_alert_enabled or current_power is None:
  268. return
  269. # Cooldown: don't alert more than once per 5 minutes
  270. cooldown_minutes = 5
  271. if plug.power_alert_last_triggered:
  272. time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
  273. if time_since_last < timedelta(minutes=cooldown_minutes):
  274. return
  275. alert_triggered = False
  276. alert_type = None
  277. threshold = None
  278. # Check high threshold
  279. if plug.power_alert_high is not None and current_power > plug.power_alert_high:
  280. alert_triggered = True
  281. alert_type = "high"
  282. threshold = plug.power_alert_high
  283. # Check low threshold
  284. if plug.power_alert_low is not None and current_power < plug.power_alert_low:
  285. alert_triggered = True
  286. alert_type = "low"
  287. threshold = plug.power_alert_low
  288. if alert_triggered:
  289. plug.power_alert_last_triggered = datetime.utcnow()
  290. await db.commit()
  291. # Send notification
  292. title = f"Power Alert: {plug.name}"
  293. if alert_type == "high":
  294. message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
  295. else:
  296. message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
  297. logger.info(f"Power alert triggered for {plug.name}: {message}")
  298. # Use printer_error event type for power alerts (closest match)
  299. await notification_service.send_notification(
  300. event_type="printer_error",
  301. title=title,
  302. message=message,
  303. printer_id=plug.printer_id,
  304. printer_name=plug.name,
  305. context={
  306. "error_type": f"Power {alert_type.title()}",
  307. "error_detail": message,
  308. },
  309. )
  310. @router.post("/test-connection")
  311. async def test_connection(data: SmartPlugTestConnection):
  312. """Test connection to a Tasmota device."""
  313. result = await tasmota_service.test_connection(
  314. data.ip_address,
  315. data.username,
  316. data.password,
  317. )
  318. if not result["success"]:
  319. raise HTTPException(503, result.get("error", "Failed to connect to device"))
  320. return {
  321. "success": True,
  322. "state": result["state"],
  323. "device_name": result.get("device_name"),
  324. }