M8-T17: add Home Assistant thermal entities
This commit is contained in:
+332
-1
@@ -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 = "m³", "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)
|
||||
|
||||
Reference in New Issue
Block a user