Files
home-automation/tests/test_dsmr_ingest.py
T
tliu93 9bc46f8d2d expose: buy/sell_price_now follow the live dual-tariff slot
Subscribe the separate DSMR tariff topic (dsmr/meter-stats/electricity_tariff,
1=dal/off-peak, 2=normal/peak), keep the latest slot in memory, and make the
manual-contract buy/sell_price_now sensors show the matching tariff's price
(tariff 1 -> dal, 2/unknown -> normal). Tibber (single 15-min price) unchanged.
Billing is untouched (register-split already handles tariffs correctly).

- new config dsmr_tariff_topic; subscribe managed by apply_dsmr_subscription
  (restart-free on config save). In-memory tariff is lock-guarded.
- import_cost_total / export_revenue_total cumulative getters unchanged.
2026-06-24 15:39:21 +02:00

505 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for M6-T06: DSMR ingest service (handle_message).
All tests use a temporary SQLite database (upgraded to the full Alembic head)
so that the real DsmrReading model/constraints are exercised.
Covers:
- Sample telegram with second=00 (on interval) is persisted as a full-frame blob.
- Sample telegram with second=48 (off interval, 10s default) is discarded.
- Telegram with second=00 → all fields including gas (extra_device_*) and null
phases are stored verbatim.
- Duplicate source_id → not re-inserted (idempotency).
- source_id absent → row still inserted with source_id=None.
- dsmr_sample_interval_s=0 → no ZeroDivisionError; every telegram is persisted.
- Invalid JSON → silently discarded (no exception propagated).
- Missing 'timestamp' field → silently discarded.
- Unparseable 'timestamp' → silently discarded.
- Handler exception (e.g. bad DB) → does not propagate out of handle_message.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.models.energy import DsmrReading
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _alembic_config(database_url: str) -> Config:
cfg = Config("alembic_app.ini")
cfg.set_main_option("sqlalchemy.url", database_url)
return cfg
@pytest.fixture()
def dsmr_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to the Alembic head; yields (engine, session_factory)."""
db_path = tmp_path / "dsmr_ingest_test.db"
db_url = f"sqlite:///{db_path}"
command.upgrade(_alembic_config(db_url), "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
yield engine, SessionLocal
engine.dispose()
def _make_settings(
*,
dsmr_sample_interval_s: int = 10,
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
# The reference telegram sample from §6.3 of the design doc.
# Numeric values are JSON strings; missing phases are null.
_SAMPLE_TELEGRAM = {
"id": 200086230,
"timestamp": "2026-06-23T12:16:00Z", # second=00, on 10s boundary
"electricity_delivered_1": "20915.154",
"electricity_returned_1": "2979.905",
"electricity_delivered_2": "15212.090",
"electricity_returned_2": "6786.406",
"electricity_currently_delivered": "0.000",
"phase_currently_delivered_l1": "0.000",
"phase_currently_delivered_l2": None, # absent phase
"extra_device_timestamp": "2026-06-23T12:15:00Z",
"extra_device_delivered": "6208.234",
"phase_voltage_l1": "237.0",
"phase_voltage_l2": None, # absent phase
}
# Telegram with second=48 (not divisible by 10) — should be discarded.
_SAMPLE_TELEGRAM_SECOND_48 = {
**_SAMPLE_TELEGRAM,
"id": 200086248,
"timestamp": "2026-06-23T12:16:48Z", # second=48
}
# Telegram with second=10 — another valid boundary.
_SAMPLE_TELEGRAM_SECOND_10 = {
**_SAMPLE_TELEGRAM,
"id": 200086210,
"timestamp": "2026-06-23T12:16:10Z", # second=10
}
def _payload(data: dict) -> bytes:
return json.dumps(data).encode()
# ---------------------------------------------------------------------------
# Helper: invoke handle_message with the test DB injected via patch
# ---------------------------------------------------------------------------
def _call_handle_message(
data: dict,
settings,
SessionLocal,
) -> None:
"""Call dsmr_ingest.handle_message with the test SessionLocal patched in."""
from app.services import dsmr_ingest
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
dsmr_ingest.handle_message(_payload(data), settings)
def _count_readings(SessionLocal) -> int:
with SessionLocal() as session:
return session.scalar(
__import__("sqlalchemy", fromlist=["func"]).func.count(DsmrReading.id)
)
def _get_readings(SessionLocal) -> list[DsmrReading]:
with SessionLocal() as session:
return session.scalars(select(DsmrReading)).all()
# ---------------------------------------------------------------------------
# 1. On-interval telegram is persisted (whole frame)
# ---------------------------------------------------------------------------
def test_second_00_persists_full_frame(dsmr_db):
"""A telegram with second=00 (10s boundary) must be persisted with the full payload."""
engine, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
with Session(engine) as session:
readings = session.scalars(select(DsmrReading)).all()
assert len(readings) == 1
row = readings[0]
# Recorded_at must be parsed from the telegram timestamp.
assert row.recorded_at is not None
assert row.recorded_at.second == 0
# source_id must equal the telegram id.
assert row.source_id == _SAMPLE_TELEGRAM["id"]
# Full frame preserved — spot-check several fields.
payload = row.payload
assert payload["electricity_delivered_1"] == "20915.154"
assert payload["electricity_returned_2"] == "6786.406"
assert payload["extra_device_delivered"] == "6208.234"
assert payload["extra_device_timestamp"] == "2026-06-23T12:15:00Z"
# Null phases stored verbatim (not omitted, not converted).
assert "phase_currently_delivered_l2" in payload
assert payload["phase_currently_delivered_l2"] is None
assert "phase_voltage_l2" in payload
assert payload["phase_voltage_l2"] is None
def test_second_10_persists(dsmr_db):
"""A telegram with second=10 (another 10s boundary) must also be persisted."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM_SECOND_10, settings, SessionLocal)
assert _count_readings(SessionLocal) == 1
# ---------------------------------------------------------------------------
# 2. Off-interval telegram is discarded
# ---------------------------------------------------------------------------
def test_second_48_discarded(dsmr_db):
"""A telegram with second=48 (not on 10s boundary) must be silently discarded."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM_SECOND_48, settings, SessionLocal)
assert _count_readings(SessionLocal) == 0
def test_off_interval_not_multiple_of_10(dsmr_db):
"""Seconds 19, 1119, etc. are all off-interval and must be discarded."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
for second in (1, 3, 7, 9, 11, 23, 47, 59):
ts = f"2026-06-23T12:16:{second:02d}Z"
data = {**_SAMPLE_TELEGRAM, "id": 200000000 + second, "timestamp": ts}
_call_handle_message(data, settings, SessionLocal)
assert _count_readings(SessionLocal) == 0
# ---------------------------------------------------------------------------
# 3. Idempotency — keyed on recorded_at (telegram timestamp), NOT the telegram id
# ---------------------------------------------------------------------------
def test_same_timestamp_not_reinserted(dsmr_db):
"""Feeding the same telegram twice (same timestamp) must result in one row."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal) # duplicate
assert _count_readings(SessionLocal) == 1
def test_different_timestamps_are_independent(dsmr_db):
"""Telegrams with different timestamps produce separate rows."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
_call_handle_message(_SAMPLE_TELEGRAM_SECOND_10, settings, SessionLocal)
assert _count_readings(SessionLocal) == 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
telegram id must BOTH be stored — dedup must not depend on the telegram id."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
first = {**_SAMPLE_TELEGRAM, "id": 0, "timestamp": "2026-06-23T12:16:00Z"}
# Later telegram, id reset back to the same value after an overflow.
second = {**_SAMPLE_TELEGRAM, "id": 0, "timestamp": "2026-06-23T12:16:10Z"}
_call_handle_message(first, settings, SessionLocal)
_call_handle_message(second, settings, SessionLocal)
# Both must persist — the colliding telegram id must not cause a drop.
assert _count_readings(SessionLocal) == 2
# ---------------------------------------------------------------------------
# 4. Missing source_id — still persisted with source_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."""
engine, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
data = {k: v for k, v in _SAMPLE_TELEGRAM.items() if k != "id"}
_call_handle_message(data, settings, SessionLocal)
with Session(engine) as session:
readings = session.scalars(select(DsmrReading)).all()
assert len(readings) == 1
assert readings[0].source_id is None
# ---------------------------------------------------------------------------
# 5. interval=0 → no ZeroDivisionError; all telegrams persisted
# ---------------------------------------------------------------------------
def test_interval_zero_does_not_crash(dsmr_db):
"""When dsmr_sample_interval_s=0, no ZeroDivisionError; all telegrams kept."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=0)
for second in (0, 7, 13, 48, 59):
ts = f"2026-06-23T12:16:{second:02d}Z"
data = {**_SAMPLE_TELEGRAM, "id": 300000000 + second, "timestamp": ts}
_call_handle_message(data, settings, SessionLocal)
# All 5 should have been persisted (no sampling applied).
assert _count_readings(SessionLocal) == 5
# ---------------------------------------------------------------------------
# 6. Invalid / malformed payloads — silently discarded, no exception propagated
# ---------------------------------------------------------------------------
def test_invalid_json_discarded(dsmr_db):
"""Invalid JSON payload must be silently discarded without raising."""
_, SessionLocal = dsmr_db
settings = _make_settings()
from app.services import dsmr_ingest
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
# Must not raise
dsmr_ingest.handle_message(b"not-valid-json", settings)
assert _count_readings(SessionLocal) == 0
def test_missing_timestamp_discarded(dsmr_db):
"""Telegram without a 'timestamp' field must be discarded."""
_, SessionLocal = dsmr_db
settings = _make_settings()
data = {k: v for k, v in _SAMPLE_TELEGRAM.items() if k != "timestamp"}
_call_handle_message(data, settings, SessionLocal)
assert _count_readings(SessionLocal) == 0
def test_unparseable_timestamp_discarded(dsmr_db):
"""Telegram with a garbage 'timestamp' field must be discarded."""
_, SessionLocal = dsmr_db
settings = _make_settings()
data = {**_SAMPLE_TELEGRAM, "timestamp": "not-a-date"}
_call_handle_message(data, settings, SessionLocal)
assert _count_readings(SessionLocal) == 0
def test_handle_message_does_not_propagate_any_exception() -> None:
"""handle_message must never propagate any exception to the caller."""
from app.services import dsmr_ingest
settings = _make_settings()
def _explode():
raise RuntimeError("DB is on fire")
with patch.object(dsmr_ingest, "get_session_local", side_effect=_explode):
# Must not raise
dsmr_ingest.handle_message(_payload(_SAMPLE_TELEGRAM), settings)
# ---------------------------------------------------------------------------
# 7. Null phase values stored verbatim
# ---------------------------------------------------------------------------
def test_null_phases_stored_as_none(dsmr_db):
"""Null phase values must be preserved as JSON null (Python None) in payload."""
engine, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
with Session(engine) as session:
reading = session.scalars(select(DsmrReading)).first()
assert reading is not None
assert reading.payload.get("phase_currently_delivered_l2") is None
assert reading.payload.get("phase_voltage_l2") is None
# ---------------------------------------------------------------------------
# 8. Gas / extra_device fields stored
# ---------------------------------------------------------------------------
def test_gas_fields_stored(dsmr_db):
"""extra_device_* (gas) fields must be present in the stored payload."""
engine, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
with Session(engine) as session:
reading = session.scalars(select(DsmrReading)).first()
assert reading is not None
payload = reading.payload
assert "extra_device_delivered" in payload
assert payload["extra_device_delivered"] == "6208.234"
assert "extra_device_timestamp" in payload
# ---------------------------------------------------------------------------
# 9. Timestamp with +00:00 suffix (no Z) also works
# ---------------------------------------------------------------------------
def test_timestamp_with_utc_offset_suffix(dsmr_db):
"""Timestamps using '+00:00' instead of 'Z' must also be parsed correctly."""
_, SessionLocal = dsmr_db
settings = _make_settings(dsmr_sample_interval_s=10)
data = {
**_SAMPLE_TELEGRAM,
"id": 999999,
"timestamp": "2026-06-23T12:16:00+00:00", # no Z, uses +00:00
}
_call_handle_message(data, settings, SessionLocal)
assert _count_readings(SessionLocal) == 1
# ---------------------------------------------------------------------------
# 10. handle_tariff_message: parse, validate, update, reject invalid payloads
# ---------------------------------------------------------------------------
@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, "_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
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
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
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" 1 ")
assert get_current_tariff() == 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")
# Must NOT raise and must NOT change the tariff.
assert get_current_tariff() == 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
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
handle_tariff_message(b"0") # 0 is not a valid tariff
assert get_current_tariff() == 2
def test_tariff_message_does_not_raise_on_any_input(reset_tariff):
"""handle_tariff_message must never propagate any exception to the caller."""
from app.services.dsmr_ingest import handle_tariff_message
# 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