From 8180082f90c5f8b06c4e0e1a1ca4c35ade7ac8d8 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 27 Aug 2026 21:29:45 +0200 Subject: [PATCH] M8-R14: preserve zero DSMR sample interval --- app/integrations/meter_sources.py | 4 +++- tests/test_dsmr_source_migration.py | 11 ++++++++--- tests/test_meter_source_services.py | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/integrations/meter_sources.py b/app/integrations/meter_sources.py index 50bcb71..d43a99e 100644 --- a/app/integrations/meter_sources.py +++ b/app/integrations/meter_sources.py @@ -98,8 +98,10 @@ def _validate_field_value(kind: str, field: SourceConfigField, value: Any) -> No _check_type(field, value) if field.name == "path" and not value.startswith("/dev/"): raise SourceProfileError("warmtelink_serial config path must start with '/dev/'.") - if field.name in {"broker_port", "sample_interval_s", "baudrate"} and value <= 0: + if field.name in {"broker_port", "baudrate"} and value <= 0: raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.") + if field.name == "sample_interval_s" and value < 0: + raise SourceProfileError("Config field 'sample_interval_s' must not be negative.") if field.name == "data_bits" and value != 7: raise SourceProfileError("warmtelink_serial data_bits must be 7.") if field.name == "parity" and value != "N": diff --git a/tests/test_dsmr_source_migration.py b/tests/test_dsmr_source_migration.py index d6f0817..c09de86 100644 --- a/tests/test_dsmr_source_migration.py +++ b/tests/test_dsmr_source_migration.py @@ -12,6 +12,8 @@ from alembic import command from alembic.config import Config from sqlalchemy import create_engine, event, inspect, text +from app.integrations.meter_sources import sanitize_source_config, validate_source_config + def _config(database_url: str) -> Config: config = Config("alembic_app.ini") @@ -108,7 +110,7 @@ def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path {"key": "DSMR_INGEST_ENABLED", "value": "true", "at": start}, {"key": "DSMR_MQTT_TOPIC", "value": "historic/dsmr", "at": start}, {"key": "DSMR_TARIFF_TOPIC", "value": "historic/tariff", "at": start}, - {"key": "DSMR_SAMPLE_INTERVAL_S", "value": "15", "at": start}, + {"key": "DSMR_SAMPLE_INTERVAL_S", "value": "0", "at": start}, {"key": "MQTT_BROKER_HOST", "value": "mqtt.example.invalid", "at": start}, {"key": "MQTT_BROKER_PORT", "value": "1884", "at": start}, {"key": "MQTT_USERNAME", "value": "historic-user", "at": start}, @@ -159,11 +161,14 @@ def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path text("SELECT id, enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'") ).one() assert source.enabled == 1 - assert json.loads(source.config) == { + source_config = json.loads(source.config) + assert source_config == { "broker_host": "mqtt.example.invalid", "broker_port": 1884, "username": "historic-user", "password": "historic-password", "tls_enabled": True, - "topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 15, + "topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 0, } + assert validate_source_config("dsmr_mqtt", source_config) == source_config + assert sanitize_source_config("dsmr_mqtt", source_config)["sample_interval_s"] == 0 assert connection.execute(text("SELECT value FROM app_config WHERE key = 'DSMR_MQTT_TOPIC'")).scalar_one() == "historic/dsmr" assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == config_before assert connection.execute(text("SELECT group_concat(telegram_id) FROM dsmr_reading")).scalar_one() == "77,78,77" diff --git a/tests/test_meter_source_services.py b/tests/test_meter_source_services.py index 44efd87..5af50f1 100644 --- a/tests/test_meter_source_services.py +++ b/tests/test_meter_source_services.py @@ -68,6 +68,32 @@ def test_secret_sanitize_and_mask_merge_keep_old_value(): assert merged["topic"] == "new/topic" +def test_dsmr_zero_interval_validates_sanitizes_and_merges_unchanged(): + config = validate_source_config("dsmr_mqtt", {"sample_interval_s": 0}) + + assert config["sample_interval_s"] == 0 + assert sanitize_source_config("dsmr_mqtt", config)["sample_interval_s"] == 0 + assert merge_source_config("dsmr_mqtt", config, {"topic": "new/topic"})["sample_interval_s"] == 0 + + +@pytest.mark.parametrize( + ("kind", "config"), + [ + ("dsmr_mqtt", {"sample_interval_s": -1}), + ("dsmr_mqtt", {"sample_interval_s": False}), + ("dsmr_mqtt", {"broker_port": 0}), + ("dsmr_mqtt", {"broker_port": -1}), + ("dsmr_mqtt", {"broker_port": False}), + ("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": 0}), + ("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": -1}), + ("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": False}), + ], +) +def test_numeric_source_profile_constraints_still_reject_invalid_values(kind, config): + with pytest.raises(SourceProfileError): + validate_source_config(kind, config) + + @pytest.fixture() def session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'source_services.db'}")