ACT-001: actuator-first sensor lifecycle
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -83,6 +84,32 @@ class HaClient:
|
||||
)
|
||||
return payload
|
||||
|
||||
def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
|
||||
if not entity_ids:
|
||||
return {}
|
||||
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
|
||||
raise ValueError("entity_id enthält ein ungültiges Format.")
|
||||
template = _metadata_template(entity_ids)
|
||||
rendered = self._post_text("/api/template", {"template": template})
|
||||
try:
|
||||
payload = json.loads(rendered)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HaUnexpectedPayloadError("Entity-Metadaten konnten nicht gelesen werden.") from exc
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError("Entity-Metadaten haben ein unerwartetes Format.")
|
||||
result: dict[str, dict[str, str | None]] = {}
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
raise HaUnexpectedPayloadError("Entity-Metadaten haben ein unerwartetes Format.")
|
||||
entity_id = item.get("entity_id")
|
||||
if not isinstance(entity_id, str) or "." not in entity_id:
|
||||
raise HaUnexpectedPayloadError("Entity-Metadaten enthalten ungültige entity_id.")
|
||||
result[entity_id] = {
|
||||
key: _optional_string(item.get(key))
|
||||
for key in ("area_id", "area_name", "device_id", "device_name")
|
||||
}
|
||||
return result
|
||||
|
||||
def _get_json(
|
||||
self,
|
||||
path: str,
|
||||
@@ -125,3 +152,57 @@ class HaClient:
|
||||
) from exc
|
||||
|
||||
return payload
|
||||
|
||||
def _post_text(self, path: str, payload: dict[str, str]) -> str:
|
||||
try:
|
||||
response = self._session.post(
|
||||
f"{self._settings.url.rstrip('/')}{path}",
|
||||
json=payload,
|
||||
timeout=self._settings.timeout_seconds,
|
||||
)
|
||||
except requests.Timeout as exc:
|
||||
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
|
||||
except requests.RequestException as exc:
|
||||
raise HaHttpError(
|
||||
getattr(getattr(exc, "response", None), "status_code", 502),
|
||||
"Netzwerkfehler beim Zugriff auf Home Assistant.",
|
||||
) from exc
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
raise HaAuthError(
|
||||
response.status_code,
|
||||
"Authentifizierung bei Home Assistant fehlgeschlagen.",
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
|
||||
return response.text
|
||||
|
||||
|
||||
def _metadata_template(entity_ids: list[str]) -> str:
|
||||
ids = json.dumps(entity_ids, ensure_ascii=True)
|
||||
return (
|
||||
"{% set ids = "
|
||||
f"{ids}"
|
||||
" %}["
|
||||
"{% for entity_id in ids %}"
|
||||
"{% set device = device_id(entity_id) %}"
|
||||
"{{ "
|
||||
"{"
|
||||
"\"entity_id\": entity_id,"
|
||||
"\"area_id\": area_id(entity_id),"
|
||||
"\"area_name\": area_name(entity_id),"
|
||||
"\"device_id\": device,"
|
||||
"\"device_name\": device_attr(device, 'name') if device else none"
|
||||
"}"
|
||||
" | tojson }}"
|
||||
"{% if not loop.last %},{% endif %}"
|
||||
"{% endfor %}]"
|
||||
)
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
@@ -17,3 +17,8 @@ class HaEntitySummary(BaseModel):
|
||||
state_class: str | None = None
|
||||
device_class: str | None = None
|
||||
unit_of_measurement: str | None = None
|
||||
friendly_name: str | None = None
|
||||
area_id: str | None = None
|
||||
area_name: str | None = None
|
||||
device_id: str | None = None
|
||||
device_name: str | None = None
|
||||
|
||||
@@ -3,12 +3,17 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from app.ha.exceptions import HaClientError
|
||||
|
||||
from app.ha.client import HaClient
|
||||
from app.ha.discovery import DiscoveredEntity, discover_entities
|
||||
from app.ha.history import EntityHistorySeries, normalize_history_payload
|
||||
from app.ha.models import HaEntitySummary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HaReader:
|
||||
def __init__(self, client: HaClient) -> None:
|
||||
@@ -16,6 +21,16 @@ class HaReader:
|
||||
|
||||
def read_entities(self) -> Sequence[HaEntitySummary]:
|
||||
entities = self._client.list_entities()
|
||||
entity_ids = [
|
||||
raw_entity_id
|
||||
for item in entities
|
||||
if isinstance((raw_entity_id := item.get("entity_id")), str) and "." in raw_entity_id
|
||||
]
|
||||
try:
|
||||
metadata_by_entity = self._client.list_entity_metadata(entity_ids)
|
||||
except (HaClientError, ValueError) as exc:
|
||||
logger.warning("HA metadata enrichment skipped: %s", exc)
|
||||
metadata_by_entity = {}
|
||||
summaries: list[HaEntitySummary] = []
|
||||
for item in entities:
|
||||
raw_entity_id = item.get("entity_id")
|
||||
@@ -25,6 +40,7 @@ class HaReader:
|
||||
domain = entity_id.split(".", 1)[0]
|
||||
raw_attributes = item.get("attributes") or {}
|
||||
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
|
||||
metadata = metadata_by_entity.get(entity_id, {})
|
||||
summaries.append(
|
||||
HaEntitySummary(
|
||||
entity_id=entity_id,
|
||||
@@ -32,6 +48,15 @@ class HaReader:
|
||||
state_class=_optional_str(attributes.get("state_class")),
|
||||
device_class=_optional_str(attributes.get("device_class")),
|
||||
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
|
||||
friendly_name=_optional_str(attributes.get("friendly_name")),
|
||||
area_id=_optional_str(metadata.get("area_id") or attributes.get("area_id")),
|
||||
area_name=_optional_str(metadata.get("area_name") or attributes.get("area_name")),
|
||||
device_id=_optional_str(metadata.get("device_id") or attributes.get("device_id")),
|
||||
device_name=_optional_str(
|
||||
metadata.get("device_name")
|
||||
or attributes.get("device_name")
|
||||
or attributes.get("device")
|
||||
),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
Reference in New Issue
Block a user