smart_plugs.py 38 KB

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