smart_plugs.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  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.mqtt_relay import mqtt_relay
  28. from backend.app.services.notification_service import notification_service
  29. from backend.app.services.printer_manager import printer_manager
  30. from backend.app.services.tasmota import tasmota_service
  31. logger = logging.getLogger(__name__)
  32. router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
  33. @router.get("/", response_model=list[SmartPlugResponse])
  34. async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
  35. """List all smart plugs."""
  36. result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
  37. return list(result.scalars().all())
  38. @router.post("/", response_model=SmartPlugResponse)
  39. async def create_smart_plug(
  40. data: SmartPlugCreate,
  41. db: AsyncSession = Depends(get_db),
  42. ):
  43. """Create a new smart plug."""
  44. # Validate printer_id if provided
  45. if data.printer_id:
  46. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  47. if not result.scalar_one_or_none():
  48. raise HTTPException(400, "Printer not found")
  49. # Check if printer already has a plug assigned
  50. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
  51. if result.scalar_one_or_none():
  52. raise HTTPException(400, "This printer already has a smart plug assigned")
  53. # For MQTT plugs, ensure MQTT broker is configured and service is connected
  54. if data.plug_type == "mqtt":
  55. # Try to configure the smart plug service if not already configured
  56. if not mqtt_relay.smart_plug_service.is_configured():
  57. # Get MQTT broker settings from database
  58. mqtt_broker = await get_setting(db, "mqtt_broker") or ""
  59. if not mqtt_broker:
  60. raise HTTPException(
  61. 400,
  62. "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.",
  63. )
  64. # Configure the smart plug service with broker settings
  65. mqtt_settings = {
  66. "mqtt_enabled": True, # Enable for smart plug subscription
  67. "mqtt_broker": mqtt_broker,
  68. "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
  69. "mqtt_username": await get_setting(db, "mqtt_username") or "",
  70. "mqtt_password": await get_setting(db, "mqtt_password") or "",
  71. "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
  72. }
  73. await mqtt_relay.smart_plug_service.configure(mqtt_settings)
  74. # Check if connection succeeded
  75. if not mqtt_relay.smart_plug_service.is_configured():
  76. raise HTTPException(
  77. 400,
  78. f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
  79. )
  80. plug = SmartPlug(**data.model_dump())
  81. db.add(plug)
  82. await db.commit()
  83. await db.refresh(plug)
  84. # Subscribe MQTT plugs to their topics
  85. if plug.plug_type == "mqtt":
  86. # Determine effective topics (new fields take priority, fall back to legacy)
  87. power_topic = plug.mqtt_power_topic or plug.mqtt_topic
  88. energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic
  89. state_topic = plug.mqtt_state_topic or plug.mqtt_topic
  90. # Only subscribe if at least one data source is configured
  91. if (
  92. (power_topic and plug.mqtt_power_path)
  93. or (energy_topic and plug.mqtt_energy_path)
  94. or (state_topic and plug.mqtt_state_path)
  95. ):
  96. mqtt_relay.smart_plug_service.subscribe(
  97. plug_id=plug.id,
  98. # Power source
  99. power_topic=power_topic if plug.mqtt_power_path else None,
  100. power_path=plug.mqtt_power_path,
  101. power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
  102. # Energy source
  103. energy_topic=energy_topic if plug.mqtt_energy_path else None,
  104. energy_path=plug.mqtt_energy_path,
  105. energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
  106. # State source
  107. state_topic=state_topic if plug.mqtt_state_path else None,
  108. state_path=plug.mqtt_state_path,
  109. state_on_value=plug.mqtt_state_on_value,
  110. )
  111. topics = [
  112. t
  113. for t in [
  114. power_topic if plug.mqtt_power_path else None,
  115. energy_topic if plug.mqtt_energy_path else None,
  116. state_topic if plug.mqtt_state_path else None,
  117. ]
  118. if t
  119. ]
  120. logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
  121. elif plug.plug_type == "homeassistant":
  122. logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
  123. else:
  124. logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
  125. return plug
  126. @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
  127. async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  128. """Get the smart plug assigned to a printer."""
  129. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  130. plug = result.scalar_one_or_none()
  131. if not plug:
  132. return None
  133. return plug
  134. # Tasmota Discovery Endpoints
  135. # NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts
  136. class TasmotaScanRequest(BaseModel):
  137. """Request to scan for Tasmota devices."""
  138. from_ip: str | None = None # Starting IP (auto-detected if not provided)
  139. to_ip: str | None = None # Ending IP (auto-detected if not provided)
  140. timeout: float = 1.0 # Connection timeout per host
  141. def get_local_network_range() -> tuple[str, str]:
  142. """Auto-detect local network and return IP range to scan."""
  143. import socket
  144. try:
  145. # Get local IP by connecting to a public DNS (doesn't actually send data)
  146. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  147. s.connect(("8.8.8.8", 80))
  148. local_ip = s.getsockname()[0]
  149. s.close()
  150. # Parse IP and create range (assume /24 subnet)
  151. parts = local_ip.split(".")
  152. base = ".".join(parts[:3])
  153. from_ip = f"{base}.1"
  154. to_ip = f"{base}.254"
  155. logger.info(f"Auto-detected network: {from_ip} - {to_ip} (local IP: {local_ip})")
  156. return from_ip, to_ip
  157. except Exception as e:
  158. logger.error(f"Failed to detect local network: {e}")
  159. # Fallback to common home network
  160. return "192.168.1.1", "192.168.1.254"
  161. class TasmotaScanStatus(BaseModel):
  162. """Tasmota scan status response."""
  163. running: bool
  164. scanned: int
  165. total: int
  166. class DiscoveredTasmotaDevice(BaseModel):
  167. """Discovered Tasmota device."""
  168. ip_address: str
  169. name: str
  170. module: int | None = None
  171. state: str | None = None
  172. discovered_at: str | None = None
  173. @router.post("/discover/scan", response_model=TasmotaScanStatus)
  174. async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
  175. """Start an IP range scan for Tasmota devices.
  176. Auto-detects local network if no IP range provided.
  177. """
  178. import asyncio
  179. # Auto-detect network
  180. from_ip, to_ip = get_local_network_range()
  181. timeout = request.timeout if request else 1.0
  182. # Start scan in background
  183. asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
  184. # Return immediate status
  185. scanned, total = tasmota_scanner.progress
  186. return TasmotaScanStatus(
  187. running=tasmota_scanner.is_running,
  188. scanned=scanned,
  189. total=total,
  190. )
  191. @router.get("/discover/status", response_model=TasmotaScanStatus)
  192. async def get_tasmota_scan_status():
  193. """Get the current Tasmota scan status."""
  194. scanned, total = tasmota_scanner.progress
  195. return TasmotaScanStatus(
  196. running=tasmota_scanner.is_running,
  197. scanned=scanned,
  198. total=total,
  199. )
  200. @router.post("/discover/stop", response_model=TasmotaScanStatus)
  201. async def stop_tasmota_scan():
  202. """Stop the current Tasmota scan."""
  203. tasmota_scanner.stop()
  204. scanned, total = tasmota_scanner.progress
  205. return TasmotaScanStatus(
  206. running=tasmota_scanner.is_running,
  207. scanned=scanned,
  208. total=total,
  209. )
  210. @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
  211. async def get_discovered_tasmota_devices():
  212. """Get list of discovered Tasmota devices."""
  213. return [
  214. DiscoveredTasmotaDevice(
  215. ip_address=d["ip_address"],
  216. name=d["name"],
  217. module=d.get("module"),
  218. state=d.get("state"),
  219. discovered_at=d.get("discovered_at"),
  220. )
  221. for d in tasmota_scanner.discovered_devices
  222. ]
  223. # Home Assistant Discovery Endpoints
  224. @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
  225. async def test_ha_connection(request: HATestConnectionRequest):
  226. """Test connection to Home Assistant."""
  227. result = await homeassistant_service.test_connection(request.url, request.token)
  228. return HATestConnectionResponse(**result)
  229. @router.get("/ha/entities", response_model=list[HAEntity])
  230. async def list_ha_entities(
  231. db: AsyncSession = Depends(get_db),
  232. search: str | None = None,
  233. ):
  234. """List available Home Assistant entities.
  235. By default, returns switch/light/input_boolean entities.
  236. When search is provided, searches ALL entities by entity_id or friendly_name.
  237. Requires HA connection settings to be configured in Settings.
  238. """
  239. ha_url = await get_setting(db, "ha_url") or ""
  240. ha_token = await get_setting(db, "ha_token") or ""
  241. if not ha_url or not ha_token:
  242. raise HTTPException(
  243. 400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  244. )
  245. entities = await homeassistant_service.list_entities(ha_url, ha_token, search)
  246. return [HAEntity(**e) for e in entities]
  247. @router.get("/ha/sensors", response_model=list[HASensorEntity])
  248. async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
  249. """List available Home Assistant sensor entities for energy monitoring.
  250. Returns sensors with power/energy units (W, kW, kWh, Wh).
  251. Requires HA connection settings to be configured in Settings.
  252. """
  253. ha_url = await get_setting(db, "ha_url") or ""
  254. ha_token = await get_setting(db, "ha_token") or ""
  255. if not ha_url or not ha_token:
  256. raise HTTPException(
  257. 400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  258. )
  259. sensors = await homeassistant_service.list_sensor_entities(ha_url, ha_token)
  260. return [HASensorEntity(**s) for s in sensors]
  261. @router.get("/{plug_id}", response_model=SmartPlugResponse)
  262. async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
  263. """Get a specific smart plug."""
  264. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  265. plug = result.scalar_one_or_none()
  266. if not plug:
  267. raise HTTPException(404, "Smart plug not found")
  268. return plug
  269. @router.patch("/{plug_id}", response_model=SmartPlugResponse)
  270. async def update_smart_plug(
  271. plug_id: int,
  272. data: SmartPlugUpdate,
  273. db: AsyncSession = Depends(get_db),
  274. ):
  275. """Update a smart plug."""
  276. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  277. plug = result.scalar_one_or_none()
  278. if not plug:
  279. raise HTTPException(404, "Smart plug not found")
  280. update_data = data.model_dump(exclude_unset=True)
  281. # Validate new printer_id if being changed
  282. if "printer_id" in update_data and update_data["printer_id"]:
  283. new_printer_id = update_data["printer_id"]
  284. # Check printer exists
  285. result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
  286. if not result.scalar_one_or_none():
  287. raise HTTPException(400, "Printer not found")
  288. # Check if that printer already has a different plug assigned
  289. result = await db.execute(
  290. select(SmartPlug).where(
  291. SmartPlug.printer_id == new_printer_id,
  292. SmartPlug.id != plug_id,
  293. )
  294. )
  295. if result.scalar_one_or_none():
  296. raise HTTPException(400, "This printer already has a smart plug assigned")
  297. # Track old MQTT settings for comparison
  298. old_plug_type = plug.plug_type
  299. old_mqtt_config = {
  300. "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
  301. "power_path": plug.mqtt_power_path,
  302. "power_multiplier": plug.mqtt_power_multiplier,
  303. "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
  304. "energy_path": plug.mqtt_energy_path,
  305. "energy_multiplier": plug.mqtt_energy_multiplier,
  306. "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
  307. "state_path": plug.mqtt_state_path,
  308. "state_on_value": plug.mqtt_state_on_value,
  309. }
  310. for field, value in update_data.items():
  311. setattr(plug, field, value)
  312. await db.commit()
  313. await db.refresh(plug)
  314. # Handle MQTT subscription changes
  315. if old_plug_type == "mqtt" and plug.plug_type != "mqtt":
  316. # Changed away from MQTT - unsubscribe
  317. mqtt_relay.smart_plug_service.unsubscribe(plug.id)
  318. elif plug.plug_type == "mqtt":
  319. # Check if any MQTT config changed
  320. new_mqtt_config = {
  321. "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
  322. "power_path": plug.mqtt_power_path,
  323. "power_multiplier": plug.mqtt_power_multiplier,
  324. "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
  325. "energy_path": plug.mqtt_energy_path,
  326. "energy_multiplier": plug.mqtt_energy_multiplier,
  327. "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
  328. "state_path": plug.mqtt_state_path,
  329. "state_on_value": plug.mqtt_state_on_value,
  330. }
  331. mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config
  332. if mqtt_changed:
  333. # Unsubscribe from old topics first
  334. if old_plug_type == "mqtt":
  335. mqtt_relay.smart_plug_service.unsubscribe(plug.id)
  336. # Subscribe to new topics
  337. power_topic = plug.mqtt_power_topic or plug.mqtt_topic
  338. energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic
  339. state_topic = plug.mqtt_state_topic or plug.mqtt_topic
  340. if (
  341. (power_topic and plug.mqtt_power_path)
  342. or (energy_topic and plug.mqtt_energy_path)
  343. or (state_topic and plug.mqtt_state_path)
  344. ):
  345. mqtt_relay.smart_plug_service.subscribe(
  346. plug_id=plug.id,
  347. # Power source
  348. power_topic=power_topic if plug.mqtt_power_path else None,
  349. power_path=plug.mqtt_power_path,
  350. power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
  351. # Energy source
  352. energy_topic=energy_topic if plug.mqtt_energy_path else None,
  353. energy_path=plug.mqtt_energy_path,
  354. energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
  355. # State source
  356. state_topic=state_topic if plug.mqtt_state_path else None,
  357. state_path=plug.mqtt_state_path,
  358. state_on_value=plug.mqtt_state_on_value,
  359. )
  360. logger.info(f"Updated smart plug '{plug.name}'")
  361. return plug
  362. @router.delete("/{plug_id}")
  363. async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
  364. """Delete a smart plug."""
  365. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  366. plug = result.scalar_one_or_none()
  367. if not plug:
  368. raise HTTPException(404, "Smart plug not found")
  369. plug_name = plug.name
  370. plug_type = plug.plug_type
  371. # Unsubscribe MQTT plug before deletion
  372. if plug_type == "mqtt":
  373. mqtt_relay.smart_plug_service.unsubscribe(plug_id)
  374. await db.delete(plug)
  375. await db.commit()
  376. logger.info(f"Deleted smart plug '{plug_name}'")
  377. return {"message": "Smart plug deleted"}
  378. async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
  379. """Get the appropriate service for the plug type.
  380. For HA plugs, configures the service with current settings from DB.
  381. """
  382. if plug.plug_type == "homeassistant":
  383. # Configure HA service with current settings
  384. ha_url = await get_setting(db, "ha_url") or ""
  385. ha_token = await get_setting(db, "ha_token") or ""
  386. homeassistant_service.configure(ha_url, ha_token)
  387. return homeassistant_service
  388. return tasmota_service
  389. @router.post("/{plug_id}/control")
  390. async def control_smart_plug(
  391. plug_id: int,
  392. control: SmartPlugControl,
  393. db: AsyncSession = Depends(get_db),
  394. ):
  395. """Manual control: on/off/toggle."""
  396. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  397. plug = result.scalar_one_or_none()
  398. if not plug:
  399. raise HTTPException(404, "Smart plug not found")
  400. # MQTT plugs are monitor-only - cannot control them
  401. if plug.plug_type == "mqtt":
  402. raise HTTPException(
  403. 400,
  404. "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.",
  405. )
  406. service = await _get_service_for_plug(plug, db)
  407. if control.action == "on":
  408. success = await service.turn_on(plug)
  409. expected_state = "ON"
  410. elif control.action == "off":
  411. success = await service.turn_off(plug)
  412. expected_state = "OFF"
  413. elif control.action == "toggle":
  414. success = await service.toggle(plug)
  415. expected_state = None # Unknown after toggle
  416. else:
  417. raise HTTPException(400, f"Invalid action: {control.action}")
  418. if not success:
  419. raise HTTPException(503, "Failed to communicate with device")
  420. # Update last state and reset auto_off_executed when turning on
  421. if expected_state:
  422. plug.last_state = expected_state
  423. if expected_state == "ON":
  424. plug.auto_off_executed = False # Reset flag when manually turning on
  425. elif expected_state == "OFF" and plug.printer_id:
  426. # Mark printer offline immediately for faster UI update
  427. printer_manager.mark_printer_offline(plug.printer_id)
  428. plug.last_checked = datetime.utcnow()
  429. await db.commit()
  430. # MQTT relay - publish smart plug state change
  431. if expected_state:
  432. try:
  433. from backend.app.services.mqtt_relay import mqtt_relay
  434. # Get printer name if linked
  435. printer_name = None
  436. if plug.printer_id:
  437. result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))
  438. printer = result.scalar_one_or_none()
  439. printer_name = printer.name if printer else None
  440. await mqtt_relay.on_smart_plug_state(
  441. plug_id=plug.id,
  442. plug_name=plug.name,
  443. state="on" if expected_state == "ON" else "off",
  444. printer_id=plug.printer_id,
  445. printer_name=printer_name,
  446. )
  447. except Exception:
  448. pass # Don't fail if MQTT fails
  449. return {"success": True, "action": control.action}
  450. @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
  451. async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
  452. """Get current plug status from device including energy data."""
  453. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  454. plug = result.scalar_one_or_none()
  455. if not plug:
  456. raise HTTPException(404, "Smart plug not found")
  457. # Handle MQTT plugs - get data from subscription service
  458. if plug.plug_type == "mqtt":
  459. data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)
  460. is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)
  461. if data:
  462. # Update last state in database
  463. if is_reachable and data.state:
  464. plug.last_state = data.state
  465. plug.last_checked = datetime.utcnow()
  466. await db.commit()
  467. energy_data = None
  468. if data.power is not None or data.energy is not None:
  469. energy_data = SmartPlugEnergy(
  470. power=data.power,
  471. today=data.energy,
  472. )
  473. # Check power alerts
  474. if data.power is not None:
  475. await check_power_alerts(plug, data.power, db)
  476. return SmartPlugStatus(
  477. state=data.state,
  478. reachable=is_reachable,
  479. device_name=None,
  480. energy=energy_data,
  481. )
  482. # No data received yet
  483. return SmartPlugStatus(
  484. state=None,
  485. reachable=False,
  486. device_name=None,
  487. energy=None,
  488. )
  489. # Handle Tasmota/HomeAssistant plugs
  490. service = await _get_service_for_plug(plug, db)
  491. status = await service.get_status(plug)
  492. # Update last state in database
  493. if status["reachable"]:
  494. plug.last_state = status["state"]
  495. plug.last_checked = datetime.utcnow()
  496. await db.commit()
  497. # Fetch energy data if device is reachable
  498. energy_data = None
  499. if status["reachable"]:
  500. energy = await service.get_energy(plug)
  501. if energy:
  502. energy_data = SmartPlugEnergy(**energy)
  503. # Check power alerts
  504. await check_power_alerts(plug, energy.get("power"), db)
  505. return SmartPlugStatus(
  506. state=status["state"],
  507. reachable=status["reachable"],
  508. device_name=status.get("device_name"),
  509. energy=energy_data,
  510. )
  511. async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
  512. """Check if power crosses alert thresholds and send notifications."""
  513. if not plug.power_alert_enabled or current_power is None:
  514. return
  515. # Cooldown: don't alert more than once per 5 minutes
  516. cooldown_minutes = 5
  517. if plug.power_alert_last_triggered:
  518. time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
  519. if time_since_last < timedelta(minutes=cooldown_minutes):
  520. return
  521. alert_triggered = False
  522. alert_type = None
  523. threshold = None
  524. # Check high threshold
  525. if plug.power_alert_high is not None and current_power > plug.power_alert_high:
  526. alert_triggered = True
  527. alert_type = "high"
  528. threshold = plug.power_alert_high
  529. # Check low threshold
  530. if plug.power_alert_low is not None and current_power < plug.power_alert_low:
  531. alert_triggered = True
  532. alert_type = "low"
  533. threshold = plug.power_alert_low
  534. if alert_triggered:
  535. plug.power_alert_last_triggered = datetime.utcnow()
  536. await db.commit()
  537. # Send notification
  538. title = f"Power Alert: {plug.name}"
  539. if alert_type == "high":
  540. message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
  541. else:
  542. message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
  543. logger.info(f"Power alert triggered for {plug.name}: {message}")
  544. # Use printer_error event type for power alerts (closest match)
  545. await notification_service.send_notification(
  546. event_type="printer_error",
  547. title=title,
  548. message=message,
  549. printer_id=plug.printer_id,
  550. printer_name=plug.name,
  551. context={
  552. "error_type": f"Power {alert_type.title()}",
  553. "error_detail": message,
  554. },
  555. )
  556. @router.post("/test-connection")
  557. async def test_connection(data: SmartPlugTestConnection):
  558. """Test connection to a Tasmota device."""
  559. result = await tasmota_service.test_connection(
  560. data.ip_address,
  561. data.username,
  562. data.password,
  563. )
  564. if not result["success"]:
  565. raise HTTPException(503, result.get("error", "Failed to connect to device"))
  566. return {
  567. "success": True,
  568. "state": result["state"],
  569. "device_name": result.get("device_name"),
  570. }