smart_plugs.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  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.api.routes.settings import get_setting
  9. from backend.app.core.database import get_db
  10. from backend.app.models.printer import Printer
  11. from backend.app.models.smart_plug import SmartPlug
  12. from backend.app.schemas.smart_plug import (
  13. HAEntity,
  14. HASensorEntity,
  15. HATestConnectionRequest,
  16. HATestConnectionResponse,
  17. SmartPlugControl,
  18. SmartPlugCreate,
  19. SmartPlugEnergy,
  20. SmartPlugResponse,
  21. SmartPlugStatus,
  22. SmartPlugTestConnection,
  23. SmartPlugUpdate,
  24. )
  25. from backend.app.services.discovery import tasmota_scanner
  26. from backend.app.services.homeassistant import homeassistant_service
  27. from backend.app.services.notification_service import notification_service
  28. from backend.app.services.printer_manager import printer_manager
  29. from backend.app.services.tasmota import tasmota_service
  30. logger = logging.getLogger(__name__)
  31. router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
  32. @router.get("/", response_model=list[SmartPlugResponse])
  33. async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
  34. """List all smart plugs."""
  35. result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
  36. return list(result.scalars().all())
  37. @router.post("/", response_model=SmartPlugResponse)
  38. async def create_smart_plug(
  39. data: SmartPlugCreate,
  40. db: AsyncSession = Depends(get_db),
  41. ):
  42. """Create a new smart plug."""
  43. # Validate printer_id if provided
  44. if data.printer_id:
  45. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  46. if not result.scalar_one_or_none():
  47. raise HTTPException(400, "Printer not found")
  48. # Check if printer already has a plug assigned
  49. # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
  50. is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
  51. if not is_script:
  52. # For non-script plugs, check there's no other non-script plug assigned
  53. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
  54. existing = result.scalar_one_or_none()
  55. if existing:
  56. # Allow if existing plug is a script
  57. existing_is_script = (
  58. existing.plug_type == "homeassistant"
  59. and existing.ha_entity_id
  60. and existing.ha_entity_id.startswith("script.")
  61. )
  62. if not existing_is_script:
  63. raise HTTPException(400, "This printer already has a smart plug assigned")
  64. plug = SmartPlug(**data.model_dump())
  65. db.add(plug)
  66. await db.commit()
  67. await db.refresh(plug)
  68. if plug.plug_type == "homeassistant":
  69. logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
  70. else:
  71. logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
  72. return plug
  73. @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
  74. async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  75. """Get the main smart plug assigned to a printer.
  76. When multiple plugs are assigned (e.g., a regular plug + script),
  77. returns the main (non-script) plug for power control.
  78. """
  79. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  80. plugs = result.scalars().all()
  81. if not plugs:
  82. return None
  83. # If multiple plugs, prefer the non-script one (main power plug)
  84. for plug in plugs:
  85. is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
  86. if not is_script:
  87. return plug
  88. # All are scripts, return the first one
  89. return plugs[0]
  90. @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
  91. async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  92. """Get all HA script plugs assigned to a printer.
  93. Returns only script entities (script.*) for the printer that have
  94. show_on_printer_card enabled.
  95. Used to display "Run Script" buttons alongside the main power plug.
  96. """
  97. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  98. plugs = result.scalars().all()
  99. # Filter to only scripts with show_on_printer_card enabled
  100. scripts = [
  101. plug
  102. for plug in plugs
  103. if plug.plug_type == "homeassistant"
  104. and plug.ha_entity_id
  105. and plug.ha_entity_id.startswith("script.")
  106. and plug.show_on_printer_card
  107. ]
  108. return scripts
  109. # Tasmota Discovery Endpoints
  110. # NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts
  111. class TasmotaScanRequest(BaseModel):
  112. """Request to scan for Tasmota devices."""
  113. from_ip: str | None = None # Starting IP (auto-detected if not provided)
  114. to_ip: str | None = None # Ending IP (auto-detected if not provided)
  115. timeout: float = 1.0 # Connection timeout per host
  116. def get_local_network_range() -> tuple[str, str]:
  117. """Auto-detect local network and return IP range to scan."""
  118. import socket
  119. try:
  120. # Get local IP by connecting to a public DNS (doesn't actually send data)
  121. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  122. s.connect(("8.8.8.8", 80))
  123. local_ip = s.getsockname()[0]
  124. s.close()
  125. # Parse IP and create range (assume /24 subnet)
  126. parts = local_ip.split(".")
  127. base = ".".join(parts[:3])
  128. from_ip = f"{base}.1"
  129. to_ip = f"{base}.254"
  130. logger.info(f"Auto-detected network: {from_ip} - {to_ip} (local IP: {local_ip})")
  131. return from_ip, to_ip
  132. except Exception as e:
  133. logger.error(f"Failed to detect local network: {e}")
  134. # Fallback to common home network
  135. return "192.168.1.1", "192.168.1.254"
  136. class TasmotaScanStatus(BaseModel):
  137. """Tasmota scan status response."""
  138. running: bool
  139. scanned: int
  140. total: int
  141. class DiscoveredTasmotaDevice(BaseModel):
  142. """Discovered Tasmota device."""
  143. ip_address: str
  144. name: str
  145. module: int | None = None
  146. state: str | None = None
  147. discovered_at: str | None = None
  148. @router.post("/discover/scan", response_model=TasmotaScanStatus)
  149. async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
  150. """Start an IP range scan for Tasmota devices.
  151. Auto-detects local network if no IP range provided.
  152. """
  153. import asyncio
  154. # Auto-detect network
  155. from_ip, to_ip = get_local_network_range()
  156. timeout = request.timeout if request else 1.0
  157. # Start scan in background
  158. asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
  159. # Return immediate status
  160. scanned, total = tasmota_scanner.progress
  161. return TasmotaScanStatus(
  162. running=tasmota_scanner.is_running,
  163. scanned=scanned,
  164. total=total,
  165. )
  166. @router.get("/discover/status", response_model=TasmotaScanStatus)
  167. async def get_tasmota_scan_status():
  168. """Get the current Tasmota scan status."""
  169. scanned, total = tasmota_scanner.progress
  170. return TasmotaScanStatus(
  171. running=tasmota_scanner.is_running,
  172. scanned=scanned,
  173. total=total,
  174. )
  175. @router.post("/discover/stop", response_model=TasmotaScanStatus)
  176. async def stop_tasmota_scan():
  177. """Stop the current Tasmota scan."""
  178. tasmota_scanner.stop()
  179. scanned, total = tasmota_scanner.progress
  180. return TasmotaScanStatus(
  181. running=tasmota_scanner.is_running,
  182. scanned=scanned,
  183. total=total,
  184. )
  185. @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
  186. async def get_discovered_tasmota_devices():
  187. """Get list of discovered Tasmota devices."""
  188. return [
  189. DiscoveredTasmotaDevice(
  190. ip_address=d["ip_address"],
  191. name=d["name"],
  192. module=d.get("module"),
  193. state=d.get("state"),
  194. discovered_at=d.get("discovered_at"),
  195. )
  196. for d in tasmota_scanner.discovered_devices
  197. ]
  198. # Home Assistant Discovery Endpoints
  199. @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
  200. async def test_ha_connection(request: HATestConnectionRequest):
  201. """Test connection to Home Assistant."""
  202. result = await homeassistant_service.test_connection(request.url, request.token)
  203. return HATestConnectionResponse(**result)
  204. @router.get("/ha/entities", response_model=list[HAEntity])
  205. async def list_ha_entities(
  206. db: AsyncSession = Depends(get_db),
  207. search: str | None = None,
  208. ):
  209. """List available Home Assistant entities.
  210. By default, returns switch/light/input_boolean entities.
  211. When search is provided, searches ALL entities by entity_id or friendly_name.
  212. Requires HA connection settings to be configured in Settings.
  213. """
  214. ha_url = await get_setting(db, "ha_url") or ""
  215. ha_token = await get_setting(db, "ha_token") or ""
  216. if not ha_url or not ha_token:
  217. raise HTTPException(
  218. 400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  219. )
  220. entities = await homeassistant_service.list_entities(ha_url, ha_token, search)
  221. return [HAEntity(**e) for e in entities]
  222. @router.get("/ha/sensors", response_model=list[HASensorEntity])
  223. async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
  224. """List available Home Assistant sensor entities for energy monitoring.
  225. Returns sensors with power/energy units (W, kW, kWh, Wh).
  226. Requires HA connection settings to be configured in Settings.
  227. """
  228. ha_url = await get_setting(db, "ha_url") or ""
  229. ha_token = await get_setting(db, "ha_token") or ""
  230. if not ha_url or not ha_token:
  231. raise HTTPException(
  232. 400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  233. )
  234. sensors = await homeassistant_service.list_sensor_entities(ha_url, ha_token)
  235. return [HASensorEntity(**s) for s in sensors]
  236. @router.get("/{plug_id}", response_model=SmartPlugResponse)
  237. async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
  238. """Get a specific smart plug."""
  239. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  240. plug = result.scalar_one_or_none()
  241. if not plug:
  242. raise HTTPException(404, "Smart plug not found")
  243. return plug
  244. @router.patch("/{plug_id}", response_model=SmartPlugResponse)
  245. async def update_smart_plug(
  246. plug_id: int,
  247. data: SmartPlugUpdate,
  248. db: AsyncSession = Depends(get_db),
  249. ):
  250. """Update a smart plug."""
  251. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  252. plug = result.scalar_one_or_none()
  253. if not plug:
  254. raise HTTPException(404, "Smart plug not found")
  255. update_data = data.model_dump(exclude_unset=True)
  256. # Validate new printer_id if being changed
  257. if "printer_id" in update_data and update_data["printer_id"]:
  258. new_printer_id = update_data["printer_id"]
  259. # Check printer exists
  260. result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
  261. if not result.scalar_one_or_none():
  262. raise HTTPException(400, "Printer not found")
  263. # Check if that printer already has a different plug assigned
  264. # Scripts can coexist with other plugs
  265. # Determine if the plug being updated is/will be a script
  266. new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
  267. new_plug_type = update_data.get("plug_type", plug.plug_type)
  268. is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
  269. if not is_script:
  270. result = await db.execute(
  271. select(SmartPlug).where(
  272. SmartPlug.printer_id == new_printer_id,
  273. SmartPlug.id != plug_id,
  274. )
  275. )
  276. existing = result.scalar_one_or_none()
  277. if existing:
  278. # Allow if existing plug is a script
  279. existing_is_script = (
  280. existing.plug_type == "homeassistant"
  281. and existing.ha_entity_id
  282. and existing.ha_entity_id.startswith("script.")
  283. )
  284. if not existing_is_script:
  285. raise HTTPException(400, "This printer already has a smart plug assigned")
  286. for field, value in update_data.items():
  287. setattr(plug, field, value)
  288. await db.commit()
  289. await db.refresh(plug)
  290. logger.info(f"Updated smart plug '{plug.name}'")
  291. return plug
  292. @router.delete("/{plug_id}")
  293. async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
  294. """Delete a smart plug."""
  295. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  296. plug = result.scalar_one_or_none()
  297. if not plug:
  298. raise HTTPException(404, "Smart plug not found")
  299. plug_name = plug.name
  300. await db.delete(plug)
  301. await db.commit()
  302. logger.info(f"Deleted smart plug '{plug_name}'")
  303. return {"message": "Smart plug deleted"}
  304. async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
  305. """Get the appropriate service for the plug type.
  306. For HA plugs, configures the service with current settings from DB.
  307. """
  308. if plug.plug_type == "homeassistant":
  309. # Configure HA service with current settings
  310. ha_url = await get_setting(db, "ha_url") or ""
  311. ha_token = await get_setting(db, "ha_token") or ""
  312. homeassistant_service.configure(ha_url, ha_token)
  313. return homeassistant_service
  314. return tasmota_service
  315. @router.post("/{plug_id}/control")
  316. async def control_smart_plug(
  317. plug_id: int,
  318. control: SmartPlugControl,
  319. db: AsyncSession = Depends(get_db),
  320. ):
  321. """Manual control: on/off/toggle."""
  322. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  323. plug = result.scalar_one_or_none()
  324. if not plug:
  325. raise HTTPException(404, "Smart plug not found")
  326. service = await _get_service_for_plug(plug, db)
  327. if control.action == "on":
  328. success = await service.turn_on(plug)
  329. expected_state = "ON"
  330. elif control.action == "off":
  331. success = await service.turn_off(plug)
  332. expected_state = "OFF"
  333. elif control.action == "toggle":
  334. success = await service.toggle(plug)
  335. expected_state = None # Unknown after toggle
  336. else:
  337. raise HTTPException(400, f"Invalid action: {control.action}")
  338. if not success:
  339. raise HTTPException(503, "Failed to communicate with device")
  340. # Update last state and reset auto_off_executed when turning on
  341. if expected_state:
  342. plug.last_state = expected_state
  343. if expected_state == "ON":
  344. plug.auto_off_executed = False # Reset flag when manually turning on
  345. elif expected_state == "OFF" and plug.printer_id:
  346. # Mark printer offline immediately for faster UI update
  347. printer_manager.mark_printer_offline(plug.printer_id)
  348. plug.last_checked = datetime.utcnow()
  349. await db.commit()
  350. # Trigger associated scripts if this is a main (non-script) plug
  351. is_main_plug = not (
  352. plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
  353. )
  354. if is_main_plug and plug.printer_id and expected_state:
  355. await trigger_associated_scripts(plug.printer_id, expected_state, db)
  356. # MQTT relay - publish smart plug state change
  357. if expected_state:
  358. try:
  359. from backend.app.services.mqtt_relay import mqtt_relay
  360. # Get printer name if linked
  361. printer_name = None
  362. if plug.printer_id:
  363. result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))
  364. printer = result.scalar_one_or_none()
  365. printer_name = printer.name if printer else None
  366. await mqtt_relay.on_smart_plug_state(
  367. plug_id=plug.id,
  368. plug_name=plug.name,
  369. state="on" if expected_state == "ON" else "off",
  370. printer_id=plug.printer_id,
  371. printer_name=printer_name,
  372. )
  373. except Exception:
  374. pass # Don't fail if MQTT fails
  375. return {"success": True, "action": control.action}
  376. async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
  377. """Trigger scripts linked to a printer based on main plug state change.
  378. When the main plug turns ON, triggers scripts with auto_on=True.
  379. When the main plug turns OFF, triggers scripts with auto_off=True.
  380. """
  381. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  382. plugs = result.scalars().all()
  383. # Find scripts that should be triggered
  384. for plug in plugs:
  385. is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
  386. if not is_script:
  387. continue
  388. should_trigger = False
  389. if plug_state == "ON" and plug.auto_on:
  390. should_trigger = True
  391. logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
  392. elif plug_state == "OFF" and plug.auto_off:
  393. should_trigger = True
  394. logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
  395. if should_trigger:
  396. try:
  397. service = await _get_service_for_plug(plug, db)
  398. await service.turn_on(plug) # Scripts are triggered by calling turn_on
  399. except Exception as e:
  400. logger.error(f"Failed to trigger script '{plug.name}': {e}")
  401. @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
  402. async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
  403. """Get current plug status from device including energy data."""
  404. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  405. plug = result.scalar_one_or_none()
  406. if not plug:
  407. raise HTTPException(404, "Smart plug not found")
  408. service = await _get_service_for_plug(plug, db)
  409. status = await service.get_status(plug)
  410. # Update last state in database
  411. if status["reachable"]:
  412. plug.last_state = status["state"]
  413. plug.last_checked = datetime.utcnow()
  414. await db.commit()
  415. # Fetch energy data if device is reachable
  416. energy_data = None
  417. if status["reachable"]:
  418. energy = await service.get_energy(plug)
  419. if energy:
  420. energy_data = SmartPlugEnergy(**energy)
  421. # Check power alerts
  422. await check_power_alerts(plug, energy.get("power"), db)
  423. return SmartPlugStatus(
  424. state=status["state"],
  425. reachable=status["reachable"],
  426. device_name=status.get("device_name"),
  427. energy=energy_data,
  428. )
  429. async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
  430. """Check if power crosses alert thresholds and send notifications."""
  431. if not plug.power_alert_enabled or current_power is None:
  432. return
  433. # Cooldown: don't alert more than once per 5 minutes
  434. cooldown_minutes = 5
  435. if plug.power_alert_last_triggered:
  436. time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
  437. if time_since_last < timedelta(minutes=cooldown_minutes):
  438. return
  439. alert_triggered = False
  440. alert_type = None
  441. threshold = None
  442. # Check high threshold
  443. if plug.power_alert_high is not None and current_power > plug.power_alert_high:
  444. alert_triggered = True
  445. alert_type = "high"
  446. threshold = plug.power_alert_high
  447. # Check low threshold
  448. if plug.power_alert_low is not None and current_power < plug.power_alert_low:
  449. alert_triggered = True
  450. alert_type = "low"
  451. threshold = plug.power_alert_low
  452. if alert_triggered:
  453. plug.power_alert_last_triggered = datetime.utcnow()
  454. await db.commit()
  455. # Send notification
  456. title = f"Power Alert: {plug.name}"
  457. if alert_type == "high":
  458. message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
  459. else:
  460. message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
  461. logger.info(f"Power alert triggered for {plug.name}: {message}")
  462. # Use printer_error event type for power alerts (closest match)
  463. await notification_service.send_notification(
  464. event_type="printer_error",
  465. title=title,
  466. message=message,
  467. printer_id=plug.printer_id,
  468. printer_name=plug.name,
  469. context={
  470. "error_type": f"Power {alert_type.title()}",
  471. "error_detail": message,
  472. },
  473. )
  474. @router.post("/test-connection")
  475. async def test_connection(data: SmartPlugTestConnection):
  476. """Test connection to a Tasmota device."""
  477. result = await tasmota_service.test_connection(
  478. data.ip_address,
  479. data.username,
  480. data.password,
  481. )
  482. if not result["success"]:
  483. raise HTTPException(503, result.get("error", "Failed to connect to device"))
  484. return {
  485. "success": True,
  486. "state": result["state"],
  487. "device_name": result.get("device_name"),
  488. }