M8-T04: reconcile DSMR ingest from meter sources
This commit is contained in:
+168
-37
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user