HA-008 HA-009: add discovery and history pipeline
Closes #17 Closes #18
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern
|
||||||
|
- Validierter Zugriff auf die Home-Assistant-History-API
|
||||||
|
- Normalisierte, chronologisch sortierte numerische Zeitreihen über `/v1/history`
|
||||||
|
|
||||||
## 0.1.0 - 2026-06-13
|
## 0.1.0 - 2026-06-13
|
||||||
- Projektinitiierung
|
- Projektinitiierung
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ uvicorn app.main:app --reload
|
|||||||
- `http://127.0.0.1:8000/health` - Health-Check
|
- `http://127.0.0.1:8000/health` - Health-Check
|
||||||
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation
|
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation
|
||||||
- `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities
|
- `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities
|
||||||
|
- `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities
|
||||||
|
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
|
||||||
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
|
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
|
||||||
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
|
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import List
|
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.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.models import HaEntitySummary
|
||||||
from app.ha.reader import HaReader
|
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]:
|
def list_entities(ha_reader: HaReader = Depends(get_ha_reader)) -> List[HaEntitySummary]:
|
||||||
return list(ha_reader.read_entities())
|
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
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ from app.ha.exceptions import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@dataclass(frozen=True)
|
||||||
class HaClientSettings:
|
class HaClientSettings:
|
||||||
@@ -35,9 +41,58 @@ class HaClient:
|
|||||||
self._session.close()
|
self._session.close()
|
||||||
|
|
||||||
def list_entities(self) -> list[dict[str, object]]:
|
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:
|
try:
|
||||||
response = self._session.get(
|
response = self._session.get(
|
||||||
f"{self._settings.url.rstrip('/')}/api/states",
|
f"{self._settings.url.rstrip('/')}{path}",
|
||||||
|
params=params,
|
||||||
timeout=self._settings.timeout_seconds,
|
timeout=self._settings.timeout_seconds,
|
||||||
)
|
)
|
||||||
except requests.Timeout as exc:
|
except requests.Timeout as exc:
|
||||||
@@ -69,9 +124,4 @@ class HaClient:
|
|||||||
"Antwort von Home Assistant ist kein gültiges JSON."
|
"Antwort von Home Assistant ist kein gültiges JSON."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
if not isinstance(payload, list):
|
|
||||||
raise HaUnexpectedPayloadError(
|
|
||||||
"Antwort von Home Assistant hat unerwartetes Format."
|
|
||||||
)
|
|
||||||
|
|
||||||
return payload
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.ha.client import HaClient
|
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
|
from app.ha.models import HaEntitySummary
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +36,22 @@ class HaReader:
|
|||||||
)
|
)
|
||||||
return summaries
|
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:
|
def _optional_str(value: object) -> str | None:
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
|
|||||||
42
docs/ha_data.md
Normal file
42
docs/ha_data.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Home-Assistant-Datenpipeline
|
||||||
|
|
||||||
|
SillyHome Next trennt aktuelle Entity-Metadaten, Discovery und historische
|
||||||
|
Messwerte. Dadurch gelangen nur klassifizierte, geeignete Daten in spätere
|
||||||
|
Trainings- und Erklärungsprozesse.
|
||||||
|
|
||||||
|
## Entity Discovery
|
||||||
|
|
||||||
|
`GET /v1/discovery` klassifiziert Home-Assistant-Entities in:
|
||||||
|
|
||||||
|
- `measurement`: numerische Messsensoren, für Training geeignet
|
||||||
|
- `binary_context`: binäre Kontextsensoren wie Bewegung oder Anwesenheit
|
||||||
|
- `context`: Personen-, Wetter- und Standortkontext
|
||||||
|
- `actuator`: mögliche Automationsziele, nicht als Trainingssensor verwendet
|
||||||
|
- `unsupported`: noch nicht klassifizierte Entity-Typen
|
||||||
|
|
||||||
|
Optionale Query-Parameter:
|
||||||
|
|
||||||
|
- `domain=sensor` kann mehrfach angegeben werden
|
||||||
|
- `learnable=true|false` filtert nach Trainingsrelevanz
|
||||||
|
|
||||||
|
## Historische Daten
|
||||||
|
|
||||||
|
Historische Zustände werden über Home Assistants
|
||||||
|
`/api/history/period/<start>`-Schnittstelle geladen. Abfragen verlangen:
|
||||||
|
|
||||||
|
- mindestens eine Entity-ID, maximal 100
|
||||||
|
- zeitzonenbehaftete Start- und Endzeit
|
||||||
|
- ein Enddatum nach dem Startdatum
|
||||||
|
- maximal 31 Tage pro Abfrage
|
||||||
|
|
||||||
|
Die Normalisierung übernimmt nur endliche numerische Zustände. `unknown`,
|
||||||
|
`unavailable`, nichtnumerische Werte, `NaN` und unendliche Werte werden nicht
|
||||||
|
als Trainingsdaten verwendet. Ergebnisse werden je Entity chronologisch
|
||||||
|
sortiert.
|
||||||
|
|
||||||
|
## Datenschutz und Betrieb
|
||||||
|
|
||||||
|
Die Daten bleiben lokal. Home-Assistant-Tokens gehören ausschließlich in die
|
||||||
|
Umgebungskonfiguration und dürfen nicht protokolliert oder versioniert werden.
|
||||||
|
Die API sollte nur lokal oder hinter einem authentifizierenden Reverse Proxy
|
||||||
|
erreichbar sein.
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.ha.exceptions import HaTimeoutError
|
from app.ha.exceptions import HaTimeoutError
|
||||||
|
from app.ha.discovery import DiscoveredEntity, EntityRole
|
||||||
|
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
|
||||||
from app.ha.models import HaEntitySummary
|
from app.ha.models import HaEntitySummary
|
||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -15,6 +18,38 @@ class FakeHaReader(HaReader):
|
|||||||
def read_entities(self) -> Sequence[HaEntitySummary]:
|
def read_entities(self) -> Sequence[HaEntitySummary]:
|
||||||
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
|
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
|
||||||
|
|
||||||
|
def discover(
|
||||||
|
self,
|
||||||
|
domains: set[str] | None = None,
|
||||||
|
learnable: bool | None = None,
|
||||||
|
) -> Sequence[DiscoveredEntity]:
|
||||||
|
result = DiscoveredEntity(
|
||||||
|
entity_id="sensor.temperature",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="temperature",
|
||||||
|
role=EntityRole.MEASUREMENT,
|
||||||
|
learnable=True,
|
||||||
|
reason="Numerischer Messsensor für Zeitreihen und Training.",
|
||||||
|
)
|
||||||
|
if domains and result.domain not in domains:
|
||||||
|
return []
|
||||||
|
if learnable is not None and result.learnable is not learnable:
|
||||||
|
return []
|
||||||
|
return [result]
|
||||||
|
|
||||||
|
def read_history(
|
||||||
|
self,
|
||||||
|
entity_ids: list[str],
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> Sequence[EntityHistorySeries]:
|
||||||
|
return [
|
||||||
|
EntityHistorySeries(
|
||||||
|
entity_id=entity_ids[0],
|
||||||
|
points=[NumericHistoryPoint(timestamp=start_time, value=21.5)],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TimeoutHaReader(HaReader):
|
class TimeoutHaReader(HaReader):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -59,3 +94,44 @@ def test_entities_maps_ha_errors_without_leaking_details() -> None:
|
|||||||
response = client.get("/v1/entities")
|
response = client.get("/v1/entities")
|
||||||
assert response.status_code == 504
|
assert response.status_code == 504
|
||||||
assert response.json() == {"detail": "Home Assistant request timed out."}
|
assert response.json() == {"detail": "Home Assistant request timed out."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_filters_entities() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
app.state.ha_reader = FakeHaReader()
|
||||||
|
response = client.get("/v1/discovery?domain=sensor&learnable=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == [
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.temperature",
|
||||||
|
"domain": "sensor",
|
||||||
|
"device_class": "temperature",
|
||||||
|
"state_class": None,
|
||||||
|
"unit_of_measurement": None,
|
||||||
|
"role": "measurement",
|
||||||
|
"learnable": True,
|
||||||
|
"reason": "Numerischer Messsensor für Zeitreihen und Training.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_returns_normalized_series() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
app.state.ha_reader = FakeHaReader()
|
||||||
|
response = client.get(
|
||||||
|
"/v1/history",
|
||||||
|
params=[
|
||||||
|
("entity_id", "sensor.temperature"),
|
||||||
|
("start_time", "2026-06-01T00:00:00Z"),
|
||||||
|
("end_time", "2026-06-02T00:00:00Z"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == [
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.temperature",
|
||||||
|
"points": [{"timestamp": "2026-06-01T00:00:00Z", "value": 21.5}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
76
tests/ha/test_discovery.py
Normal file
76
tests/ha/test_discovery.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.ha.discovery import EntityRole, classify_entity, discover_entities
|
||||||
|
from app.ha.models import HaEntitySummary
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("entity", "role", "learnable"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="sensor.temperature",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="temperature",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_of_measurement="°C",
|
||||||
|
),
|
||||||
|
EntityRole.MEASUREMENT,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.motion",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="motion",
|
||||||
|
),
|
||||||
|
EntityRole.BINARY_CONTEXT,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
HaEntitySummary(entity_id="person.simon", domain="person"),
|
||||||
|
EntityRole.CONTEXT,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
HaEntitySummary(entity_id="light.living_room", domain="light"),
|
||||||
|
EntityRole.ACTUATOR,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
HaEntitySummary(entity_id="camera.driveway", domain="camera"),
|
||||||
|
EntityRole.UNSUPPORTED,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_classify_entity(
|
||||||
|
entity: HaEntitySummary,
|
||||||
|
role: EntityRole,
|
||||||
|
learnable: bool,
|
||||||
|
) -> None:
|
||||||
|
result = classify_entity(entity)
|
||||||
|
assert result.role is role
|
||||||
|
assert result.learnable is learnable
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_filters_domain_and_learnable() -> None:
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="sensor.temperature",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="temperature",
|
||||||
|
),
|
||||||
|
HaEntitySummary(entity_id="sensor.status", domain="sensor"),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.motion",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="motion",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = discover_entities(entities, domains={" SENSOR "}, learnable=True)
|
||||||
|
|
||||||
|
assert [item.entity_id for item in result] == ["sensor.temperature"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -68,4 +69,60 @@ def test_list_entities_rejects_invalid_json() -> None:
|
|||||||
def test_list_entities_rejects_non_list_payload() -> None:
|
def test_list_entities_rejects_non_list_payload() -> None:
|
||||||
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
|
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
|
||||||
with pytest.raises(HaUnexpectedPayloadError):
|
with pytest.raises(HaUnexpectedPayloadError):
|
||||||
client.list_entities()
|
client.list_entities()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_calls_home_assistant_history_api() -> None:
|
||||||
|
response = _response(payload=[[{"entity_id": "sensor.temperature", "state": "21.0"}]])
|
||||||
|
client = _client_with_response(response)
|
||||||
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
payload = client.get_history(["sensor.temperature"], start, end)
|
||||||
|
|
||||||
|
assert payload == [[{"entity_id": "sensor.temperature", "state": "21.0"}]]
|
||||||
|
client._session.get.assert_called_once() # type: ignore[attr-defined]
|
||||||
|
call = client._session.get.call_args # type: ignore[attr-defined]
|
||||||
|
assert "/api/history/period/2026-06-01T00:00:00+00:00" in call.args[0]
|
||||||
|
assert call.kwargs["params"]["filter_entity_id"] == "sensor.temperature"
|
||||||
|
assert call.kwargs["params"]["end_time"] == "2026-06-02T00:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("entity_ids", "start", "end"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
[],
|
||||||
|
datetime(2026, 6, 1, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 6, 2, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["sensor.temperature"],
|
||||||
|
datetime(2026, 6, 1),
|
||||||
|
datetime(2026, 6, 2, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["sensor.temperature"],
|
||||||
|
datetime(2026, 6, 2, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 6, 1, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["invalid entity"],
|
||||||
|
datetime(2026, 6, 1, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 6, 2, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["sensor.temperature"],
|
||||||
|
datetime(2026, 5, 1, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 6, 2, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_history_validates_request(
|
||||||
|
entity_ids: list[str],
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> None:
|
||||||
|
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
client.get_history(entity_ids, start, end)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.ha.client import HaClient, HaClientSettings
|
from app.ha.client import HaClient, HaClientSettings
|
||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
|
|
||||||
@@ -26,6 +28,22 @@ class FakeHaClient(HaClient):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
entity_ids: list[str],
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> list[object]:
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": entity_ids[0],
|
||||||
|
"state": "21.5",
|
||||||
|
"last_changed": start_time.isoformat(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_ha_reader_returns_summaries() -> None:
|
def test_ha_reader_returns_summaries() -> None:
|
||||||
reader = HaReader(FakeHaClient())
|
reader = HaReader(FakeHaClient())
|
||||||
@@ -35,3 +53,25 @@ def test_ha_reader_returns_summaries() -> None:
|
|||||||
assert domains == {"sensor", "light"}
|
assert domains == {"sensor", "light"}
|
||||||
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
|
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
|
||||||
assert sensor.unit_of_measurement == "°C"
|
assert sensor.unit_of_measurement == "°C"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_reader_discovers_learnable_sensors() -> None:
|
||||||
|
reader = HaReader(FakeHaClient())
|
||||||
|
|
||||||
|
discovered = reader.discover(learnable=True)
|
||||||
|
|
||||||
|
assert [entity.entity_id for entity in discovered] == ["sensor.temperature"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_reader_normalizes_history() -> None:
|
||||||
|
reader = HaReader(FakeHaClient())
|
||||||
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
history = reader.read_history(
|
||||||
|
["sensor.temperature"],
|
||||||
|
start,
|
||||||
|
datetime(2026, 6, 2, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert history[0].entity_id == "sensor.temperature"
|
||||||
|
assert history[0].points[0].value == 21.5
|
||||||
|
|||||||
92
tests/ha/test_history.py
Normal file
92
tests/ha/test_history.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.ha.exceptions import HaUnexpectedPayloadError
|
||||||
|
from app.ha.history import normalize_history_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None:
|
||||||
|
payload = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.temperature",
|
||||||
|
"state": "22.5",
|
||||||
|
"last_changed": "2026-06-01T12:15:00+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "21.0",
|
||||||
|
"last_changed": "2026-06-01T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.humidity",
|
||||||
|
"state": 45,
|
||||||
|
"last_updated": "2026-06-01T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
result = normalize_history_payload(payload)
|
||||||
|
|
||||||
|
assert [series.entity_id for series in result] == [
|
||||||
|
"sensor.humidity",
|
||||||
|
"sensor.temperature",
|
||||||
|
]
|
||||||
|
temperature = result[1]
|
||||||
|
assert [point.value for point in temperature.points] == [21.0, 22.5]
|
||||||
|
assert temperature.points[0].timestamp == datetime(
|
||||||
|
2026, 6, 1, 12, 0, tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_history_payload_skips_non_numeric_and_non_finite_states() -> None:
|
||||||
|
payload = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.temperature",
|
||||||
|
"state": state,
|
||||||
|
"last_changed": "2026-06-01T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
for state in ("unknown", "unavailable", "nan", "inf", "-inf", True, None)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
assert normalize_history_payload(payload) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
[{}],
|
||||||
|
[["invalid"]],
|
||||||
|
[[{"entity_id": "invalid", "state": "21", "last_changed": "2026-06-01"}]],
|
||||||
|
[[{"entity_id": "sensor.a", "state": "21", "last_changed": "invalid"}]],
|
||||||
|
[[{"state": "21", "last_changed": "2026-06-01T12:00:00+00:00"}]],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.a",
|
||||||
|
"state": "21",
|
||||||
|
"last_changed": "2026-06-01T12:00:00+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.b",
|
||||||
|
"state": "22",
|
||||||
|
"last_changed": "2026-06-01T12:01:00+00:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_history_payload_rejects_malformed_structure(payload: object) -> None:
|
||||||
|
with pytest.raises(HaUnexpectedPayloadError):
|
||||||
|
normalize_history_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_history_payload_accepts_empty_series() -> None:
|
||||||
|
assert normalize_history_payload([[]]) == []
|
||||||
Reference in New Issue
Block a user