homeassistant.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. """Service for communicating with Home Assistant via REST API."""
  2. import asyncio
  3. import logging
  4. from typing import TYPE_CHECKING
  5. from urllib.parse import urlparse
  6. import httpx
  7. from backend.app.core.logging_filters import redact_refused_headers
  8. if TYPE_CHECKING:
  9. from backend.app.models.smart_plug import SmartPlug
  10. logger = logging.getLogger(__name__)
  11. class HomeAssistantService:
  12. """Service for controlling Home Assistant entities via REST API."""
  13. def __init__(self, timeout: float = 10.0):
  14. self.timeout = timeout
  15. self.base_url: str = ""
  16. self.token: str = ""
  17. def configure(self, url: str, token: str):
  18. """Configure HA connection settings."""
  19. self.base_url = url.rstrip("/") if url else ""
  20. self.token = token or ""
  21. def _headers(self) -> dict:
  22. return {
  23. "Authorization": f"Bearer {self.token}",
  24. "Content-Type": "application/json",
  25. }
  26. async def get_status(self, plug: "SmartPlug") -> dict:
  27. """Get current state of HA entity.
  28. Returns dict with:
  29. - state: "ON" or "OFF" or None if unreachable
  30. - reachable: bool
  31. - device_name: str or None
  32. """
  33. if not self.base_url or not self.token:
  34. return {"state": None, "reachable": False, "device_name": None}
  35. try:
  36. async with httpx.AsyncClient(timeout=self.timeout) as client:
  37. response = await client.get(
  38. f"{self.base_url}/api/states/{plug.ha_entity_id}",
  39. headers=self._headers(),
  40. )
  41. response.raise_for_status()
  42. data = response.json()
  43. state_value = data.get("state", "").lower()
  44. # Normalize to ON/OFF
  45. if state_value == "on":
  46. state = "ON"
  47. elif state_value == "off":
  48. state = "OFF"
  49. else:
  50. state = None
  51. return {
  52. "state": state,
  53. "reachable": True,
  54. "device_name": data.get("attributes", {}).get("friendly_name"),
  55. }
  56. except Exception as e:
  57. logger.warning("Failed to get HA entity state for %s: %s", plug.ha_entity_id, e)
  58. return {"state": None, "reachable": False, "device_name": None}
  59. async def turn_on(self, plug: "SmartPlug") -> bool:
  60. """Turn on HA entity. Returns True if successful."""
  61. success = await self._call_service(plug, "turn_on")
  62. if success:
  63. logger.info("Turned ON HA entity '%s' (%s)", plug.name, plug.ha_entity_id)
  64. return success
  65. async def turn_off(self, plug: "SmartPlug") -> bool:
  66. """Turn off HA entity. Returns True if successful."""
  67. success = await self._call_service(plug, "turn_off")
  68. if success:
  69. logger.info("Turned OFF HA entity '%s' (%s)", plug.name, plug.ha_entity_id)
  70. return success
  71. async def toggle(self, plug: "SmartPlug") -> bool:
  72. """Toggle HA entity. Returns True if successful."""
  73. success = await self._call_service(plug, "toggle")
  74. if success:
  75. logger.info("Toggled HA entity '%s' (%s)", plug.name, plug.ha_entity_id)
  76. return success
  77. async def _call_service(self, plug: "SmartPlug", action: str) -> bool:
  78. """Call HA service on entity."""
  79. if not self.base_url or not self.token or not plug.ha_entity_id:
  80. return False
  81. domain = plug.ha_entity_id.split(".")[0] # "switch", "light", etc.
  82. try:
  83. async with httpx.AsyncClient(timeout=self.timeout) as client:
  84. response = await client.post(
  85. f"{self.base_url}/api/services/{domain}/{action}",
  86. headers=self._headers(),
  87. json={"entity_id": plug.ha_entity_id},
  88. )
  89. response.raise_for_status()
  90. return True
  91. except Exception as e:
  92. logger.warning("Failed to %s HA entity %s: %s", action, plug.ha_entity_id, e)
  93. return False
  94. async def get_energy(self, plug: "SmartPlug") -> dict | None:
  95. """Get energy data from HA sensor entities or switch attributes.
  96. First tries dedicated sensor entities if configured, then falls back
  97. to checking the switch entity's attributes.
  98. Returns dict with energy data or None if not available.
  99. """
  100. if not self.base_url or not self.token:
  101. return None
  102. power = None
  103. today = None
  104. total = None
  105. try:
  106. async with httpx.AsyncClient(timeout=self.timeout) as client:
  107. # Fetch power from dedicated sensor entity if configured
  108. if plug.ha_power_entity:
  109. power = await self._get_sensor_value(client, plug.ha_power_entity)
  110. # Fetch today's energy from dedicated sensor entity if configured
  111. if plug.ha_energy_today_entity:
  112. today = await self._get_sensor_value(client, plug.ha_energy_today_entity)
  113. # Fetch total energy from dedicated sensor entity if configured
  114. if plug.ha_energy_total_entity:
  115. total = await self._get_sensor_value(client, plug.ha_energy_total_entity)
  116. # Fallback: try switch entity attributes (original behavior)
  117. if power is None:
  118. response = await client.get(
  119. f"{self.base_url}/api/states/{plug.ha_entity_id}",
  120. headers=self._headers(),
  121. )
  122. response.raise_for_status()
  123. attrs = response.json().get("attributes", {})
  124. power = attrs.get("current_power_w") or attrs.get("power")
  125. if today is None:
  126. today = attrs.get("today_energy_kwh")
  127. if total is None:
  128. total = attrs.get("total_energy_kwh")
  129. if power is None:
  130. return None
  131. return {
  132. "power": power,
  133. "voltage": None,
  134. "current": None,
  135. "today": today,
  136. "total": total,
  137. "yesterday": None,
  138. "factor": None,
  139. "apparent_power": None,
  140. "reactive_power": None,
  141. }
  142. except Exception as e:
  143. logger.debug("Failed to get HA energy data: %s", e)
  144. return None
  145. async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:
  146. """Fetch numeric value from a HA sensor entity."""
  147. try:
  148. response = await client.get(
  149. f"{self.base_url}/api/states/{entity_id}",
  150. headers=self._headers(),
  151. )
  152. response.raise_for_status()
  153. state = response.json().get("state")
  154. if state and state not in ("unknown", "unavailable"):
  155. return float(state)
  156. except Exception:
  157. pass # Sensor read is best-effort; caller handles None
  158. return None
  159. @staticmethod
  160. def _validate_url(url: str) -> str | None:
  161. """Normalise a caller-supplied HA URL, or return None if it is unsafe.
  162. The stored ``ha_url`` setting is already validated at the schema layer
  163. (``LAN_SERVICE_URL_SETTINGS`` in schemas/settings.py), but
  164. ``test_connection`` takes its URL straight from the request body, so
  165. the same policy has to be applied here.
  166. Delegates to ``_url_safety.assert_safe_lan_service_url`` rather than
  167. the string blocklist this replaces. That blocklist only knew three
  168. literal hostnames plus a ``169.254.`` prefix and never parsed the
  169. hostname as an IP, so it let through the Alibaba (100.100.100.200)
  170. and AWS-IPv6 (fd00:ec2::254) metadata endpoints, numeric-encoded
  171. loopback, multicast, and IPv4-mapped IPv6 encodings of the IMDS
  172. address it did know about.
  173. Loopback and RFC-1918 remain permitted — Home Assistant is a
  174. LAN-resident service by design, and the shared guard is documented
  175. that way.
  176. """
  177. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  178. try:
  179. assert_safe_lan_service_url(url, label="Home Assistant URL")
  180. except ValueError:
  181. return None
  182. # Guard passed, so the scheme is http/https and a hostname is present;
  183. # re-parse only to drop query/fragment and normalise the authority.
  184. parsed = urlparse(url)
  185. if not parsed.hostname:
  186. return None
  187. # urlparse strips the brackets off an IPv6 literal, so they have to go
  188. # back on or the rebuilt URL is unparseable ("http://fd00::1:8123").
  189. host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
  190. return f"{parsed.scheme.lower()}://{host}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
  191. async def test_connection(self, url: str, token: str) -> dict:
  192. """Test connection to Home Assistant.
  193. Returns dict with:
  194. - success: bool
  195. - message: str or None (HA message on success)
  196. - error: str or None (error message on failure)
  197. """
  198. safe_url = self._validate_url(url)
  199. if not safe_url:
  200. return {"success": False, "message": None, "error": "Invalid Home Assistant URL"}
  201. try:
  202. async with httpx.AsyncClient(timeout=self.timeout) as client:
  203. response = await client.get(
  204. f"{safe_url.rstrip('/')}/api/",
  205. headers={"Authorization": f"Bearer {token}"},
  206. )
  207. response.raise_for_status()
  208. data = response.json()
  209. return {
  210. "success": True,
  211. "message": data.get("message", "Connected"),
  212. "error": None,
  213. }
  214. except httpx.HTTPStatusError as e:
  215. if e.response.status_code == 401:
  216. return {"success": False, "message": None, "error": "Invalid access token"}
  217. return {"success": False, "message": None, "error": f"HTTP {e.response.status_code}"}
  218. except httpx.TimeoutException:
  219. return {"success": False, "message": None, "error": "Connection timeout"}
  220. except httpx.ConnectError:
  221. return {"success": False, "message": None, "error": "Could not connect to Home Assistant"}
  222. except Exception as e:
  223. # May be the saved token's test: a refused header's value stays out.
  224. return {"success": False, "message": None, "error": redact_refused_headers(str(e))}
  225. async def list_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
  226. """List available entities from HA.
  227. Always filters to switch/light/input_boolean/script — the only domains
  228. the SmartPlugBase.ha_entity_id pattern accepts. When a search query is
  229. provided it narrows the same domain-filtered list by entity_id or
  230. friendly_name substring (case-insensitive).
  231. Previously search bypassed the domain filter, which let users pick a
  232. sensor.* or binary_sensor.* entity from the dropdown that the backend
  233. schema would then reject with the cryptic Pydantic pattern error
  234. (#1388). Picking what you can't save isn't a useful UX.
  235. Returns list of entity dicts with:
  236. - entity_id: str
  237. - friendly_name: str
  238. - state: str
  239. - domain: str
  240. """
  241. # Allowed domains for smart plug control — must mirror the regex in
  242. # backend/app/schemas/smart_plug.py:17 (SmartPlugBase.ha_entity_id).
  243. allowed_domains = {"switch", "light", "input_boolean", "script"}
  244. try:
  245. async with httpx.AsyncClient(timeout=self.timeout) as client:
  246. response = await client.get(
  247. f"{url.rstrip('/')}/api/states",
  248. headers={"Authorization": f"Bearer {token}"},
  249. )
  250. response.raise_for_status()
  251. entities = []
  252. search_lower = search.lower().strip() if search else None
  253. for entity in response.json():
  254. entity_id = entity.get("entity_id", "")
  255. domain = entity_id.split(".")[0] if "." in entity_id else ""
  256. friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id)
  257. if domain not in allowed_domains:
  258. continue
  259. if search_lower and (
  260. search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
  261. ):
  262. continue
  263. entities.append(
  264. {
  265. "entity_id": entity_id,
  266. "friendly_name": friendly_name,
  267. "state": entity.get("state"),
  268. "domain": domain,
  269. }
  270. )
  271. return sorted(entities, key=lambda x: x["friendly_name"].lower())
  272. except Exception as e:
  273. logger.warning("Failed to list HA entities: %s", e)
  274. return []
  275. async def list_sensor_entities(self, url: str, token: str) -> list[dict]:
  276. """List available sensor entities for energy monitoring.
  277. Returns list of sensor entities with power/energy units.
  278. """
  279. try:
  280. async with httpx.AsyncClient(timeout=self.timeout) as client:
  281. response = await client.get(
  282. f"{url.rstrip('/')}/api/states",
  283. headers={"Authorization": f"Bearer {token}"},
  284. )
  285. response.raise_for_status()
  286. # Valid units for energy monitoring sensors (lowercase for case-insensitive matching)
  287. power_units = {"w", "kw", "mw"}
  288. energy_units = {"kwh", "wh", "mwh"}
  289. valid_units = power_units | energy_units
  290. entities = []
  291. for entity in response.json():
  292. entity_id = entity.get("entity_id", "")
  293. domain = entity_id.split(".")[0] if "." in entity_id else ""
  294. # Filter to sensor domain only
  295. if domain != "sensor":
  296. continue
  297. attrs = entity.get("attributes", {})
  298. unit = attrs.get("unit_of_measurement", "")
  299. # Only include sensors with power/energy units (case-insensitive)
  300. if unit.lower() in valid_units:
  301. entities.append(
  302. {
  303. "entity_id": entity_id,
  304. "friendly_name": attrs.get("friendly_name", entity_id),
  305. "state": entity.get("state"),
  306. "unit_of_measurement": unit,
  307. }
  308. )
  309. return sorted(entities, key=lambda x: x["friendly_name"].lower())
  310. except Exception as e:
  311. logger.warning("Failed to list HA sensor entities: %s", e)
  312. return []
  313. async def list_display_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
  314. """List entities that can be bound to a printer for display (#1148, #448).
  315. Covers every ``binary_sensor.*`` plus the ``sensor.*`` entities that
  316. carry a reading. Distinct from ``list_sensor_entities``, which exists
  317. for a plug's energy monitoring and therefore only admits power/energy
  318. units — an enclosure thermometer is exactly what that one filters out.
  319. A ``sensor.*`` qualifies when it has a unit or its state parses as a
  320. number. That drops the text sensors (``sensor.washing_machine_status``)
  321. that the card has no way to render as a value.
  322. """
  323. try:
  324. async with httpx.AsyncClient(timeout=self.timeout) as client:
  325. response = await client.get(
  326. f"{url.rstrip('/')}/api/states",
  327. headers={"Authorization": f"Bearer {token}"},
  328. )
  329. response.raise_for_status()
  330. entities = []
  331. search_lower = search.lower().strip() if search else None
  332. for entity in response.json():
  333. entity_id = entity.get("entity_id", "")
  334. domain = entity_id.split(".")[0] if "." in entity_id else ""
  335. if domain not in ("binary_sensor", "sensor"):
  336. continue
  337. attrs = entity.get("attributes", {})
  338. unit = attrs.get("unit_of_measurement")
  339. state = entity.get("state")
  340. if domain == "sensor" and not unit and as_float(state) is None:
  341. continue
  342. friendly_name = attrs.get("friendly_name") or entity_id
  343. if search_lower and (
  344. search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
  345. ):
  346. continue
  347. entities.append(
  348. {
  349. "entity_id": entity_id,
  350. "friendly_name": friendly_name,
  351. "state": state,
  352. "domain": domain,
  353. "device_class": attrs.get("device_class"),
  354. "unit_of_measurement": unit,
  355. }
  356. )
  357. return sorted(entities, key=lambda x: x["friendly_name"].lower())
  358. except Exception as e:
  359. logger.warning("Failed to list HA display entities: %s", e)
  360. return []
  361. async def fetch_states(self, entity_ids: list[str]) -> dict[str, dict | None]:
  362. """Read several entities in one pass, keyed by entity_id.
  363. One GET per entity over a shared client rather than a single
  364. ``/api/states`` sweep: the poller only ever wants a handful of bound
  365. entities, and pulling every state in the user's Home Assistant on a
  366. 15-second cadence is a lot of payload to throw away.
  367. A ``None`` value means that entity could not be read — the callers
  368. treat that as "no opinion" rather than as a state, so an unreachable
  369. Home Assistant never trips an alert or holds a print.
  370. """
  371. if not entity_ids:
  372. return {}
  373. if not self.base_url or not self.token:
  374. return dict.fromkeys(entity_ids)
  375. async with httpx.AsyncClient(timeout=self.timeout) as client:
  376. async def _one(entity_id: str) -> tuple[str, dict | None]:
  377. try:
  378. response = await client.get(
  379. f"{self.base_url}/api/states/{entity_id}",
  380. headers=self._headers(),
  381. )
  382. response.raise_for_status()
  383. return entity_id, response.json()
  384. except Exception as e:
  385. logger.debug("Failed to read HA entity %s: %s", entity_id, e)
  386. return entity_id, None
  387. results = await asyncio.gather(*(_one(e) for e in entity_ids))
  388. return dict(results)
  389. def as_float(value) -> float | None:
  390. """Parse a HA state to a number, or None for "unknown"/"unavailable"/text."""
  391. try:
  392. return float(value)
  393. except (TypeError, ValueError):
  394. return None
  395. # Singleton instance
  396. homeassistant_service = HomeAssistantService()