smart_plugs.py 30 KB

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