HA-008 HA-009: add discovery and history pipeline
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled

Closes #17

Closes #18
This commit is contained in:
2026-06-13 20:06:05 +02:00
parent 1fbed37126
commit 816a516106
13 changed files with 783 additions and 8 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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

View File

@@ -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
View 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.

View File

@@ -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}],
}
]

View 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"]

View File

@@ -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
@@ -69,3 +70,59 @@ 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)

View File

@@ -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
View 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([[]]) == []