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
+237 -1
View File
@@ -28,10 +28,12 @@ Coverage:
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from zoneinfo import ZoneInfo
import pytest
from alembic import command
@@ -2715,3 +2717,237 @@ def test_midnight_state_publish_no_raise_when_mqtt_disabled() -> None:
publish_states(sess) # must not raise
mock_mgr.publish.assert_not_called()
# ---------------------------------------------------------------------------
# M8-T17 source / thermal HA catalog
# ---------------------------------------------------------------------------
def _make_thermal_source_and_meter(session: Session, commodity: str, now: datetime) -> Any:
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
source = MeterSource(
name=f"{commodity} source", kind="warmtelink_serial", enabled=True, config={},
status="online", last_seen_at=now, last_error=None, created_at=now, updated_at=now,
)
session.add(source)
session.flush()
unit = {"electricity": "kWh", "heating": "GJ", "hot_water": ""}[commodity]
channel = MeterSourceChannel(
source_id=source.id, channel_key=f"accepted-{commodity}", label=commodity, unit=unit,
latest_value=Decimal("12.5"), latest_at=now, latest_quality="valid",
created_at=now, updated_at=now,
)
meter = Meter(label=f"{commodity} meter", commodity=commodity, started_at=now - timedelta(days=1),
ended_at=None, reason="initial", note=None, created_at=now)
session.add_all((channel, meter))
session.flush()
session.add(MeterSourceBinding(meter_id=meter.id, channel_id=channel.id,
started_at=meter.started_at, ended_at=None,
created_at=now, updated_at=now))
session.flush()
return source, channel, meter
def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) -> None:
"""D13: all new entities are catalogued with safe default toggles and units."""
from app.integrations.expose import build_catalog
now = datetime.now(timezone.utc)
with Session(energy_db) as session:
heating_source, _, heating = _make_thermal_source_and_meter(session, "heating", now)
_water_source, _, water = _make_thermal_source_and_meter(session, "hot_water", now)
_electricity_source, _, electricity = _make_thermal_source_and_meter(session, "electricity", now)
session.commit()
catalog = build_catalog(session)
entries = {entry.entity.key: entry for entry in catalog}
assert entries[f"source.{heating_source.uuid}.online"].entity.device_class == "connectivity"
assert entries[f"meter.{heating.uuid}.total"].entity.unit == "GJ"
assert entries[f"meter.{heating.uuid}.total"].entity.device_class == "energy"
assert entries[f"meter.{water.uuid}.total"].entity.unit == ""
assert entries[f"meter.{water.uuid}.total"].entity.device_class == "volume"
assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh"
assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy"
assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing"
thermal = [entry for key, entry in entries.items() if key.startswith("thermal_cost.")]
assert len(thermal) == 12
assert all(entry.enabled is False for entry in thermal)
assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal)
def test_m8_meter_getter_hides_stale_or_offline_source(energy_db) -> None:
from app.integrations.expose import build_catalog
now = datetime.now(timezone.utc)
with Session(energy_db) as session:
source, channel, meter = _make_thermal_source_and_meter(session, "heating", now)
session.commit()
entry = next(item for item in build_catalog(session) if item.entity.key == f"meter.{meter.uuid}.total")
assert entry.entity.value_getter(session) == channel.latest_value
source.status = "error"
assert entry.entity.value_getter(session) is None
source.status = "online"
source.last_seen_at = now - timedelta(minutes=6)
assert entry.entity.value_getter(session) is None
def test_m8_meter_today_uses_current_binding_and_never_invents_zero(energy_db) -> None:
"""A binding is a half-open cumulative epoch, not merely a channel filter."""
from app.integrations.expose import build_catalog
from app.models.meter_source import WarmteLinkReading
now = datetime.now(timezone.utc)
with Session(energy_db) as session:
_source, channel, meter = _make_thermal_source_and_meter(session, "heating", now)
binding = meter.source_bindings[0]
binding.started_at = now - timedelta(minutes=20)
session.add_all((
WarmteLinkReading(channel_id=channel.id, recorded_at=now - timedelta(minutes=30),
received_at=now, value=Decimal("100"), unit="GJ", quality="valid",
equipment_fingerprint="test"),
WarmteLinkReading(channel_id=channel.id, recorded_at=now - timedelta(minutes=15),
received_at=now, value=Decimal("110"), unit="GJ", quality="valid",
equipment_fingerprint="test"),
WarmteLinkReading(channel_id=channel.id, recorded_at=now - timedelta(minutes=5),
received_at=now, value=Decimal("115"), unit="GJ", quality="valid",
equipment_fingerprint="test"),
))
session.commit()
entity = next(item.entity for item in build_catalog(session)
if item.entity.key == f"meter.{meter.uuid}.today")
assert entity.value_getter(session) == Decimal("5")
session.query(WarmteLinkReading).filter(WarmteLinkReading.channel_id == channel.id).delete()
session.add(WarmteLinkReading(channel_id=channel.id, recorded_at=now - timedelta(minutes=5),
received_at=now, value=Decimal("115"), unit="GJ", quality="valid",
equipment_fingerprint="test"))
session.flush()
assert entity.value_getter(session) is None
def test_m8_dsmr_electricity_meter_reads_real_telegram_domain(energy_db) -> None:
"""DSMR does not populate channel latest fields: its telegram is authoritative."""
from app.integrations.expose import build_catalog
from app.models.energy import DsmrReading
now = datetime.now(timezone.utc)
with Session(energy_db) as session:
source, channel, meter = _make_thermal_source_and_meter(session, "electricity", now)
source.kind, source.status, source.last_seen_at = "dsmr_mqtt", "unknown", None
channel.latest_at, channel.latest_value, channel.latest_quality = None, None, None
session.add_all((
DsmrReading(meter_source_id=source.id, recorded_at=now - timedelta(minutes=2),
payload={"electricity_delivered_1": "100", "electricity_delivered_2": "20"}),
DsmrReading(meter_source_id=source.id, recorded_at=now - timedelta(minutes=1),
payload={"electricity_delivered_1": "103", "electricity_delivered_2": "22"}),
))
session.commit()
entries = {item.entity.key: item.entity for item in build_catalog(session)}
assert entries[f"source.{source.uuid}.online"].value_getter(session) == "ON"
assert entries[f"meter.{meter.uuid}.total"].value_getter(session) == Decimal("125")
assert entries[f"meter.{meter.uuid}.today"].value_getter(session) == Decimal("5")
def test_m8_warmtelink_future_snapshot_is_offline_but_today_uses_elapsed_points(energy_db) -> None:
"""Future channel snapshots never become current HA state or today's delta."""
from app.integrations.expose import build_catalog
from app.models.meter_source import WarmteLinkReading
from app.services import timezone as tz
now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc)
with Session(energy_db) as session:
source, channel, meter = _make_thermal_source_and_meter(session, "heating", now)
channel.latest_at, channel.latest_value = now + timedelta(minutes=1), Decimal("999")
session.add_all((
WarmteLinkReading(channel_id=channel.id, recorded_at=now - timedelta(minutes=2),
received_at=now, value=Decimal("100"), unit="GJ", quality="valid",
equipment_fingerprint="test"),
WarmteLinkReading(channel_id=channel.id, recorded_at=now,
received_at=now, value=Decimal("110"), unit="GJ", quality="valid",
equipment_fingerprint="test"),
WarmteLinkReading(channel_id=channel.id, recorded_at=now + timedelta(minutes=1),
received_at=now, value=Decimal("999"), unit="GJ", quality="valid",
equipment_fingerprint="test"),
))
session.commit()
with (
patch("app.integrations.expose._utc_now", return_value=now),
patch.object(tz, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")),
patch.object(tz, "local_now", return_value=now.astimezone(ZoneInfo("Europe/Amsterdam"))),
):
entries = {item.entity.key: item.entity for item in build_catalog(session)}
assert entries[f"meter.{meter.uuid}.total"].device.availability_getter(session) is False
assert entries[f"meter.{meter.uuid}.total"].value_getter(session) is None
assert entries[f"meter.{meter.uuid}.today"].value_getter(session) == Decimal("10")
def test_m8_dsmr_future_telegram_is_offline_and_excluded_from_today(energy_db) -> None:
"""DSMR's latest telegram is not fresh when it is in the future."""
from app.integrations.expose import build_catalog
from app.models.energy import DsmrReading
from app.services import timezone as tz
now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc)
local_tz = ZoneInfo("Europe/Amsterdam")
next_midnight = datetime(2026, 1, 15, 23, tzinfo=timezone.utc)
with Session(energy_db) as session:
source, channel, meter = _make_thermal_source_and_meter(session, "electricity", now)
source.kind, source.status, source.last_seen_at = "dsmr_mqtt", "unknown", None
channel.latest_at, channel.latest_value, channel.latest_quality = None, None, None
session.add_all((
DsmrReading(meter_source_id=source.id, recorded_at=now - timedelta(minutes=2),
payload={"electricity_delivered_1": "100", "electricity_delivered_2": "0"}),
DsmrReading(meter_source_id=source.id, recorded_at=now,
payload={"electricity_delivered_1": "110", "electricity_delivered_2": "0"}),
DsmrReading(meter_source_id=source.id, recorded_at=now + timedelta(minutes=1),
payload={"electricity_delivered_1": "999", "electricity_delivered_2": "0"}),
DsmrReading(meter_source_id=source.id, recorded_at=next_midnight,
payload={"electricity_delivered_1": "1000", "electricity_delivered_2": "0"}),
))
session.commit()
with (
patch("app.integrations.expose._utc_now", return_value=now),
patch.object(tz, "local_tz", return_value=local_tz),
patch.object(tz, "local_now", return_value=now.astimezone(local_tz)),
):
entries = {item.entity.key: item.entity for item in build_catalog(session)}
assert entries[f"source.{source.uuid}.online"].value_getter(session) == "OFF"
assert entries[f"meter.{meter.uuid}.total"].value_getter(session) is None
assert entries[f"meter.{meter.uuid}.today"].value_getter(session) == Decimal("10")
def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
"""A running local day must not include thermal cost rows from its future."""
from app.integrations.expose import build_catalog
from app.services import timezone as tz
now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc)
local_tz = ZoneInfo("Europe/Amsterdam")
captured: dict[str, datetime] = {}
result = {
"period_count": 1, "fixed_cost": Decimal("0"), "total_cost": Decimal("0"),
"breakdown": {key: Decimal("0") for key in (
"heating", "hot_water_heating", "hot_water", "hot_water_tax",
)},
}
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
captured.update(start=start, end=end, now=now)
return result
with Session(energy_db) as session:
_make_thermal_source_and_meter(session, "heating", now)
_make_thermal_source_and_meter(session, "hot_water", now)
session.commit()
with (
patch("app.integrations.expose._utc_now", return_value=now),
patch.object(tz, "local_tz", return_value=local_tz),
patch.object(tz, "local_now", return_value=now.astimezone(local_tz)),
patch("app.services.meter_cost.summarize", side_effect=summarize_spy),
):
entity = next(item.entity for item in build_catalog(session)
if item.entity.key.endswith(".heating_today"))
assert entity.value_getter(session) == Decimal("0")
assert captured["end"] == now
+18 -1
View File
@@ -15,7 +15,7 @@ Coverage:
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
@@ -1361,3 +1361,20 @@ def test_settings_payload_includes_ha_state_topic_prefix() -> None:
assert payload["ha_state_topic_prefix"] == "my_prefix", (
f"Expected 'my_prefix', got {payload['ha_state_topic_prefix']!r}"
)
def test_stale_m8_entities_includes_ended_electricity_meter(disco_db) -> None:
"""An electricity swap clears exactly the old Meter's two discovery configs."""
from app.models.energy import Meter
from app.services.ha_discovery import _stale_m8_entities
now = datetime.now(timezone.utc)
with Session(disco_db) as session:
old = Meter(label="old", commodity="electricity", started_at=now - timedelta(days=1),
ended_at=now, reason="meter_swap", note=None, created_at=now)
current = Meter(label="current", commodity="electricity", started_at=now,
ended_at=None, reason="meter_swap", note=None, created_at=now)
session.add_all((old, current))
session.commit()
keys = {entity.key for entity in _stale_m8_entities(session)}
assert keys == {f"meter.{old.uuid}.total", f"meter.{old.uuid}.today"}