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