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