M8-T17: add Home Assistant thermal entities

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 3eec701448
commit 39c11ae606
5 changed files with 683 additions and 8 deletions
+332 -1
View File
@@ -27,7 +27,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from datetime import UTC, datetime, timedelta
from typing import Any, Callable, Optional, Protocol
from sqlalchemy.orm import Session
@@ -74,6 +74,17 @@ class DeviceInfo:
their state is being published.
"""
availability_id: Optional[str] = None
"""Stable id used for the shared availability topic, when different from
this HA device's identity. A Meter is identified by its own UUID, while
its liveness comes from the source/channel feeding it.
"""
availability_getter: Optional[Callable[["Session"], bool]] = field(
default=None, repr=False
)
"""Return whether the source behind this device is currently usable."""
@dataclass
class ExposableEntity:
@@ -877,3 +888,323 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
# Register the energy cost provider at module load time.
register_provider(_energy_cost_provider)
# ---------------------------------------------------------------------------
# M8 source / meter / thermal-cost provider
# ---------------------------------------------------------------------------
_SOURCE_STALE_AFTER = timedelta(minutes=5)
def _utc_now() -> datetime:
"""Small clock seam for live-value bounds and deterministic tests."""
return datetime.now(UTC)
def _as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _source_is_online(source: Any, channel: Any | None = None) -> bool:
"""Do not turn an old cumulative value into a plausible live HA state."""
now = _utc_now()
if not source.enabled or source.status != "online" or source.last_seen_at is None:
return False
source_age = now - _as_utc(source.last_seen_at)
if not timedelta(0) <= source_age <= _SOURCE_STALE_AFTER:
return False
if channel is None:
return True
if channel.latest_at is None or channel.latest_quality not in {"valid", "unverifiable"}:
return False
channel_age = now - _as_utc(channel.latest_at)
return timedelta(0) <= channel_age <= _SOURCE_STALE_AFTER
def _dsmr_latest(session: Session, source_id: int, *, start: datetime | None = None,
end: datetime | None = None, not_after: datetime | None = None) -> Any:
"""Latest DSMR row in the source's (optionally bounded) cumulative domain."""
from app.models.energy import DsmrReading
from sqlalchemy import select
query = select(DsmrReading).where(DsmrReading.meter_source_id == source_id)
if start is not None:
query = query.where(DsmrReading.recorded_at >= start)
if end is not None:
query = query.where(DsmrReading.recorded_at < end)
if not_after is not None:
query = query.where(DsmrReading.recorded_at <= not_after)
return session.execute(query.order_by(DsmrReading.recorded_at.desc()).limit(1)).scalar_one_or_none()
def _dsmr_total(reading: Any) -> Any:
"""Return imported electricity total from a real DSMR telegram, or None."""
from decimal import Decimal, InvalidOperation
try:
payload = reading.payload or {}
return Decimal(str(payload["electricity_delivered_1"])) + Decimal(
str(payload["electricity_delivered_2"])
)
except (InvalidOperation, KeyError, TypeError, ValueError):
return None
def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
"""Expose accepted source snapshots and current M8 meters.
The provider intentionally reads the thermal service's public ``summarize``
result for money. Keeping formulas in ``meter_cost`` prevents HA from
becoming a second, subtly different billing implementation.
"""
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
from sqlalchemy import select
sources = session.execute(select(MeterSource)).scalars().all()
entities: list[ExposableEntity] = []
for source in sources:
source_info = DeviceInfo(
identifiers=("meter-source", source.uuid), name=source.name,
availability_id=source.uuid,
availability_getter=lambda sess, source_id=source.id: _source_online_by_id(sess, source_id),
)
entities.append(ExposableEntity(
key=f"source.{source.uuid}.online", component="binary_sensor", device=source_info,
device_class="connectivity", unit="", name=f"{source.name} Online",
value_getter=lambda sess, source_id=source.id: "ON" if _source_online_by_id(sess, source_id) else "OFF",
))
active_meters = session.execute(
select(Meter).where(
Meter.ended_at.is_(None), Meter.commodity.in_(("electricity", "heating", "hot_water"))
)
).scalars().all()
active_by_commodity = {meter.commodity: meter for meter in active_meters}
for meter in active_meters:
bound = session.execute(
select(MeterSourceBinding, MeterSourceChannel, MeterSource)
.join(MeterSourceChannel, MeterSourceChannel.id == MeterSourceBinding.channel_id)
.join(MeterSource, MeterSource.id == MeterSourceChannel.source_id)
.where(MeterSourceBinding.meter_id == meter.id, MeterSourceBinding.ended_at.is_(None))
).one_or_none()
if bound is None:
continue
binding, channel, source = bound
info = DeviceInfo(
identifiers=("meter", meter.uuid), name=meter.label,
# Keep this opaque and Meter-anchored. In particular, do not use a
# source UUID here: two channels of one source can be independently
# stale/invalid and must not overwrite each other's availability.
availability_id=f"meter-availability-{meter.uuid}",
availability_getter=lambda sess, source_id=source.id, channel_id=channel.id:
_bound_channel_online(sess, source_id, channel_id),
)
if meter.commodity == "electricity":
unit, device_class = "kWh", "energy"
elif meter.commodity == "heating":
unit, device_class = "GJ", "energy"
else:
unit, device_class = "", "volume"
for suffix, getter in (
("total", _meter_total_getter(binding.id, source.id, channel.id)),
("today", _meter_today_getter(binding.id, source.id, channel.id)),
):
entities.append(ExposableEntity(
key=f"meter.{meter.uuid}.{suffix}", component="sensor", device=info,
device_class=device_class, unit=unit,
name=f"{meter.label} {suffix.title()}", value_getter=getter,
state_class="total_increasing",
))
heating, hot_water = active_by_commodity.get("heating"), active_by_commodity.get("hot_water")
if heating is not None and hot_water is not None:
identity = ".".join(sorted((heating.uuid, hot_water.uuid)))
currency = _thermal_currency(session)
cost_info = DeviceInfo(
identifiers=("thermal-cost", identity), name="Thermal Energy Cost",
provides_availability=False,
)
labels = {
"heating": "Heating", "hot_water_heating": "Hot Water Heating", "water": "Water",
"water_tax": "Water Tax", "fixed": "Fixed", "all_in": "All-in",
}
for suffix, window in (("total", None), ("today", "today")):
for metric, label in labels.items():
entities.append(ExposableEntity(
key=f"thermal_cost.{identity}.{metric}_{suffix}", component="sensor", device=cost_info,
device_class="monetary", unit=currency, name=f"Thermal {label} {suffix.title()}",
value_getter=_thermal_cost_getter(metric, window),
state_class="total" if suffix == "total" else "total_increasing",
))
return entities
def _source_online_by_id(session: Session, source_id: int) -> bool:
from app.models.meter_source import MeterSource
source = session.get(MeterSource, source_id)
if source is not None and source.kind == "dsmr_mqtt":
now = _utc_now()
# Inspect the actual latest telegram before calculating freshness: a
# clock-skewed future telegram must not make an older one look live.
latest = _dsmr_latest(session, source_id)
if not source.enabled or latest is None:
return False
age = now - _as_utc(latest.recorded_at)
return timedelta(0) <= age <= _SOURCE_STALE_AFTER
return source is not None and _source_is_online(source)
def _bound_channel_online(session: Session, source_id: int, channel_id: int) -> bool:
from app.models.meter_source import MeterSource, MeterSourceChannel
source, channel = session.get(MeterSource, source_id), session.get(MeterSourceChannel, channel_id)
if source is not None and source.kind == "dsmr_mqtt":
return _source_online_by_id(session, source_id)
return source is not None and channel is not None and _source_is_online(source, channel)
def _meter_total_getter(binding_id: int, source_id: int, channel_id: int) -> Callable[[Session], Any]:
def _getter(session: Session) -> Any:
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
if not _bound_channel_online(session, source_id, channel_id):
return None
binding = session.get(MeterSourceBinding, binding_id)
if (
binding is None or binding.ended_at is not None or binding.channel_id != channel_id
or (meter := session.get(Meter, binding.meter_id)) is None or meter.ended_at is not None
):
return None
source = session.get(MeterSource, source_id)
channel = session.get(MeterSourceChannel, channel_id)
if source is None or channel is None:
return None
start = max(_as_utc(meter.started_at), _as_utc(binding.started_at))
# Both windows are half-open. The current records have no end, but
# retaining this form makes a future close fail safely.
end = min(
(value for value in (_as_utc(meter.ended_at) if meter.ended_at else None,
_as_utc(binding.ended_at) if binding.ended_at else None) if value is not None),
default=None,
)
now = _utc_now()
if source.kind == "dsmr_mqtt":
latest = _dsmr_latest(session, source_id, start=start, end=end, not_after=now)
if latest is None:
return None
return _dsmr_total(latest)
if channel.latest_at is None or channel.latest_quality not in {"valid", "unverifiable"}:
return None
latest_at = _as_utc(channel.latest_at)
if latest_at < start or latest_at > now or (end is not None and latest_at >= end):
return None
return channel.latest_value
return _getter
def _meter_today_getter(binding_id: int, source_id: int, channel_id: int) -> Callable[[Session], Any]:
def _getter(session: Session) -> Any:
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, WarmteLinkReading
from app.services import timezone as tz
from sqlalchemy import select
binding = session.get(MeterSourceBinding, binding_id)
if binding is None or binding.ended_at is not None or binding.channel_id != channel_id:
return None
meter = session.get(Meter, binding.meter_id)
if meter is None or meter.ended_at is not None:
return None
now = _utc_now()
day = (tz.local_now() - _TODAY_RESET_GRACE).date()
start = max(tz.local_midnight_utc(day), _as_utc(meter.started_at), _as_utc(binding.started_at))
bounds = [tz.local_midnight_utc(day + timedelta(days=1))]
bounds.extend(value for value in (
_as_utc(meter.ended_at) if meter.ended_at else None,
_as_utc(binding.ended_at) if binding.ended_at else None,
) if value is not None)
end = min(bounds)
source = session.get(MeterSource, source_id)
if source is None or not source.enabled:
return None
if source.kind != "dsmr_mqtt" and source.status != "online":
return None
if source.kind == "dsmr_mqtt":
first = _dsmr_latest(session, source_id, start=start, end=end, not_after=now)
if first is None:
return None
from app.models.energy import DsmrReading
from sqlalchemy import select as dsmr_select
rows = session.execute(dsmr_select(DsmrReading).where(
DsmrReading.meter_source_id == source_id, DsmrReading.recorded_at >= start,
DsmrReading.recorded_at < end,
DsmrReading.recorded_at <= now,
).order_by(DsmrReading.recorded_at)).scalars().all()
if len(rows) < 2:
return None
first_total, last_total = _dsmr_total(rows[0]), _dsmr_total(rows[-1])
if first_total is None or last_total is None:
return None
value = last_total - first_total
return value if value >= 0 else None
query = select(WarmteLinkReading).where(
WarmteLinkReading.channel_id == channel_id,
WarmteLinkReading.recorded_at >= start,
WarmteLinkReading.quality.in_(("valid", "unverifiable")),
)
if end is not None:
query = query.where(WarmteLinkReading.recorded_at < end)
query = query.where(WarmteLinkReading.recorded_at <= now)
readings = session.execute(query.order_by(WarmteLinkReading.recorded_at)).scalars().all()
if len(readings) < 2:
return None
value = readings[-1].value - readings[0].value
return value if value >= 0 else None
return _getter
def _thermal_currency(session: Session) -> str:
from app.services.contracts import active_contract_versions
versions = active_contract_versions(session, scope="thermal")
return versions[-1].contract.currency if versions else "EUR"
def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session], Any]:
def _getter(session: Session) -> Any:
from app.services import timezone as tz
from app.services.meter_cost import summarize
now = _utc_now()
from app.models.energy import Meter
meters = session.query(Meter).filter(
Meter.commodity.in_(("heating", "hot_water")), Meter.ended_at.is_(None)
).all()
if len(meters) != 2:
return None
epoch_start = max(_as_utc(m.started_at) for m in meters)
if window == "today":
day = (tz.local_now() - _TODAY_RESET_GRACE).date()
start = max(tz.local_midnight_utc(day), epoch_start)
# During the reset grace ``day`` is yesterday, whose local midnight
# remains the cap; otherwise do not summarize readings from later today.
end = min(tz.local_midnight_utc(day + timedelta(days=1)), now)
else:
# A combined thermal identity begins when its newest constituent
# meter epoch begins; including pre-swap rows would mix identities.
start, end = epoch_start, now
result = summarize(session, start, end, now=now)
if result["period_count"] == 0 and result["fixed_cost"] == 0:
return None
if metric == "fixed":
return result["fixed_cost"]
if metric == "all_in":
return result["total_cost"]
if metric == "water":
return result["breakdown"]["hot_water"]
if metric == "water_tax":
return result["breakdown"]["hot_water_tax"]
return result["breakdown"][metric]
return _getter
register_provider(_m8_energy_provider)