CONTROL-001: add safe HA automation handoff
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-14 16:21:57 +02:00
parent 77f328c4a8
commit b3cf68eade
25 changed files with 1083 additions and 69 deletions

View File

@@ -107,6 +107,18 @@ class HaClient:
)
return payload
def get_automation_config(self, automation_id: str) -> dict[str, object]:
if not automation_id or len(automation_id) > 120:
raise ValueError("Ungültige Automation-ID.")
payload = self._get_json(
f"/api/config/automation/config/{quote(automation_id, safe='')}"
)
if not isinstance(payload, dict):
raise HaUnexpectedPayloadError(
"Automation-Konfiguration hat ein unerwartetes Format."
)
return payload
def call_service(
self,
domain: str,

View File

@@ -26,3 +26,10 @@ class HaEntitySummary(BaseModel):
area_name: str | None = None
device_id: str | None = None
device_name: str | None = None
class HaAutomationSummary(BaseModel):
entity_id: str
config_id: str
friendly_name: str
enabled: bool

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from datetime import datetime, timedelta, timezone
from threading import RLock
from typing import Any
import logging
@@ -17,7 +18,7 @@ from app.ha.history import (
normalize_logbook_payload,
normalize_state_history_payload,
)
from app.ha.models import HaEntitySummary
from app.ha.models import HaAutomationSummary, HaEntitySummary
logger = logging.getLogger(__name__)
@@ -25,6 +26,11 @@ logger = logging.getLogger(__name__)
class HaReader:
def __init__(self, client: HaClient) -> None:
self._client = client
self._automation_cache: list[
tuple[HaAutomationSummary, dict[str, object]]
] = []
self._automation_cache_at: datetime | None = None
self._automation_cache_lock = RLock()
def read_entities(self) -> Sequence[HaEntitySummary]:
entities = self._client.list_entities()
@@ -112,6 +118,79 @@ class HaReader:
) -> Sequence[object]:
return self._client.call_service(domain, service, service_data)
def find_automations_for_entity(
self,
entity_id: str,
) -> list[HaAutomationSummary]:
current_states = {
raw_entity_id: item.get("state") == "on"
for item in self._client.list_entities()
if isinstance((raw_entity_id := item.get("entity_id")), str)
and raw_entity_id.startswith("automation.")
}
matches = [
summary.model_copy(
update={
"enabled": current_states.get(
summary.entity_id,
summary.enabled,
)
}
)
for summary, config in self._read_automation_configs()
if _contains_exact_value(config, entity_id)
]
return sorted(matches, key=lambda item: item.entity_id)
def _read_automation_configs(
self,
) -> list[tuple[HaAutomationSummary, dict[str, object]]]:
now = datetime.now(timezone.utc)
with self._automation_cache_lock:
if (
self._automation_cache_at is not None
and now - self._automation_cache_at < timedelta(minutes=10)
):
return list(self._automation_cache)
configs: list[tuple[HaAutomationSummary, dict[str, object]]] = []
for item in self._client.list_entities():
raw_entity_id = item.get("entity_id")
if not isinstance(raw_entity_id, str) or not raw_entity_id.startswith(
"automation."
):
continue
attributes = item.get("attributes")
if not isinstance(attributes, dict):
continue
config_id = attributes.get("id")
if not isinstance(config_id, str) or not config_id:
continue
try:
config = self._client.get_automation_config(config_id)
except (HaClientError, ValueError) as exc:
logger.warning(
"Automation config unavailable for %s: %s",
raw_entity_id,
exc,
)
continue
configs.append(
(
HaAutomationSummary(
entity_id=raw_entity_id,
config_id=config_id,
friendly_name=str(
attributes.get("friendly_name") or raw_entity_id
),
enabled=item.get("state") == "on",
),
config,
)
)
self._automation_cache = configs
self._automation_cache_at = now
return list(configs)
def _optional_str(value: object) -> str | None:
if value is None or value == "":
@@ -127,3 +206,13 @@ def _optional_datetime(value: object) -> datetime | None:
except ValueError:
return None
return parsed if parsed.tzinfo is not None else None
def _contains_exact_value(value: object, expected: str) -> bool:
if value == expected:
return True
if isinstance(value, dict):
return any(_contains_exact_value(item, expected) for item in value.values())
if isinstance(value, list):
return any(_contains_exact_value(item, expected) for item in value)
return False