feat: Home Assistant integration — WebSocket connection, automation conditions, UI

Add full Home Assistant integration via WebSocket API:
- HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache
- HAManager: ref-counted runtime pool (like WeatherManager)
- HomeAssistantCondition: new automation trigger type matching entity states
- REST API: CRUD for HA sources + /test, /entities, /status endpoints
- /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators
- Frontend: HA Sources tab in Streams, condition type in automation editor
- Modal editor with host, token, SSL, entity filters
- websockets>=13.0 dependency added
This commit is contained in:
2026-03-27 22:42:48 +03:00
parent f3d07fc47f
commit 2153dde4b7
26 changed files with 1912 additions and 119 deletions
@@ -180,6 +180,34 @@ class StartupCondition(Condition):
return cls()
@dataclass
class HomeAssistantCondition(Condition):
"""Activate based on a Home Assistant entity state."""
condition_type: str = "home_assistant"
ha_source_id: str = "" # references HomeAssistantSource
entity_id: str = "" # e.g. "binary_sensor.front_door"
state: str = "" # expected state value
match_mode: str = "exact" # "exact" | "contains" | "regex"
def to_dict(self) -> dict:
d = super().to_dict()
d["ha_source_id"] = self.ha_source_id
d["entity_id"] = self.entity_id
d["state"] = self.state
d["match_mode"] = self.match_mode
return d
@classmethod
def from_dict(cls, data: dict) -> "HomeAssistantCondition":
return cls(
ha_source_id=data.get("ha_source_id", ""),
entity_id=data.get("entity_id", ""),
state=data.get("state", ""),
match_mode=data.get("match_mode", "exact"),
)
_CONDITION_MAP: Dict[str, Type[Condition]] = {
"always": AlwaysCondition,
"application": ApplicationCondition,
@@ -189,6 +217,7 @@ _CONDITION_MAP: Dict[str, Type[Condition]] = {
"mqtt": MQTTCondition,
"webhook": WebhookCondition,
"startup": StartupCondition,
"home_assistant": HomeAssistantCondition,
}
@@ -243,6 +272,10 @@ class Automation:
deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
tags=data.get("tags", []),
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
updated_at=datetime.fromisoformat(
data.get("updated_at", datetime.now(timezone.utc).isoformat())
),
)