HA-008 HA-009: add discovery and history pipeline
Closes #17 Closes #18
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.dependencies import get_ha_reader
|
||||
from app.ha.discovery import DiscoveredEntity
|
||||
from app.ha.history import EntityHistorySeries
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
@@ -19,3 +22,43 @@ router = APIRouter(prefix="/v1", tags=["entities"])
|
||||
)
|
||||
def list_entities(ha_reader: HaReader = Depends(get_ha_reader)) -> List[HaEntitySummary]:
|
||||
return list(ha_reader.read_entities())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/discovery",
|
||||
summary="Home-Assistant-Entities klassifizieren",
|
||||
description="Klassifiziert Entities nach Lernrelevanz, Kontextquelle und Aktor-Rolle.",
|
||||
response_model=List[DiscoveredEntity],
|
||||
)
|
||||
def discovery(
|
||||
domain: List[str] | None = Query(default=None),
|
||||
learnable: bool | None = None,
|
||||
ha_reader: HaReader = Depends(get_ha_reader),
|
||||
) -> List[DiscoveredEntity]:
|
||||
return list(
|
||||
ha_reader.discover(
|
||||
domains=set(domain) if domain else None,
|
||||
learnable=learnable,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
summary="Numerische Home-Assistant-Historie lesen",
|
||||
description="Lädt und normalisiert numerische Zustände ausgewählter Entities.",
|
||||
response_model=List[EntityHistorySeries],
|
||||
)
|
||||
def history(
|
||||
entity_id: List[str] = Query(),
|
||||
start_time: datetime = Query(),
|
||||
end_time: datetime = Query(),
|
||||
ha_reader: HaReader = Depends(get_ha_reader),
|
||||
) -> List[EntityHistorySeries]:
|
||||
try:
|
||||
return list(ha_reader.read_history(entity_id, start_time, end_time))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
@@ -14,6 +17,9 @@ from app.ha.exceptions import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaClientSettings:
|
||||
@@ -35,9 +41,58 @@ class HaClient:
|
||||
self._session.close()
|
||||
|
||||
def list_entities(self) -> list[dict[str, object]]:
|
||||
payload = self._get_json("/api/states")
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError(
|
||||
"Antwort von Home Assistant hat unerwartetes Format."
|
||||
)
|
||||
return payload
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[object]:
|
||||
if not entity_ids:
|
||||
raise ValueError("Mindestens eine entity_id ist erforderlich.")
|
||||
if len(entity_ids) > 100:
|
||||
raise ValueError("Es können höchstens 100 Entities abgefragt werden.")
|
||||
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.")
|
||||
if start_time.tzinfo is None or end_time.tzinfo is None:
|
||||
raise ValueError("start_time und end_time müssen eine Zeitzone enthalten.")
|
||||
if end_time <= start_time:
|
||||
raise ValueError("end_time muss nach start_time liegen.")
|
||||
if (end_time - start_time).total_seconds() > _MAX_HISTORY_SECONDS:
|
||||
raise ValueError("History-Abfragen sind auf 31 Tage begrenzt.")
|
||||
|
||||
start = quote(start_time.isoformat(), safe=":+")
|
||||
payload = self._get_json(
|
||||
f"/api/history/period/{start}",
|
||||
params={
|
||||
"filter_entity_id": ",".join(entity_ids),
|
||||
"end_time": end_time.isoformat(),
|
||||
"minimal_response": "1",
|
||||
"no_attributes": "1",
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError(
|
||||
"History-Antwort von Home Assistant hat unerwartetes Format."
|
||||
)
|
||||
return payload
|
||||
|
||||
def _get_json(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
try:
|
||||
response = self._session.get(
|
||||
f"{self._settings.url.rstrip('/')}/api/states",
|
||||
f"{self._settings.url.rstrip('/')}{path}",
|
||||
params=params,
|
||||
timeout=self._settings.timeout_seconds,
|
||||
)
|
||||
except requests.Timeout as exc:
|
||||
@@ -69,9 +124,4 @@ class HaClient:
|
||||
"Antwort von Home Assistant ist kein gültiges JSON."
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError(
|
||||
"Antwort von Home Assistant hat unerwartetes Format."
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
184
app/ha/discovery.py
Normal file
184
app/ha/discovery.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.ha.models import HaEntitySummary
|
||||
|
||||
|
||||
class EntityRole(StrEnum):
|
||||
MEASUREMENT = "measurement"
|
||||
BINARY_CONTEXT = "binary_context"
|
||||
CONTEXT = "context"
|
||||
ACTUATOR = "actuator"
|
||||
UNSUPPORTED = "unsupported"
|
||||
|
||||
|
||||
class DiscoveredEntity(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
device_class: str | None = None
|
||||
state_class: str | None = None
|
||||
unit_of_measurement: str | None = None
|
||||
role: EntityRole
|
||||
learnable: bool
|
||||
reason: str
|
||||
|
||||
|
||||
_MEASUREMENT_CLASSES = frozenset({
|
||||
"apparent_power",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"current",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"power",
|
||||
"precipitation",
|
||||
"pressure",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
"sound_pressure",
|
||||
"speed",
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"volatile_organic_compounds",
|
||||
"voltage",
|
||||
"volume",
|
||||
"volume_flow_rate",
|
||||
"water",
|
||||
"weight",
|
||||
"wind_speed",
|
||||
})
|
||||
_BINARY_CONTEXT_CLASSES = frozenset({
|
||||
"door",
|
||||
"garage_door",
|
||||
"lock",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"opening",
|
||||
"presence",
|
||||
"problem",
|
||||
"safety",
|
||||
"smoke",
|
||||
"sound",
|
||||
"vibration",
|
||||
"window",
|
||||
})
|
||||
_ACTUATOR_DOMAINS = frozenset({
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"light",
|
||||
"lock",
|
||||
"scene",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"valve",
|
||||
})
|
||||
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
|
||||
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})
|
||||
_NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"})
|
||||
|
||||
|
||||
def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
|
||||
if entity.domain == "sensor" and (
|
||||
entity.state_class in _NUMERIC_STATE_CLASSES
|
||||
or entity.device_class in _MEASUREMENT_CLASSES
|
||||
or entity.unit_of_measurement is not None
|
||||
):
|
||||
return _result(
|
||||
entity,
|
||||
EntityRole.MEASUREMENT,
|
||||
learnable=True,
|
||||
reason="Numerischer Messsensor für Zeitreihen und Training.",
|
||||
)
|
||||
|
||||
if entity.domain == "binary_sensor" and entity.device_class in _BINARY_CONTEXT_CLASSES:
|
||||
return _result(
|
||||
entity,
|
||||
EntityRole.BINARY_CONTEXT,
|
||||
learnable=True,
|
||||
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
|
||||
)
|
||||
|
||||
if entity.domain in _CONTEXT_DOMAINS:
|
||||
learnable = entity.domain in _LEARNABLE_CONTEXT_DOMAINS
|
||||
return _result(
|
||||
entity,
|
||||
EntityRole.CONTEXT,
|
||||
learnable=learnable,
|
||||
reason=(
|
||||
"Kontextquelle für Training und Erklärungen."
|
||||
if learnable
|
||||
else "Kontextquelle ohne direkte Trainingsfreigabe."
|
||||
),
|
||||
)
|
||||
|
||||
if entity.domain in _ACTUATOR_DOMAINS:
|
||||
return _result(
|
||||
entity,
|
||||
EntityRole.ACTUATOR,
|
||||
learnable=False,
|
||||
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
|
||||
)
|
||||
|
||||
return _result(
|
||||
entity,
|
||||
EntityRole.UNSUPPORTED,
|
||||
learnable=False,
|
||||
reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.",
|
||||
)
|
||||
|
||||
|
||||
def discover_entities(
|
||||
entities: list[HaEntitySummary],
|
||||
domains: set[str] | None = None,
|
||||
learnable: bool | None = None,
|
||||
) -> list[DiscoveredEntity]:
|
||||
normalized_domains = {domain.strip().lower() for domain in domains or set() if domain.strip()}
|
||||
discovered = [classify_entity(entity) for entity in entities]
|
||||
return [
|
||||
entity
|
||||
for entity in discovered
|
||||
if (not normalized_domains or entity.domain in normalized_domains)
|
||||
and (learnable is None or entity.learnable is learnable)
|
||||
]
|
||||
|
||||
|
||||
def _result(
|
||||
entity: HaEntitySummary,
|
||||
role: EntityRole,
|
||||
*,
|
||||
learnable: bool,
|
||||
reason: str,
|
||||
) -> DiscoveredEntity:
|
||||
return DiscoveredEntity(
|
||||
entity_id=entity.entity_id,
|
||||
domain=entity.domain,
|
||||
device_class=entity.device_class,
|
||||
state_class=entity.state_class,
|
||||
unit_of_measurement=entity.unit_of_measurement,
|
||||
role=role,
|
||||
learnable=learnable,
|
||||
reason=reason,
|
||||
)
|
||||
91
app/ha/history.py
Normal file
91
app/ha/history.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.ha.exceptions import HaUnexpectedPayloadError
|
||||
|
||||
|
||||
class NumericHistoryPoint(BaseModel):
|
||||
timestamp: datetime
|
||||
value: float
|
||||
|
||||
|
||||
class EntityHistorySeries(BaseModel):
|
||||
entity_id: str
|
||||
points: list[NumericHistoryPoint]
|
||||
|
||||
|
||||
def normalize_history_payload(payload: object) -> list[EntityHistorySeries]:
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.")
|
||||
|
||||
normalized: list[EntityHistorySeries] = []
|
||||
for raw_series in payload:
|
||||
if not isinstance(raw_series, list):
|
||||
raise HaUnexpectedPayloadError("History-Serie muss eine Liste sein.")
|
||||
series = _normalize_series(raw_series)
|
||||
if series is not None:
|
||||
normalized.append(series)
|
||||
|
||||
return sorted(normalized, key=lambda item: item.entity_id)
|
||||
|
||||
|
||||
def _normalize_series(raw_series: list[object]) -> EntityHistorySeries | None:
|
||||
entity_id: str | None = None
|
||||
points: list[NumericHistoryPoint] = []
|
||||
|
||||
for raw_entry in raw_series:
|
||||
if not isinstance(raw_entry, dict):
|
||||
raise HaUnexpectedPayloadError("History-Eintrag muss ein Objekt sein.")
|
||||
|
||||
raw_entity_id = raw_entry.get("entity_id")
|
||||
if raw_entity_id is not None:
|
||||
if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
|
||||
raise HaUnexpectedPayloadError("History-Eintrag enthält ungültige entity_id.")
|
||||
if entity_id is not None and entity_id != raw_entity_id:
|
||||
raise HaUnexpectedPayloadError("History-Serie enthält mehrere Entities.")
|
||||
entity_id = raw_entity_id
|
||||
|
||||
raw_state = raw_entry.get("state")
|
||||
value = _finite_float(raw_state)
|
||||
if value is None:
|
||||
continue
|
||||
if entity_id is None:
|
||||
raise HaUnexpectedPayloadError("History-Serie enthält keine entity_id.")
|
||||
|
||||
raw_timestamp = raw_entry.get("last_changed") or raw_entry.get("last_updated")
|
||||
timestamp = _parse_timestamp(raw_timestamp)
|
||||
points.append(NumericHistoryPoint(timestamp=timestamp, value=value))
|
||||
|
||||
if entity_id is None or not points:
|
||||
return None
|
||||
|
||||
points.sort(key=lambda point: point.timestamp)
|
||||
return EntityHistorySeries(entity_id=entity_id, points=points)
|
||||
|
||||
|
||||
def _finite_float(value: object) -> float | None:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
if not isinstance(value, (str, int, float)):
|
||||
return None
|
||||
try:
|
||||
converted = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return converted if math.isfinite(converted) else None
|
||||
|
||||
|
||||
def _parse_timestamp(value: object) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise HaUnexpectedPayloadError("Numerischer History-Eintrag enthält keinen Zeitstempel.")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HaUnexpectedPayloadError("History-Eintrag enthält ungültigen Zeitstempel.") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.")
|
||||
return parsed
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -33,6 +36,22 @@ class HaReader:
|
||||
)
|
||||
return summaries
|
||||
|
||||
def discover(
|
||||
self,
|
||||
domains: set[str] | None = None,
|
||||
learnable: bool | None = None,
|
||||
) -> Sequence[DiscoveredEntity]:
|
||||
return discover_entities(list(self.read_entities()), domains=domains, learnable=learnable)
|
||||
|
||||
def read_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> Sequence[EntityHistorySeries]:
|
||||
payload = self._client.get_history(entity_ids, start_time, end_time)
|
||||
return normalize_history_payload(payload)
|
||||
|
||||
|
||||
def _optional_str(value: object) -> str | None:
|
||||
if value is None or value == "":
|
||||
|
||||
Reference in New Issue
Block a user