M8-T04: reconcile DSMR ingest from meter sources

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent 28486a83c7
commit 2e125dbd53
9 changed files with 1074 additions and 560 deletions
+168 -37
View File
@@ -20,8 +20,9 @@ Covers:
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from alembic import command
@@ -63,11 +64,9 @@ def _make_settings(
dsmr_ingest_enabled: bool = True,
dsmr_mqtt_topic: str = "dsmr/json",
):
s = MagicMock()
s.dsmr_sample_interval_s = dsmr_sample_interval_s
s.dsmr_ingest_enabled = dsmr_ingest_enabled
s.dsmr_mqtt_topic = dsmr_mqtt_topic
return s
del dsmr_ingest_enabled
from app.services.dsmr_ingest import DsmrSourceSnapshot
return DsmrSourceSnapshot(1, dsmr_mqtt_topic, "", dsmr_sample_interval_s)
# The reference telegram sample from §6.3 of the design doc.
@@ -240,6 +239,20 @@ def test_different_timestamps_are_independent(dsmr_db):
assert _count_readings(SessionLocal) == 2
def test_two_sources_can_store_the_same_timestamp_independently(dsmr_db):
"""The source identity, not timestamp alone, defines DSMR idempotency."""
_, SessionLocal = dsmr_db
first = _make_settings(dsmr_sample_interval_s=10)
from app.services.dsmr_ingest import DsmrSourceSnapshot
second = DsmrSourceSnapshot(2, "second/topic", "", 10)
_call_handle_message(_SAMPLE_TELEGRAM, first, SessionLocal)
_call_handle_message(_SAMPLE_TELEGRAM, second, SessionLocal)
rows = _get_readings(SessionLocal)
assert {row.meter_source_id for row in rows} == {1, 2}
def test_telegram_id_collision_does_not_drop_new_data(dsmr_db):
"""Regression: the telegram id overflows / gets reset to zero in DSMR firmware.
Two DISTINCT telegrams (different timestamps) that happen to share the SAME
@@ -259,12 +272,12 @@ def test_telegram_id_collision_does_not_drop_new_data(dsmr_db):
# ---------------------------------------------------------------------------
# 4. Missing source_id — still persisted with source_id=None
# 4. Missing telegram id — still persisted with telegram_id=None
# ---------------------------------------------------------------------------
def test_missing_id_persisted_with_source_id_none(dsmr_db):
"""Telegram without an 'id' field must be stored with source_id=None."""
def test_missing_id_persisted_with_telegram_id_none(dsmr_db):
"""Telegram without an 'id' field must be stored with telegram_id=None."""
engine, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
@@ -276,7 +289,7 @@ def test_missing_id_persisted_with_source_id_none(dsmr_db):
readings = session.scalars(select(DsmrReading)).all()
assert len(readings) == 1
assert readings[0].source_id is None
assert readings[0].telegram_id is None
# ---------------------------------------------------------------------------
@@ -419,80 +432,87 @@ def test_timestamp_with_utc_offset_suffix(dsmr_db):
@pytest.fixture()
def reset_tariff(monkeypatch):
"""Reset _current_tariff to None before and after each tariff test."""
from app.services import dsmr_ingest as _di
monkeypatch.setattr(_di, "_tariffs", {})
monkeypatch.setattr(_di, "_current_tariff", None)
yield
# monkeypatch auto-restores on teardown
def test_tariff_message_value_2_sets_tariff(reset_tariff):
"""Payload b'2' must set the current tariff to 2 (normal/peak)."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
handle_tariff_message(b"2")
assert get_current_tariff() == 2
handle_tariff_message(b"2", 1)
assert get_current_tariff(1) == 2
def test_tariff_message_value_1_sets_tariff(reset_tariff):
"""Payload b'1' must set the current tariff to 1 (dal/off-peak)."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
handle_tariff_message(b"1")
assert get_current_tariff() == 1
handle_tariff_message(b"1", 1)
assert get_current_tariff(1) == 1
def test_tariff_message_updates_from_2_to_1(reset_tariff):
"""Subsequent payloads must overwrite the previous tariff value."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
handle_tariff_message(b"2")
assert get_current_tariff() == 2
handle_tariff_message(b"1")
assert get_current_tariff() == 1
handle_tariff_message(b"2", 1)
assert get_current_tariff(1) == 2
handle_tariff_message(b"1", 1)
assert get_current_tariff(1) == 1
def test_tariffs_are_isolated_by_source(reset_tariff):
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
handle_tariff_message(b"1", 1)
handle_tariff_message(b"2", 2)
assert get_current_tariff(1) == 1
assert get_current_tariff(2) == 2
def test_tariff_message_strips_whitespace(reset_tariff):
"""Payloads with surrounding whitespace (e.g. b'2\\n') must be accepted."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
handle_tariff_message(b"2\n")
assert get_current_tariff() == 2
handle_tariff_message(b"2\n", 1)
assert get_current_tariff(1) == 2
handle_tariff_message(b" 1 ")
assert get_current_tariff() == 1
handle_tariff_message(b" 1 ", 1)
assert get_current_tariff(1) == 1
def test_tariff_message_invalid_non_numeric_does_not_update(reset_tariff):
"""Non-numeric payload must not update the tariff; previous value is preserved."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff, set_current_tariff
set_current_tariff(2)
handle_tariff_message(b"x")
set_current_tariff(1, 2)
handle_tariff_message(b"x", 1)
# Must NOT raise and must NOT change the tariff.
assert get_current_tariff() == 2
assert get_current_tariff(1) == 2
def test_tariff_message_invalid_empty_does_not_update(reset_tariff):
"""Empty payload must not update the tariff; previous value is preserved."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff, set_current_tariff
set_current_tariff(1)
handle_tariff_message(b"")
assert get_current_tariff() == 1
set_current_tariff(1, 1)
handle_tariff_message(b"", 1)
assert get_current_tariff(1) == 1
def test_tariff_message_out_of_range_value_does_not_update(reset_tariff):
"""Payload with out-of-range integer (not 1 or 2) must not update the tariff."""
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff, set_current_tariff
set_current_tariff(2)
handle_tariff_message(b"3") # 3 is not a valid tariff
assert get_current_tariff() == 2
set_current_tariff(1, 2)
handle_tariff_message(b"3", 1) # 3 is not a valid tariff
assert get_current_tariff(1) == 2
handle_tariff_message(b"0") # 0 is not a valid tariff
assert get_current_tariff() == 2
handle_tariff_message(b"0", 1) # 0 is not a valid tariff
assert get_current_tariff(1) == 2
def test_tariff_message_does_not_raise_on_any_input(reset_tariff):
@@ -501,4 +521,115 @@ def test_tariff_message_does_not_raise_on_any_input(reset_tariff):
# All of these must complete without raising.
for payload in (b"", b"x", b"99", b"\xff\xfe", b"None", b"2.0"):
handle_tariff_message(payload) # must not raise
handle_tariff_message(payload, 1) # must not raise
def test_electricity_tariff_resolves_current_binding_and_handoff(dsmr_db, reset_tariff):
"""Runtime tariffs remain isolated and are selected through the active binding."""
from datetime import timedelta
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
from app.services.dsmr_ingest import get_current_electricity_tariff, handle_tariff_message
_, SessionLocal = dsmr_db
now = datetime.now(timezone.utc).replace(microsecond=0)
with SessionLocal() as session:
meter = Meter(
label="electricity",
commodity="electricity",
started_at=now - timedelta(days=2),
ended_at=None,
reason="initial",
note=None,
created_at=now,
)
first = MeterSource(
name="first", kind="dsmr_mqtt", enabled=True, config={}, created_at=now, updated_at=now
)
second = MeterSource(
name="second", kind="dsmr_mqtt", enabled=True, config={}, created_at=now, updated_at=now
)
session.add_all([meter, first, second])
session.flush()
first_channel = MeterSourceChannel(
source_id=first.id, channel_key="electricity", label="first", unit="kWh",
created_at=now, updated_at=now
)
second_channel = MeterSourceChannel(
source_id=second.id, channel_key="electricity", label="second", unit="kWh",
created_at=now, updated_at=now
)
session.add_all([first_channel, second_channel])
session.flush()
handoff = now - timedelta(hours=1)
session.add_all([
MeterSourceBinding(meter_id=meter.id, channel_id=first_channel.id, started_at=now - timedelta(days=2), ended_at=handoff, created_at=now, updated_at=now),
MeterSourceBinding(meter_id=meter.id, channel_id=second_channel.id, started_at=handoff, ended_at=None, created_at=now, updated_at=now),
])
session.commit()
handle_tariff_message(b"1", first.id)
handle_tariff_message(b"2", second.id)
assert get_current_electricity_tariff(session, now - timedelta(days=3)) is None
assert get_current_electricity_tariff(session, handoff - timedelta(seconds=1)) == 1
assert get_current_electricity_tariff(session, handoff) == 2
assert get_current_electricity_tariff(session, now + timedelta(days=3)) == 2
def test_legacy_getter_resolves_runtime_tariff_through_current_binding(dsmr_db, reset_tariff):
"""The unchanged no-argument caller selects the bound source, not a global tariff."""
from datetime import timedelta
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
from app.services import dsmr_ingest
_, SessionLocal = dsmr_db
now = datetime.now(timezone.utc).replace(microsecond=0)
with SessionLocal() as session:
meter = Meter(
label="electricity", commodity="electricity", started_at=now - timedelta(days=1),
ended_at=None, reason="initial", note=None, created_at=now,
)
bound = MeterSource(
name="bound", kind="dsmr_mqtt", enabled=True, config={}, created_at=now, updated_at=now
)
other = MeterSource(
name="other", kind="dsmr_mqtt", enabled=True, config={}, created_at=now, updated_at=now
)
session.add_all([meter, bound, other])
session.flush()
channel = MeterSourceChannel(
source_id=bound.id, channel_key="electricity", label="bound", unit="kWh",
created_at=now, updated_at=now,
)
session.add(channel)
session.flush()
session.add(MeterSourceBinding(
meter_id=meter.id, channel_id=channel.id, started_at=now - timedelta(days=1), ended_at=None,
created_at=now, updated_at=now,
))
session.commit()
dsmr_ingest.handle_tariff_message(b"1", bound.id)
dsmr_ingest.handle_tariff_message(b"2", other.id)
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
assert dsmr_ingest.get_current_tariff() == 1
@pytest.mark.parametrize("replacement", ["disable", "delete", "config-change"])
def test_retained_source_handler_cannot_write_after_reconcile(dsmr_db, monkeypatch, replacement):
"""A callback fetched before disable/delete/reconfigure is rejected before DB access."""
from app.services import dsmr_ingest
from app.services.dsmr_ingest import DsmrSourceSnapshot
_, SessionLocal = dsmr_db
old = DsmrSourceSnapshot(1, "old", "", 10, broker_host="one.test")
if replacement in {"disable", "delete"}:
active = {}
else:
active = {1: DsmrSourceSnapshot(1, "new", "", 10, broker_host="changed.test")}
monkeypatch.setattr(dsmr_ingest, "_subscriptions", active)
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
dsmr_ingest.handle_captured_message(_payload(_SAMPLE_TELEGRAM), old)
assert _count_readings(SessionLocal) == 0