initial commit v0.2.1

This commit is contained in:
buzera committed 2026-10-26 17:34:32 +02:00
commit 2b9a10979b
19 files changed
+1665

No files matched your search

@@ -0,0 +1,104 @@
"""Read-only weather source selection and unit normalization.
Pure-Python helpers so source and unit behavior can be tested without HAOS.
Optional weather variables are observability inputs in v0.2, not MPC features.
"""
from __future__ import annotations
from math import isfinite
from .const import CONF_OUTSIDE, WEATHER_SOURCES
LIMITS = {
"temperature": (-45.0, 55.0),
"feels_like": (-65.0, 65.0),
"wind": (0.0, 90.0), # m/s
"clouds": (0.0, 100.0), # percentage
"humidity": (0.0, 100.0), # percentage
"pressure": (800.0, 1100.0), # hPa
"visibility": (0.0, 100000.0), # metres
"rain": (0.0, 500.0), # mm (sampling period defined by upstream sensor)
"snow": (0.0, 500.0),
}
def sources_for(config: dict, metric: str) -> list[str]:
"""Return ordered de-duplicated entity IDs, including v0.1 migration."""
key = WEATHER_SOURCES[metric]
sources = config.get(key)
if sources is None and metric == "temperature":
sources = config.get(CONF_OUTSIDE)
if isinstance(sources, str):
sources = [sources]
if not isinstance(sources, (list, tuple)):
return []
return list(dict.fromkeys(s for s in sources if isinstance(s, str) and s.startswith("sensor.")))
def normalize(metric: str, raw: object, unit: str | None = None) -> float | None:
"""Validate and normalize common HA units; reject unknown/mismatched units."""
try:
value = float(raw)
except (TypeError, ValueError):
return None
if not isfinite(value):
return None
unit = (unit or "").strip().lower().replace(" ", "")
if metric in ("temperature", "feels_like"):
if unit in ("°f", "f", "fahrenheit"):
value = (value - 32.0) * 5.0 / 9.0
elif unit not in ("", "°c", "c", "celsius"):
return None
elif metric == "wind":
if unit in ("km/h", "kmh", "kph"):
value /= 3.6
elif unit in ("mph",):
value *= 0.44704
elif unit not in ("", "m/s", "mps"):
return None
elif metric == "pressure":
if unit == "pa":
value /= 100.0
elif unit == "kpa":
value *= 10.0
elif unit in ("inhg",):
value *= 33.8638867
elif unit not in ("", "hpa", "mbar"):
return None
elif metric == "visibility":
if unit in ("km",):
value *= 1000.0
elif unit not in ("", "m"):
return None
elif metric in ("humidity", "clouds"):
if unit not in ("", "%"):
return None
elif metric in ("rain", "snow"):
if unit not in ("", "mm", "mm/h"):
return None
lo, hi = LIMITS[metric]
return round(value, 4) if lo <= value <= hi else None
def choose_weather_sources(
config: dict, states: dict[str, tuple[object, str | None]]
) -> tuple[dict[str, float | None], dict[str, str | None], dict[str, dict[str, float]]]:
"""Choose first valid sensor per metric, expose alternatives for comparisons.
`states` maps entity ID -> (value, unit). In HA these are current state snapshots.
"""
values: dict[str, float | None] = {}
active: dict[str, str | None] = {}
comparisons: dict[str, dict[str, float]] = {}
for metric in WEATHER_SOURCES:
valid: dict[str, float] = {}
for entity_id in sources_for(config, metric):
raw, unit = states.get(entity_id, (None, None))
value = normalize(metric, raw, unit)
if value is not None:
valid[entity_id] = value
comparisons[metric] = valid
active[metric] = next(iter(valid), None)
values[metric] = valid[active[metric]] if active[metric] else None
return values, active, comparisons