smart_plugs.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. """API routes for smart plug management."""
  2. import logging
  3. from datetime import 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.auth import RequirePermissionIfAuthEnabled
  10. from backend.app.core.database import get_db
  11. from backend.app.core.permissions import Permission
  12. from backend.app.core.tasks import spawn_background_task
  13. from backend.app.models.printer import Printer
  14. from backend.app.models.smart_plug import SmartPlug
  15. from backend.app.models.user import User
  16. from backend.app.schemas.smart_plug import (
  17. HAEntity,
  18. HASensorEntity,
  19. HATestConnectionRequest,
  20. HATestConnectionResponse,
  21. RESTTestConnectionRequest,
  22. RESTTestConnectionResponse,
  23. SmartPlugControl,
  24. SmartPlugCreate,
  25. SmartPlugEnergy,
  26. SmartPlugResponse,
  27. SmartPlugStatus,
  28. SmartPlugTestConnection,
  29. SmartPlugUpdate,
  30. )
  31. from backend.app.services.discovery import tasmota_scanner
  32. from backend.app.services.homeassistant import homeassistant_service
  33. from backend.app.services.mqtt_relay import mqtt_relay
  34. from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
  35. from backend.app.services.notification_service import notification_service
  36. from backend.app.services.plug_energy_history import fill_derived_energy
  37. from backend.app.services.printer_manager import printer_manager
  38. from backend.app.services.rest_smart_plug import rest_smart_plug_service
  39. from backend.app.services.tasmota import tasmota_service
  40. from backend.app.utils.local_time import to_naive_utc, utcnow_naive
  41. logger = logging.getLogger(__name__)
  42. router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
  43. @router.get("/", response_model=list[SmartPlugResponse])
  44. async def list_smart_plugs(
  45. db: AsyncSession = Depends(get_db),
  46. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  47. ):
  48. """List all smart plugs."""
  49. result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
  50. return list(result.scalars().all())
  51. @router.post("/", response_model=SmartPlugResponse)
  52. async def create_smart_plug(
  53. data: SmartPlugCreate,
  54. db: AsyncSession = Depends(get_db),
  55. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
  56. ):
  57. """Create a new smart plug."""
  58. # Validate printer_id if provided
  59. if data.printer_id:
  60. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  61. if not result.scalar_one_or_none():
  62. raise HTTPException(400, "Printer not found")
  63. # Check if printer already has a plug assigned
  64. # Tasmota plugs: only one per printer (physical power device)
  65. # HA entities: allow multiple per printer (for different automations)
  66. if data.plug_type == "tasmota":
  67. result = await db.execute(
  68. select(SmartPlug).where(
  69. SmartPlug.printer_id == data.printer_id,
  70. SmartPlug.plug_type == "tasmota",
  71. )
  72. )
  73. if result.scalar_one_or_none():
  74. raise HTTPException(400, "This printer already has a Tasmota plug assigned")
  75. # For MQTT plugs, ensure MQTT broker is configured and service is connected
  76. if data.plug_type == "mqtt":
  77. # Try to configure the smart plug service if not already configured
  78. if not mqtt_relay.smart_plug_service.is_configured():
  79. # Get MQTT broker settings from database
  80. mqtt_broker = await get_setting(db, "mqtt_broker") or ""
  81. if not mqtt_broker:
  82. raise HTTPException(
  83. 400,
  84. "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.",
  85. )
  86. # Configure the smart plug service with broker settings
  87. mqtt_settings = {
  88. "mqtt_enabled": True, # Enable for smart plug subscription
  89. "mqtt_broker": mqtt_broker,
  90. "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
  91. "mqtt_username": await get_setting(db, "mqtt_username") or "",
  92. "mqtt_password": await get_setting(db, "mqtt_password") or "",
  93. "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
  94. }
  95. await mqtt_relay.smart_plug_service.configure(mqtt_settings)
  96. # Check if connection succeeded
  97. if not mqtt_relay.smart_plug_service.is_configured():
  98. raise HTTPException(
  99. 400,
  100. f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
  101. )
  102. plug_data = data.model_dump()
  103. # For HA entities, default auto_on and auto_off to False
  104. # (they're for automations, not power control like Tasmota plugs)
  105. if data.plug_type == "homeassistant":
  106. plug_data["auto_on"] = False
  107. plug_data["auto_off"] = False
  108. plug = SmartPlug(**plug_data)
  109. db.add(plug)
  110. await db.commit()
  111. await db.refresh(plug)
  112. # Subscribe MQTT plugs to their topics
  113. if plug.plug_type == "mqtt":
  114. topics = subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)
  115. if topics:
  116. logger.info("Created MQTT plug '%s' subscribed to %s", plug.name, ", ".join(topics))
  117. elif plug.plug_type == "homeassistant":
  118. logger.info("Created Home Assistant plug '%s' (%s)", plug.name, plug.ha_entity_id)
  119. else:
  120. logger.info("Created Tasmota plug '%s' at %s", plug.name, plug.ip_address)
  121. return plug
  122. def _is_script_plug(plug: SmartPlug) -> bool:
  123. """Whether the plug is a Home Assistant script rather than a switchable device."""
  124. return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
  125. def _can_be_switched(plug: SmartPlug) -> bool:
  126. """Whether ``control_smart_plug`` can actually turn this plug on and off.
  127. Two kinds cannot, and the card's on/off button is useless on both:
  128. - A Home Assistant script. It can be run, not switched.
  129. - An MQTT plug. Bambuddy subscribes to it and never publishes, so the
  130. control endpoint rejects it outright as monitor-only -- and an MQTT plug
  131. is exactly the kind that reports watts, so without this it would win the
  132. power tiebreak below and take the row off a plug that can be switched.
  133. """
  134. return not _is_script_plug(plug) and plug.plug_type != "mqtt"
  135. def _reports_power(plug: SmartPlug) -> bool:
  136. """Whether the plug is configured with somewhere to read watts from (#2830).
  137. Read from the configuration rather than measured: this runs on every printer
  138. card render, and probing each plug would mean an HTTP round trip per plug.
  139. So it is approximate in both directions -- an HA plug with no dedicated power
  140. sensor may still report watts from the switch entity's own
  141. ``current_power_w`` attribute, and a Tasmota device without energy metering
  142. is counted here as if it had it. Only a live read could tell, and this is
  143. used solely to break a tie between plugs that are otherwise equally
  144. eligible, so neither miss can decide anything on its own.
  145. """
  146. if plug.plug_type == "homeassistant":
  147. return bool(plug.ha_power_entity)
  148. if plug.plug_type == "mqtt":
  149. return bool(plug.mqtt_power_topic or plug.mqtt_topic)
  150. if plug.plug_type == "rest":
  151. return bool(plug.rest_power_path)
  152. return True # Tasmota, whose firmware reports power when the hardware has it
  153. def _main_plug_rank(plug: SmartPlug) -> tuple:
  154. """Sort key for choosing the printer's main power plug, best first (#2830).
  155. A printer's plugs are not interchangeable. The card's Power row carries the
  156. power on/off and auto-off-after-print controls, so it has to land on the plug
  157. that actually feeds the printer -- pointing those at an exhaust fan is the
  158. same harm #2629 fixed for the scheduler's power-on. Ordered:
  159. 1. It can be switched at all -- see ``_can_be_switched``. The row's buttons
  160. are the point of it.
  161. 2. ``controls_printer_power`` -- the flag that says this plug feeds the
  162. printer, as opposed to an accessory that merely follows the print cycle.
  163. 3. ``enabled`` -- a disabled plug ignores automation, so its auto-off toggle
  164. would sit there doing nothing.
  165. 4. ``show_on_printer_card`` -- ranked, not filtered: excluding hidden plugs
  166. outright would strip the Power row, and with it the on/off button, from a
  167. printer whose only plug has the flag off. It sorts below the power flag
  168. because a display preference must not hand power control to an accessory.
  169. 5. Reports power, so the row shows watts rather than "--" where there is a
  170. choice.
  171. 6. Lowest id, so the answer never depends on row order. The query had no
  172. ORDER BY at all, which on Postgres means a plain UPDATE can move a row and
  173. silently swap which plug the card calls the printer's power.
  174. """
  175. return (
  176. not _can_be_switched(plug),
  177. not plug.controls_printer_power,
  178. not plug.enabled,
  179. not plug.show_on_printer_card,
  180. not _reports_power(plug),
  181. plug.id,
  182. )
  183. def _pick_main_plug(plugs: list[SmartPlug]) -> SmartPlug | None:
  184. """The plug the printer card shows as its power, or None if there are none."""
  185. return min(plugs, key=_main_plug_rank, default=None)
  186. async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlug]:
  187. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id).order_by(SmartPlug.id))
  188. return list(result.scalars().all())
  189. @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
  190. async def get_smart_plug_by_printer(
  191. printer_id: int,
  192. db: AsyncSession = Depends(get_db),
  193. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  194. ):
  195. """Get the main smart plug assigned to a printer.
  196. When several plugs are assigned -- a printer outlet, an enclosure fan, a
  197. script -- returns the one that best fits the card's power controls. See
  198. ``_main_plug_rank`` for the order and why.
  199. """
  200. return _pick_main_plug(await _plugs_for_printer(db, printer_id))
  201. @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
  202. async def get_script_plugs_by_printer(
  203. printer_id: int,
  204. db: AsyncSession = Depends(get_db),
  205. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  206. ):
  207. """Get all HA entities assigned to a printer for display on printer card.
  208. Returns HA entities (switches, scripts, lights, etc.) for the printer that have
  209. show_on_printer_card enabled.
  210. Used to display action buttons alongside the main power plug.
  211. A switchable main plug is left out: it is rendered directly above this row
  212. with its own on/off button, so listing it here draws the same entity twice
  213. (#2830). A script is not, because a printer whose only entities are scripts
  214. falls back to showing one of them in the power row -- taking it out of this
  215. row too would cost the one-click run it has always had there.
  216. """
  217. plugs = await _plugs_for_printer(db, printer_id)
  218. main_plug = _pick_main_plug(plugs)
  219. duplicate_of_power_row = main_plug.id if main_plug and not _is_script_plug(main_plug) else None
  220. # Filter to HA entities with show_on_printer_card enabled
  221. ha_entities = [
  222. plug
  223. for plug in plugs
  224. if plug.plug_type == "homeassistant"
  225. and plug.ha_entity_id
  226. and plug.show_on_printer_card
  227. and plug.id != duplicate_of_power_row
  228. ]
  229. return ha_entities
  230. # Tasmota Discovery Endpoints
  231. # NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts
  232. class TasmotaScanRequest(BaseModel):
  233. """Request to scan for Tasmota devices."""
  234. from_ip: str | None = None # Starting IP (auto-detected if not provided)
  235. to_ip: str | None = None # Ending IP (auto-detected if not provided)
  236. timeout: float = 1.0 # Connection timeout per host
  237. def get_local_network_range() -> tuple[str, str]:
  238. """Auto-detect local network and return IP range to scan."""
  239. import socket
  240. try:
  241. # Get local IP by connecting to a public DNS (doesn't actually send data)
  242. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  243. s.connect(("8.8.8.8", 80))
  244. local_ip = s.getsockname()[0]
  245. s.close()
  246. # Parse IP and create range (assume /24 subnet)
  247. parts = local_ip.split(".")
  248. base = ".".join(parts[:3])
  249. from_ip = f"{base}.1"
  250. to_ip = f"{base}.254"
  251. logger.info("Auto-detected network: %s - %s (local IP: %s)", from_ip, to_ip, local_ip)
  252. return from_ip, to_ip
  253. except OSError as e:
  254. logger.error("Failed to detect local network: %s", e)
  255. # Fallback to common home network
  256. return "192.168.1.1", "192.168.1.254"
  257. class TasmotaScanStatus(BaseModel):
  258. """Tasmota scan status response."""
  259. running: bool
  260. scanned: int
  261. total: int
  262. class DiscoveredTasmotaDevice(BaseModel):
  263. """Discovered Tasmota device."""
  264. ip_address: str
  265. name: str
  266. module: int | None = None
  267. state: str | None = None
  268. discovered_at: str | None = None
  269. @router.post("/discover/scan", response_model=TasmotaScanStatus)
  270. async def start_tasmota_scan(
  271. request: TasmotaScanRequest | None = Body(default=None),
  272. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  273. ):
  274. """Start an IP range scan for Tasmota devices.
  275. Auto-detects local network if no IP range provided.
  276. """
  277. # Auto-detect network
  278. from_ip, to_ip = get_local_network_range()
  279. timeout = request.timeout if request else 1.0
  280. # Start scan in background
  281. spawn_background_task(
  282. tasmota_scanner.scan_range(from_ip, to_ip, timeout),
  283. name="tasmota-scan",
  284. )
  285. # Return immediate status
  286. scanned, total = tasmota_scanner.progress
  287. return TasmotaScanStatus(
  288. running=tasmota_scanner.is_running,
  289. scanned=scanned,
  290. total=total,
  291. )
  292. @router.get("/discover/status", response_model=TasmotaScanStatus)
  293. async def get_tasmota_scan_status(
  294. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  295. ):
  296. """Get the current Tasmota scan status."""
  297. scanned, total = tasmota_scanner.progress
  298. return TasmotaScanStatus(
  299. running=tasmota_scanner.is_running,
  300. scanned=scanned,
  301. total=total,
  302. )
  303. @router.post("/discover/stop", response_model=TasmotaScanStatus)
  304. async def stop_tasmota_scan(
  305. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  306. ):
  307. """Stop the current Tasmota scan."""
  308. tasmota_scanner.stop()
  309. scanned, total = tasmota_scanner.progress
  310. return TasmotaScanStatus(
  311. running=tasmota_scanner.is_running,
  312. scanned=scanned,
  313. total=total,
  314. )
  315. @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
  316. async def get_discovered_tasmota_devices(
  317. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  318. ):
  319. """Get list of discovered Tasmota devices."""
  320. return [
  321. DiscoveredTasmotaDevice(
  322. ip_address=d["ip_address"],
  323. name=d["name"],
  324. module=d.get("module"),
  325. state=d.get("state"),
  326. discovered_at=d.get("discovered_at"),
  327. )
  328. for d in tasmota_scanner.discovered_devices
  329. ]
  330. # Home Assistant Discovery Endpoints
  331. @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
  332. async def test_ha_connection(
  333. request: HATestConnectionRequest,
  334. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
  335. ):
  336. """Test connection to Home Assistant."""
  337. result = await homeassistant_service.test_connection(request.url, request.token)
  338. return HATestConnectionResponse(**result)
  339. @router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
  340. async def test_rest_connection(
  341. request: RESTTestConnectionRequest,
  342. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
  343. ):
  344. """Test connection to a REST/HTTP endpoint."""
  345. result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)
  346. return RESTTestConnectionResponse(**result)
  347. @router.get("/ha/entities", response_model=list[HAEntity])
  348. async def list_ha_entities(
  349. db: AsyncSession = Depends(get_db),
  350. search: str | None = None,
  351. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  352. ):
  353. """List available Home Assistant entities.
  354. By default, returns switch/light/input_boolean entities.
  355. When search is provided, searches ALL entities by entity_id or friendly_name.
  356. Requires HA connection settings to be configured in Settings.
  357. """
  358. from backend.app.api.routes.settings import get_homeassistant_settings
  359. ha_settings = await get_homeassistant_settings(db)
  360. ha_url = ha_settings["ha_url"]
  361. ha_token = ha_settings["ha_token"]
  362. if not ha_url or not ha_token:
  363. raise HTTPException(
  364. 400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  365. )
  366. entities = await homeassistant_service.list_entities(ha_url, ha_token, search)
  367. return [HAEntity(**e) for e in entities]
  368. @router.get("/ha/sensors", response_model=list[HASensorEntity])
  369. async def list_ha_sensor_entities(
  370. db: AsyncSession = Depends(get_db),
  371. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  372. ):
  373. """List available Home Assistant sensor entities for energy monitoring.
  374. Returns sensors with power/energy units (W, kW, kWh, Wh).
  375. Requires HA connection settings to be configured in Settings.
  376. """
  377. from backend.app.api.routes.settings import get_homeassistant_settings
  378. ha_settings = await get_homeassistant_settings(db)
  379. ha_url = ha_settings["ha_url"]
  380. ha_token = ha_settings["ha_token"]
  381. if not ha_url or not ha_token:
  382. raise HTTPException(
  383. 400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  384. )
  385. sensors = await homeassistant_service.list_sensor_entities(ha_url, ha_token)
  386. return [HASensorEntity(**s) for s in sensors]
  387. @router.get("/{plug_id}", response_model=SmartPlugResponse)
  388. async def get_smart_plug(
  389. plug_id: int,
  390. db: AsyncSession = Depends(get_db),
  391. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  392. ):
  393. """Get a specific smart plug."""
  394. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  395. plug = result.scalar_one_or_none()
  396. if not plug:
  397. raise HTTPException(404, "Smart plug not found")
  398. return plug
  399. @router.patch("/{plug_id}", response_model=SmartPlugResponse)
  400. async def update_smart_plug(
  401. plug_id: int,
  402. data: SmartPlugUpdate,
  403. db: AsyncSession = Depends(get_db),
  404. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
  405. ):
  406. """Update a smart plug."""
  407. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  408. plug = result.scalar_one_or_none()
  409. if not plug:
  410. raise HTTPException(404, "Smart plug not found")
  411. update_data = data.model_dump(exclude_unset=True)
  412. # Validate new printer_id if being changed
  413. if "printer_id" in update_data and update_data["printer_id"]:
  414. new_printer_id = update_data["printer_id"]
  415. # Check printer exists
  416. result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
  417. if not result.scalar_one_or_none():
  418. raise HTTPException(400, "Printer not found")
  419. # Check if that printer already has a different Tasmota plug assigned
  420. # Tasmota plugs: only one per printer (physical power device)
  421. # HA entities: allow multiple per printer (for different automations)
  422. new_plug_type = update_data.get("plug_type", plug.plug_type)
  423. if new_plug_type == "tasmota":
  424. result = await db.execute(
  425. select(SmartPlug).where(
  426. SmartPlug.printer_id == new_printer_id,
  427. SmartPlug.id != plug_id,
  428. SmartPlug.plug_type == "tasmota",
  429. )
  430. )
  431. if result.scalar_one_or_none():
  432. raise HTTPException(400, "This printer already has a Tasmota plug assigned")
  433. # Track old MQTT settings for comparison
  434. old_plug_type = plug.plug_type
  435. old_mqtt_config = {
  436. "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
  437. "power_path": plug.mqtt_power_path,
  438. "power_multiplier": plug.mqtt_power_multiplier,
  439. "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
  440. "energy_path": plug.mqtt_energy_path,
  441. "energy_multiplier": plug.mqtt_energy_multiplier,
  442. "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
  443. "state_path": plug.mqtt_state_path,
  444. "state_on_value": plug.mqtt_state_on_value,
  445. }
  446. for field, value in update_data.items():
  447. setattr(plug, field, value)
  448. await db.commit()
  449. await db.refresh(plug)
  450. # Handle MQTT subscription changes
  451. if old_plug_type == "mqtt" and plug.plug_type != "mqtt":
  452. # Changed away from MQTT - unsubscribe
  453. mqtt_relay.smart_plug_service.unsubscribe(plug.id)
  454. elif plug.plug_type == "mqtt":
  455. # Check if any MQTT config changed
  456. new_mqtt_config = {
  457. "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
  458. "power_path": plug.mqtt_power_path,
  459. "power_multiplier": plug.mqtt_power_multiplier,
  460. "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
  461. "energy_path": plug.mqtt_energy_path,
  462. "energy_multiplier": plug.mqtt_energy_multiplier,
  463. "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
  464. "state_path": plug.mqtt_state_path,
  465. "state_on_value": plug.mqtt_state_on_value,
  466. }
  467. mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config
  468. if mqtt_changed:
  469. # Unsubscribe from old topics first
  470. if old_plug_type == "mqtt":
  471. mqtt_relay.smart_plug_service.unsubscribe(plug.id)
  472. # Subscribe via the shared helper (matches startup restore and
  473. # create route) — keeps all three paths in lock-step.
  474. subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)
  475. logger.info("Updated smart plug '%s'", plug.name)
  476. return plug
  477. @router.delete("/{plug_id}")
  478. async def delete_smart_plug(
  479. plug_id: int,
  480. db: AsyncSession = Depends(get_db),
  481. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
  482. ):
  483. """Delete a smart plug."""
  484. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  485. plug = result.scalar_one_or_none()
  486. if not plug:
  487. raise HTTPException(404, "Smart plug not found")
  488. plug_name = plug.name
  489. plug_type = plug.plug_type
  490. # Unsubscribe MQTT plug before deletion
  491. if plug_type == "mqtt":
  492. mqtt_relay.smart_plug_service.unsubscribe(plug_id)
  493. await db.delete(plug)
  494. await db.commit()
  495. logger.info("Deleted smart plug '%s'", plug_name)
  496. return {"message": "Smart plug deleted"}
  497. async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
  498. """Get the appropriate service for the plug type.
  499. For HA plugs, configures the service with current settings from DB.
  500. """
  501. if plug.plug_type == "homeassistant":
  502. # Configure HA service with current settings
  503. from backend.app.api.routes.settings import get_homeassistant_settings
  504. ha_settings = await get_homeassistant_settings(db)
  505. homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
  506. return homeassistant_service
  507. if plug.plug_type == "rest":
  508. return rest_smart_plug_service
  509. return tasmota_service
  510. @router.post("/{plug_id}/control")
  511. async def control_smart_plug(
  512. plug_id: int,
  513. control: SmartPlugControl,
  514. db: AsyncSession = Depends(get_db),
  515. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
  516. ):
  517. """Manual control: on/off/toggle."""
  518. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  519. plug = result.scalar_one_or_none()
  520. if not plug:
  521. raise HTTPException(404, "Smart plug not found")
  522. # MQTT plugs are monitor-only - cannot control them
  523. if plug.plug_type == "mqtt":
  524. raise HTTPException(
  525. 400,
  526. "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.",
  527. )
  528. service = await _get_service_for_plug(plug, db)
  529. if control.action == "on":
  530. success = await service.turn_on(plug)
  531. expected_state = "ON"
  532. elif control.action == "off":
  533. success = await service.turn_off(plug)
  534. expected_state = "OFF"
  535. elif control.action == "toggle":
  536. success = await service.toggle(plug)
  537. expected_state = None # Unknown after toggle
  538. else:
  539. raise HTTPException(400, f"Invalid action: {control.action}")
  540. if not success:
  541. raise HTTPException(503, "Failed to communicate with device")
  542. # Update last state and reset auto_off_executed when turning on
  543. if expected_state:
  544. plug.last_state = expected_state
  545. if expected_state == "ON":
  546. plug.auto_off_executed = False # Reset flag when manually turning on
  547. elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
  548. # Mark printer offline immediately for faster UI update. Skipped for
  549. # accessory plugs, which are linked to a printer but don't feed it (#2629).
  550. printer_manager.mark_printer_offline(plug.printer_id)
  551. plug.last_checked = utcnow_naive()
  552. await db.commit()
  553. # Trigger associated scripts if this is a main (non-script) plug
  554. is_main_plug = not (
  555. plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
  556. )
  557. if is_main_plug and plug.printer_id and expected_state:
  558. await trigger_associated_scripts(plug.printer_id, expected_state, db)
  559. # MQTT relay - publish smart plug state change
  560. if expected_state:
  561. try:
  562. from backend.app.services.mqtt_relay import mqtt_relay
  563. # Get printer name if linked
  564. printer_name = None
  565. if plug.printer_id:
  566. result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))
  567. printer = result.scalar_one_or_none()
  568. printer_name = printer.name if printer else None
  569. await mqtt_relay.on_smart_plug_state(
  570. plug_id=plug.id,
  571. plug_name=plug.name,
  572. state="on" if expected_state == "ON" else "off",
  573. printer_id=plug.printer_id,
  574. printer_name=printer_name,
  575. )
  576. except Exception:
  577. pass # Don't fail if MQTT fails
  578. return {"success": True, "action": control.action}
  579. async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
  580. """Trigger scripts linked to a printer based on main plug state change.
  581. When the main plug turns ON, triggers scripts with auto_on=True.
  582. When the main plug turns OFF, triggers scripts with auto_off=True.
  583. """
  584. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  585. plugs = result.scalars().all()
  586. # Find scripts that should be triggered
  587. for plug in plugs:
  588. is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
  589. if not is_script:
  590. continue
  591. should_trigger = False
  592. if plug_state == "ON" and plug.auto_on:
  593. should_trigger = True
  594. logger.info("Auto-triggering script '%s' on printer power-on", plug.name)
  595. elif plug_state == "OFF" and plug.auto_off:
  596. should_trigger = True
  597. logger.info("Auto-triggering script '%s' on printer power-off", plug.name)
  598. if should_trigger:
  599. try:
  600. service = await _get_service_for_plug(plug, db)
  601. await service.turn_on(plug) # Scripts are triggered by calling turn_on
  602. except Exception as e:
  603. logger.error("Failed to trigger script '%s': %s", plug.name, e)
  604. @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
  605. async def get_plug_status(
  606. plug_id: int,
  607. db: AsyncSession = Depends(get_db),
  608. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  609. ):
  610. """Get current plug status from device including energy data."""
  611. result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  612. plug = result.scalar_one_or_none()
  613. if not plug:
  614. raise HTTPException(404, "Smart plug not found")
  615. # Handle MQTT plugs - get data from subscription service
  616. if plug.plug_type == "mqtt":
  617. data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)
  618. is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)
  619. if data:
  620. # Update last state in database
  621. if is_reachable and data.state:
  622. plug.last_state = data.state
  623. plug.last_checked = utcnow_naive()
  624. await db.commit()
  625. energy_data = None
  626. if data.power is not None or data.energy is not None:
  627. energy_data = SmartPlugEnergy(
  628. power=data.power,
  629. today=data.energy,
  630. )
  631. # Check power alerts
  632. if data.power is not None:
  633. await check_power_alerts(plug, data.power, db)
  634. return SmartPlugStatus(
  635. state=data.state,
  636. reachable=is_reachable,
  637. device_name=None,
  638. energy=energy_data,
  639. )
  640. # No data received yet
  641. return SmartPlugStatus(
  642. state=None,
  643. reachable=False,
  644. device_name=None,
  645. energy=None,
  646. )
  647. # Handle Tasmota/HomeAssistant plugs
  648. service = await _get_service_for_plug(plug, db)
  649. status = await service.get_status(plug)
  650. # Update last state in database
  651. if status["reachable"]:
  652. plug.last_state = status["state"]
  653. plug.last_checked = utcnow_naive()
  654. await db.commit()
  655. # Fetch energy data if device is reachable
  656. energy_data = None
  657. if status["reachable"]:
  658. energy = await service.get_energy(plug)
  659. if energy:
  660. # Most plugs report only a lifetime counter — a Shelly has no notion
  661. # of "today" at all, and Home Assistant never reports "yesterday".
  662. # Fill those in from the hourly snapshots (#2539). Tasmota, which
  663. # knows its own daily figures, is left alone.
  664. energy = await fill_derived_energy(db, plug.id, energy)
  665. energy_data = SmartPlugEnergy(**energy)
  666. # Check power alerts
  667. await check_power_alerts(plug, energy.get("power"), db)
  668. return SmartPlugStatus(
  669. state=status["state"],
  670. reachable=status["reachable"],
  671. device_name=status.get("device_name"),
  672. energy=energy_data,
  673. )
  674. async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
  675. """Check if power crosses alert thresholds and send notifications."""
  676. if not plug.power_alert_enabled or current_power is None:
  677. return
  678. # Cooldown: don't alert more than once per 5 minutes
  679. cooldown_minutes = 5
  680. if plug.power_alert_last_triggered:
  681. # Naive UTC on both sides: the column is naive, so a row loaded fresh from
  682. # the DB comes back without an offset and subtracting an aware now() would
  683. # raise TypeError.
  684. time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
  685. if time_since_last < timedelta(minutes=cooldown_minutes):
  686. return
  687. alert_triggered = False
  688. alert_type = None
  689. threshold = None
  690. # Check high threshold
  691. if plug.power_alert_high is not None and current_power > plug.power_alert_high:
  692. alert_triggered = True
  693. alert_type = "high"
  694. threshold = plug.power_alert_high
  695. # Check low threshold
  696. if plug.power_alert_low is not None and current_power < plug.power_alert_low:
  697. alert_triggered = True
  698. alert_type = "low"
  699. threshold = plug.power_alert_low
  700. if alert_triggered:
  701. plug.power_alert_last_triggered = utcnow_naive()
  702. await db.commit()
  703. # Send notification
  704. title = f"Power Alert: {plug.name}"
  705. if alert_type == "high":
  706. message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
  707. else:
  708. message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
  709. logger.info("Power alert triggered for %s: %s", plug.name, message)
  710. # Use printer_error event type for power alerts (closest match)
  711. await notification_service.send_notification(
  712. event_type="printer_error",
  713. title=title,
  714. message=message,
  715. printer_id=plug.printer_id,
  716. printer_name=plug.name,
  717. context={
  718. "error_type": f"Power {alert_type.title()}",
  719. "error_detail": message,
  720. },
  721. )
  722. @router.post("/test-connection")
  723. async def test_connection(
  724. data: SmartPlugTestConnection,
  725. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
  726. ):
  727. """Test connection to a Tasmota device."""
  728. result = await tasmota_service.test_connection(
  729. data.ip_address,
  730. data.username,
  731. data.password,
  732. )
  733. if not result["success"]:
  734. raise HTTPException(503, result.get("error", "Failed to connect to device"))
  735. return {
  736. "success": True,
  737. "state": result["state"],
  738. "device_name": result.get("device_name"),
  739. }