Add native HA integration and dashboard
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.core.decision import DecisionEngineV2
|
||||
from app.core.handoff import HandoffMatrix
|
||||
from app.core.models import (
|
||||
AuditEvent,
|
||||
ControlProfile,
|
||||
Decision,
|
||||
EntityState,
|
||||
LearningProfile,
|
||||
StateEvent,
|
||||
@@ -31,7 +33,12 @@ class EventCore:
|
||||
self._decision = DecisionEngineV2()
|
||||
self._handoff = HandoffMatrix()
|
||||
|
||||
def process_state_event(self, event: StateEvent) -> list[AuditEvent]:
|
||||
def process_state_event(
|
||||
self,
|
||||
event: StateEvent,
|
||||
*,
|
||||
execute: Callable[[Decision], bool] | None = None,
|
||||
) -> list[AuditEvent]:
|
||||
runtime = self._stores.runtime()
|
||||
learning = self._stores.learning()
|
||||
control = self._stores.control()
|
||||
@@ -69,6 +76,8 @@ class EventCore:
|
||||
learning=learning_profile,
|
||||
control=control_profile,
|
||||
)
|
||||
if callable(execute) and decision.allowed and not decision.dry_run:
|
||||
decision = _execute_decision(decision, execute)
|
||||
audit.append(
|
||||
AuditEvent(
|
||||
event_id=_event_id("decision"),
|
||||
@@ -87,3 +96,17 @@ class EventCore:
|
||||
def _event_id(prefix: str) -> str:
|
||||
return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
|
||||
def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -> Decision:
|
||||
try:
|
||||
result = execute(decision)
|
||||
except Exception as exc:
|
||||
return decision.model_copy(
|
||||
update={
|
||||
"executed": False,
|
||||
"allowed": False,
|
||||
"reason": f"Ausfuehrung fehlgeschlagen: {exc}",
|
||||
"blockers": [*decision.blockers, str(exc)],
|
||||
}
|
||||
)
|
||||
return decision.model_copy(update={"executed": bool(result)})
|
||||
|
||||
163
app/core/ha_client.py
Normal file
163
app/core/ha_client.py
Normal file
@@ -0,0 +1,163 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user