homeassistant.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. """Service for communicating with Home Assistant via REST API."""
  2. import logging
  3. from typing import TYPE_CHECKING
  4. import httpx
  5. if TYPE_CHECKING:
  6. from backend.app.models.smart_plug import SmartPlug
  7. logger = logging.getLogger(__name__)
  8. class HomeAssistantService:
  9. """Service for controlling Home Assistant entities via REST API."""
  10. def __init__(self, timeout: float = 10.0):
  11. self.timeout = timeout
  12. self.base_url: str = ""
  13. self.token: str = ""
  14. def configure(self, url: str, token: str):
  15. """Configure HA connection settings."""
  16. self.base_url = url.rstrip("/") if url else ""
  17. self.token = token or ""
  18. def _headers(self) -> dict:
  19. return {
  20. "Authorization": f"Bearer {self.token}",
  21. "Content-Type": "application/json",
  22. }
  23. async def get_status(self, plug: "SmartPlug") -> dict:
  24. """Get current state of HA entity.
  25. Returns dict with:
  26. - state: "ON" or "OFF" or None if unreachable
  27. - reachable: bool
  28. - device_name: str or None
  29. """
  30. if not self.base_url or not self.token:
  31. return {"state": None, "reachable": False, "device_name": None}
  32. try:
  33. async with httpx.AsyncClient(timeout=self.timeout) as client:
  34. response = await client.get(
  35. f"{self.base_url}/api/states/{plug.ha_entity_id}",
  36. headers=self._headers(),
  37. )
  38. response.raise_for_status()
  39. data = response.json()
  40. state_value = data.get("state", "").lower()
  41. # Normalize to ON/OFF
  42. if state_value == "on":
  43. state = "ON"
  44. elif state_value == "off":
  45. state = "OFF"
  46. else:
  47. state = None
  48. return {
  49. "state": state,
  50. "reachable": True,
  51. "device_name": data.get("attributes", {}).get("friendly_name"),
  52. }
  53. except Exception as e:
  54. logger.warning(f"Failed to get HA entity state for {plug.ha_entity_id}: {e}")
  55. return {"state": None, "reachable": False, "device_name": None}
  56. async def turn_on(self, plug: "SmartPlug") -> bool:
  57. """Turn on HA entity. Returns True if successful."""
  58. success = await self._call_service(plug, "turn_on")
  59. if success:
  60. logger.info(f"Turned ON HA entity '{plug.name}' ({plug.ha_entity_id})")
  61. return success
  62. async def turn_off(self, plug: "SmartPlug") -> bool:
  63. """Turn off HA entity. Returns True if successful."""
  64. success = await self._call_service(plug, "turn_off")
  65. if success:
  66. logger.info(f"Turned OFF HA entity '{plug.name}' ({plug.ha_entity_id})")
  67. return success
  68. async def toggle(self, plug: "SmartPlug") -> bool:
  69. """Toggle HA entity. Returns True if successful."""
  70. success = await self._call_service(plug, "toggle")
  71. if success:
  72. logger.info(f"Toggled HA entity '{plug.name}' ({plug.ha_entity_id})")
  73. return success
  74. async def _call_service(self, plug: "SmartPlug", action: str) -> bool:
  75. """Call HA service on entity."""
  76. if not self.base_url or not self.token or not plug.ha_entity_id:
  77. return False
  78. domain = plug.ha_entity_id.split(".")[0] # "switch", "light", etc.
  79. try:
  80. async with httpx.AsyncClient(timeout=self.timeout) as client:
  81. response = await client.post(
  82. f"{self.base_url}/api/services/{domain}/{action}",
  83. headers=self._headers(),
  84. json={"entity_id": plug.ha_entity_id},
  85. )
  86. response.raise_for_status()
  87. return True
  88. except Exception as e:
  89. logger.warning(f"Failed to {action} HA entity {plug.ha_entity_id}: {e}")
  90. return False
  91. async def get_energy(self, plug: "SmartPlug") -> dict | None:
  92. """Get energy data from HA sensor entities or switch attributes.
  93. First tries dedicated sensor entities if configured, then falls back
  94. to checking the switch entity's attributes.
  95. Returns dict with energy data or None if not available.
  96. """
  97. if not self.base_url or not self.token:
  98. return None
  99. power = None
  100. today = None
  101. total = None
  102. try:
  103. async with httpx.AsyncClient(timeout=self.timeout) as client:
  104. # Fetch power from dedicated sensor entity if configured
  105. if plug.ha_power_entity:
  106. power = await self._get_sensor_value(client, plug.ha_power_entity)
  107. # Fetch today's energy from dedicated sensor entity if configured
  108. if plug.ha_energy_today_entity:
  109. today = await self._get_sensor_value(client, plug.ha_energy_today_entity)
  110. # Fetch total energy from dedicated sensor entity if configured
  111. if plug.ha_energy_total_entity:
  112. total = await self._get_sensor_value(client, plug.ha_energy_total_entity)
  113. # Fallback: try switch entity attributes (original behavior)
  114. if power is None:
  115. response = await client.get(
  116. f"{self.base_url}/api/states/{plug.ha_entity_id}",
  117. headers=self._headers(),
  118. )
  119. response.raise_for_status()
  120. attrs = response.json().get("attributes", {})
  121. power = attrs.get("current_power_w") or attrs.get("power")
  122. if today is None:
  123. today = attrs.get("today_energy_kwh")
  124. if total is None:
  125. total = attrs.get("total_energy_kwh")
  126. if power is None:
  127. return None
  128. return {
  129. "power": power,
  130. "voltage": None,
  131. "current": None,
  132. "today": today,
  133. "total": total,
  134. "yesterday": None,
  135. "factor": None,
  136. "apparent_power": None,
  137. "reactive_power": None,
  138. }
  139. except Exception:
  140. return None
  141. async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:
  142. """Fetch numeric value from a HA sensor entity."""
  143. try:
  144. response = await client.get(
  145. f"{self.base_url}/api/states/{entity_id}",
  146. headers=self._headers(),
  147. )
  148. response.raise_for_status()
  149. state = response.json().get("state")
  150. if state and state not in ("unknown", "unavailable"):
  151. return float(state)
  152. except Exception:
  153. pass
  154. return None
  155. async def test_connection(self, url: str, token: str) -> dict:
  156. """Test connection to Home Assistant.
  157. Returns dict with:
  158. - success: bool
  159. - message: str or None (HA message on success)
  160. - error: str or None (error message on failure)
  161. """
  162. try:
  163. async with httpx.AsyncClient(timeout=self.timeout) as client:
  164. response = await client.get(
  165. f"{url.rstrip('/')}/api/",
  166. headers={"Authorization": f"Bearer {token}"},
  167. )
  168. response.raise_for_status()
  169. data = response.json()
  170. return {
  171. "success": True,
  172. "message": data.get("message", "Connected"),
  173. "error": None,
  174. }
  175. except httpx.HTTPStatusError as e:
  176. if e.response.status_code == 401:
  177. return {"success": False, "message": None, "error": "Invalid access token"}
  178. return {"success": False, "message": None, "error": f"HTTP {e.response.status_code}"}
  179. except httpx.TimeoutException:
  180. return {"success": False, "message": None, "error": "Connection timeout"}
  181. except httpx.ConnectError:
  182. return {"success": False, "message": None, "error": "Could not connect to Home Assistant"}
  183. except Exception as e:
  184. return {"success": False, "message": None, "error": str(e)}
  185. async def list_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
  186. """List available entities from HA.
  187. By default, returns switch/light/input_boolean domains.
  188. When search is provided, searches ALL entities by entity_id or friendly_name.
  189. Returns list of entity dicts with:
  190. - entity_id: str
  191. - friendly_name: str
  192. - state: str
  193. - domain: str
  194. """
  195. # Default domains for smart plug control
  196. default_domains = {"switch", "light", "input_boolean"}
  197. try:
  198. async with httpx.AsyncClient(timeout=self.timeout) as client:
  199. response = await client.get(
  200. f"{url.rstrip('/')}/api/states",
  201. headers={"Authorization": f"Bearer {token}"},
  202. )
  203. response.raise_for_status()
  204. entities = []
  205. search_lower = search.lower().strip() if search else None
  206. for entity in response.json():
  207. entity_id = entity.get("entity_id", "")
  208. domain = entity_id.split(".")[0] if "." in entity_id else ""
  209. friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id)
  210. # If searching, match against entity_id or friendly_name
  211. if search_lower:
  212. if search_lower not in entity_id.lower() and search_lower not in friendly_name.lower():
  213. continue
  214. else:
  215. # No search: filter to default domains only
  216. if domain not in default_domains:
  217. continue
  218. entities.append(
  219. {
  220. "entity_id": entity_id,
  221. "friendly_name": friendly_name,
  222. "state": entity.get("state"),
  223. "domain": domain,
  224. }
  225. )
  226. return sorted(entities, key=lambda x: x["friendly_name"].lower())
  227. except Exception as e:
  228. logger.warning(f"Failed to list HA entities: {e}")
  229. return []
  230. async def list_sensor_entities(self, url: str, token: str) -> list[dict]:
  231. """List available sensor entities for energy monitoring.
  232. Returns list of sensor entities with power/energy units.
  233. """
  234. try:
  235. async with httpx.AsyncClient(timeout=self.timeout) as client:
  236. response = await client.get(
  237. f"{url.rstrip('/')}/api/states",
  238. headers={"Authorization": f"Bearer {token}"},
  239. )
  240. response.raise_for_status()
  241. # Valid units for energy monitoring sensors
  242. power_units = {"W", "kW", "mW"}
  243. energy_units = {"kWh", "Wh", "MWh"}
  244. valid_units = power_units | energy_units
  245. entities = []
  246. for entity in response.json():
  247. entity_id = entity.get("entity_id", "")
  248. domain = entity_id.split(".")[0] if "." in entity_id else ""
  249. # Filter to sensor domain only
  250. if domain != "sensor":
  251. continue
  252. attrs = entity.get("attributes", {})
  253. unit = attrs.get("unit_of_measurement", "")
  254. # Only include sensors with power/energy units
  255. if unit in valid_units:
  256. entities.append(
  257. {
  258. "entity_id": entity_id,
  259. "friendly_name": attrs.get("friendly_name", entity_id),
  260. "state": entity.get("state"),
  261. "unit_of_measurement": unit,
  262. }
  263. )
  264. return sorted(entities, key=lambda x: x["friendly_name"].lower())
  265. except Exception as e:
  266. logger.warning(f"Failed to list HA sensor entities: {e}")
  267. return []
  268. # Singleton instance
  269. homeassistant_service = HomeAssistantService()