92 lines
3.2 KiB
Python
92 lines
3.2 KiB
Python
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
|