Files
sillyhome-future/app/core/ha_client.py

164 lines
6.4 KiB
Python

from __future__ import annotations
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timezone
import httpx
import websockets
from app.core.models import EntityState, StateEvent
@dataclass(frozen=True)
class HaClientConfig:
core_url: str
token: str
websocket_url: str | None = None
timeout_seconds: float = 15.0
class FutureHaClient:
def __init__(self, config: HaClientConfig) -> None:
self._config = config
self._core_url = config.core_url.rstrip("/")
def read_states(self) -> list[EntityState]:
with httpx.Client(timeout=self._config.timeout_seconds) as client:
response = client.get(
f"{self._core_url}/api/states",
headers=self._headers,
)
response.raise_for_status()
payload = response.json()
result: list[EntityState] = []
for item in payload if isinstance(payload, list) else []:
if not isinstance(item, dict):
continue
entity_id = item.get("entity_id")
if not isinstance(entity_id, str) or "." not in entity_id:
continue
attributes = item.get("attributes")
attr = attributes if isinstance(attributes, dict) else {}
result.append(
EntityState(
entity_id=entity_id,
domain=entity_id.split(".", 1)[0],
state=item.get("state") if isinstance(item.get("state"), str) else None,
changed_at=_parse_datetime(item.get("last_changed")),
area_name=attr.get("area_name") if isinstance(attr.get("area_name"), str) else None,
device_id=attr.get("device_id") if isinstance(attr.get("device_id"), str) else None,
friendly_name=(
attr.get("friendly_name")
if isinstance(attr.get("friendly_name"), str)
else None
),
)
)
return result
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> None:
with httpx.Client(timeout=self._config.timeout_seconds) as client:
response = client.post(
f"{self._core_url}/api/services/{domain}/{service}",
headers=self._headers,
json=service_data,
)
response.raise_for_status()
async def listen_state_events(self) -> AsyncIterator[StateEvent]:
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
async with websockets.connect(websocket_url, ping_interval=None) as websocket:
auth_required = json.loads(await websocket.recv())
if auth_required.get("type") != "auth_required":
raise RuntimeError("Home Assistant websocket did not request authentication")
await websocket.send(json.dumps({"type": "auth", "access_token": self._config.token}))
auth_result = json.loads(await websocket.recv())
if auth_result.get("type") != "auth_ok":
raise RuntimeError("Home Assistant websocket authentication failed")
await websocket.send(
json.dumps({"id": 1, "type": "subscribe_events", "event_type": "state_changed"})
)
async for raw_message in websocket:
data = json.loads(raw_message)
if data.get("type") != "event":
continue
event = data.get("event")
if not isinstance(event, dict) or event.get("event_type") != "state_changed":
continue
event_data = event.get("data")
if not isinstance(event_data, dict):
continue
entity_id = event_data.get("entity_id")
new_state = event_data.get("new_state")
if not isinstance(entity_id, str) or not isinstance(new_state, dict):
continue
state = new_state.get("state")
attributes = new_state.get("attributes")
attr = attributes if isinstance(attributes, dict) else {}
yield StateEvent(
entity_id=entity_id,
new_state=state if isinstance(state, str) else None,
changed_at=_parse_datetime(
new_state.get("last_changed") or new_state.get("last_updated")
),
attributes={
key: value
for key, value in {
"area_name": attr.get("area_name"),
"device_id": attr.get("device_id"),
"friendly_name": attr.get("friendly_name"),
}.items()
if isinstance(value, str)
},
)
@property
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._config.token}",
"Content-Type": "application/json",
}
def service_for_state(domain: str, target_state: str | None) -> tuple[str, str] | None:
if target_state is None:
return None
if domain in {"light", "switch", "fan", "humidifier"}:
if target_state == "on":
return domain, "turn_on"
if target_state == "off":
return domain, "turn_off"
if domain == "cover":
if target_state in {"open", "opening"}:
return domain, "open_cover"
if target_state in {"closed", "closing"}:
return domain, "close_cover"
return None
def _default_websocket_url(core_url: str) -> str:
stripped = core_url.rstrip("/")
if stripped.endswith("/core"):
return stripped.replace("http://", "ws://").replace("https://", "wss://") + "/websocket"
return stripped.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
def _parse_datetime(value: object) -> datetime:
if not isinstance(value, str):
return datetime.now(timezone.utc)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return datetime.now(timezone.utc)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed