"""Pure timestamp validation for reported sensor snapshots.""" from __future__ import annotations from collections import deque from datetime import datetime from math import isfinite def freshness(reported: datetime | None, now: datetime, max_age_seconds: float) -> dict: """Use report time, not value-change time; reject unknown/future clocks.""" if not isfinite(max_age_seconds) or max_age_seconds <= 0: raise ValueError("Freshness limit must be positive and finite") if not isinstance(reported, datetime) or reported.tzinfo is None: return {"status": "invalid_timestamp", "age_seconds": None} age = (now - reported).total_seconds() if age < 0: return {"status": "future_timestamp", "age_seconds": round(age, 1)} return {"status": "stale" if age > max_age_seconds else "valid", "age_seconds": round(age, 1)} class ReportAgeHistory: """Largest report age seen at polls per entity over the last 24 hours. Helps choose freshness limits: a limit below a sensor's normal maximum marks it stale during ordinary operation. Hourly buckets keep memory bounded. """ def __init__(self) -> None: self._buckets: dict[str, deque[list[float]]] = {} def observe(self, entity_id: str, now: datetime, age_seconds: float) -> float: hour = now.timestamp() // 3600 buckets = self._buckets.setdefault(entity_id, deque(maxlen=25)) if buckets and buckets[-1][0] == hour: buckets[-1][1] = max(buckets[-1][1], age_seconds) else: buckets.append([hour, age_seconds]) return max(age for start, age in buckets if hour - 24 < start <= hour) # How much an input may move while a prediction is computed before the result no # longer describes the current situation. Below these, changes are sensor noise. INPUT_TOLERANCES = { "temperature": 0.25, # °C, zone sensors and outdoor sources "power": 50.0, # W, heat-source sensors "wind": 1.0, # m/s "wind_gust": 2.0, # m/s "wind_direction": 20.0, # degrees, compared around the circle } def inputs_differ(before: dict, after: dict) -> list: """Inputs whose change is meaningful: keys are (kind, entity), values normalized. Kinds without a tolerance (relay, thermostat mode and target, window states, configuration revision) must match exactly; a value appearing or disappearing (for example a sensor becoming unavailable) always counts. """ changed = [] for key in sorted(set(before) | set(after), key=str): old, new = before.get(key), after.get(key) tolerance = INPUT_TOLERANCES.get(key[0]) if old is None or new is None or tolerance is None: if old != new: changed.append(key) continue difference = abs(new - old) if key[0] == "wind_direction": difference = min(difference, 360.0 - difference) if difference > tolerance: changed.append(key) return changed