homeassistant.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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 entity attributes.
  93. HA entities may have power attributes - check common patterns.
  94. Returns dict with energy data or None if not available.
  95. """
  96. if not self.base_url or not self.token:
  97. return None
  98. try:
  99. async with httpx.AsyncClient(timeout=self.timeout) as client:
  100. response = await client.get(
  101. f"{self.base_url}/api/states/{plug.ha_entity_id}",
  102. headers=self._headers(),
  103. )
  104. response.raise_for_status()
  105. attrs = response.json().get("attributes", {})
  106. # Common HA power monitoring attributes
  107. power = attrs.get("current_power_w") or attrs.get("power")
  108. if power is None:
  109. return None
  110. return {
  111. "power": power,
  112. "voltage": attrs.get("voltage"),
  113. "current": attrs.get("current"),
  114. "today": attrs.get("today_energy_kwh"),
  115. "total": attrs.get("total_energy_kwh"),
  116. "yesterday": None,
  117. "factor": None,
  118. "apparent_power": None,
  119. "reactive_power": None,
  120. }
  121. except Exception:
  122. return None
  123. async def test_connection(self, url: str, token: str) -> dict:
  124. """Test connection to Home Assistant.
  125. Returns dict with:
  126. - success: bool
  127. - message: str or None (HA message on success)
  128. - error: str or None (error message on failure)
  129. """
  130. try:
  131. async with httpx.AsyncClient(timeout=self.timeout) as client:
  132. response = await client.get(
  133. f"{url.rstrip('/')}/api/",
  134. headers={"Authorization": f"Bearer {token}"},
  135. )
  136. response.raise_for_status()
  137. data = response.json()
  138. return {
  139. "success": True,
  140. "message": data.get("message", "Connected"),
  141. "error": None,
  142. }
  143. except httpx.HTTPStatusError as e:
  144. if e.response.status_code == 401:
  145. return {"success": False, "message": None, "error": "Invalid access token"}
  146. return {"success": False, "message": None, "error": f"HTTP {e.response.status_code}"}
  147. except httpx.TimeoutException:
  148. return {"success": False, "message": None, "error": "Connection timeout"}
  149. except httpx.ConnectError:
  150. return {"success": False, "message": None, "error": "Could not connect to Home Assistant"}
  151. except Exception as e:
  152. return {"success": False, "message": None, "error": str(e)}
  153. async def list_entities(self, url: str, token: str) -> list[dict]:
  154. """List available switch/light entities from HA.
  155. Returns list of entity dicts with:
  156. - entity_id: str
  157. - friendly_name: str
  158. - state: str
  159. - domain: str
  160. """
  161. try:
  162. async with httpx.AsyncClient(timeout=self.timeout) as client:
  163. response = await client.get(
  164. f"{url.rstrip('/')}/api/states",
  165. headers={"Authorization": f"Bearer {token}"},
  166. )
  167. response.raise_for_status()
  168. entities = []
  169. for entity in response.json():
  170. entity_id = entity.get("entity_id", "")
  171. domain = entity_id.split(".")[0] if "." in entity_id else ""
  172. # Filter to switch, light, input_boolean domains
  173. if domain in ["switch", "light", "input_boolean"]:
  174. entities.append(
  175. {
  176. "entity_id": entity_id,
  177. "friendly_name": entity.get("attributes", {}).get("friendly_name", entity_id),
  178. "state": entity.get("state"),
  179. "domain": domain,
  180. }
  181. )
  182. return sorted(entities, key=lambda x: x["friendly_name"].lower())
  183. except Exception as e:
  184. logger.warning(f"Failed to list HA entities: {e}")
  185. return []
  186. # Singleton instance
  187. homeassistant_service = HomeAssistantService()