From 9bc46f8d2dc7eb8801af0667aa471e1836025436 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Wed, 24 Jun 2026 15:39:21 +0200 Subject: [PATCH] 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. --- .env.example | 3 + app/config.py | 3 + app/integrations/expose.py | 41 ++++- app/services/config_page.py | 7 + app/services/dsmr_ingest.py | 101 ++++++++++++- tests/test_api_config.py | 50 ++++++- tests/test_dsmr_ingest.py | 92 ++++++++++++ tests/test_dsmr_subscription_apply.py | 85 ++++++++++- tests/test_energy_expose.py | 208 ++++++++++++++++++++++++++ 9 files changed, 578 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 9a81dfb..5d0b377 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,9 @@ HA_DISCOVERY_PREFIX=homeassistant # DSMR_INGEST_ENABLED=false # DSMR_MQTT_TOPIC=dsmr/json # DSMR_SAMPLE_INTERVAL_S=10 +# DSMR tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak). +# Empty string disables tariff-aware pricing (buy/sell_price_now always show normal rate). +# DSMR_TARIFF_TOPIC=dsmr/meter-stats/electricity_tariff # Optional: Tibber dynamic pricing credentials (M6). # Only used when an active energy contract with kind=tibber is configured. diff --git a/app/config.py b/app/config.py index d5b872e..1aedb9d 100644 --- a/app/config.py +++ b/app/config.py @@ -63,6 +63,9 @@ class Settings(BaseSettings): dsmr_ingest_enabled: bool = False dsmr_mqtt_topic: str = "dsmr/json" dsmr_sample_interval_s: int = 10 + # DSMR dual-tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak). + # Empty string = do not subscribe (tariff-aware pricing disabled). + dsmr_tariff_topic: str = "dsmr/meter-stats/electricity_tariff" # Tibber dynamic pricing credentials (M6; only used when active contract kind=tibber). tibber_api_token: str = "" diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 8afd150..ee468f3 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -449,9 +449,21 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: # --- value_getter: current buy price --- def _make_buy_price_getter() -> Callable[["Session"], Any]: - """Return a getter for the current buy price per kWh.""" + """Return a getter for the current buy price per kWh. + + For ``kind="manual"`` contracts the getter selects the price slot that + matches the current DSMR electricity tariff: + + - tariff == 1 (dal / off-peak) → ``buy_dal`` + - tariff == 2 (normal / peak) → ``buy_normal`` + - tariff is None / unknown → ``buy_normal`` (safe fallback = current status quo) + + For ``kind="tibber"`` (hourly dynamic pricing) the tariff slot is not + applicable; the getter always uses the ``buy`` key from the snapshot. + """ def _getter(sess: "Session") -> Any: from app.models.energy import EnergyCostPeriod as _ECP + from app.services.dsmr_ingest import get_current_tariff period = ( sess.query(_ECP) @@ -466,7 +478,12 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: if kind == "tibber": raw = snap.get("buy") elif kind == "manual": - raw = snap.get("buy_normal") + tariff = get_current_tariff() + if tariff == 1: + raw = snap.get("buy_dal") + else: + # tariff == 2, None, or any unexpected value → normal (peak) rate. + raw = snap.get("buy_normal") else: # Unknown kind — attempt common keys gracefully. raw = snap.get("buy") or snap.get("buy_normal") @@ -482,9 +499,20 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: # --- value_getter: current sell price --- def _make_sell_price_getter() -> Callable[["Session"], Any]: - """Return a getter for the current sell price per kWh.""" + """Return a getter for the current sell price per kWh. + + For ``kind="manual"`` contracts the getter selects the price slot that + matches the current DSMR electricity tariff: + + - tariff == 1 (dal / off-peak) → ``sell_dal`` + - tariff == 2 (normal / peak) → ``sell_normal`` + - tariff is None / unknown → ``sell_normal`` (safe fallback = current status quo) + + For ``kind="tibber"`` the tariff slot is not applicable; always uses ``sell``. + """ def _getter(sess: "Session") -> Any: from app.models.energy import EnergyCostPeriod as _ECP + from app.services.dsmr_ingest import get_current_tariff period = ( sess.query(_ECP) @@ -499,7 +527,12 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: if kind == "tibber": raw = snap.get("sell") elif kind == "manual": - raw = snap.get("sell_normal") + tariff = get_current_tariff() + if tariff == 1: + raw = snap.get("sell_dal") + else: + # tariff == 2, None, or any unexpected value → normal (peak) rate. + raw = snap.get("sell_normal") else: raw = snap.get("sell") or snap.get("sell_normal") if raw is None: diff --git a/app/services/config_page.py b/app/services/config_page.py index 7ea6a2a..5573cc6 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -138,6 +138,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( "DSMR Sample Interval (s)", input_type="number", ), + ConfigField( + "DSMR", + "DSMR_TARIFF_TOPIC", + "dsmr_tariff_topic", + "DSMR Tariff Topic", + ), ConfigField( "Tibber", "TIBBER_API_TOKEN", @@ -348,6 +354,7 @@ def _settings_payload(settings: Settings) -> dict[str, Any]: "dsmr_ingest_enabled": settings.dsmr_ingest_enabled, "dsmr_mqtt_topic": settings.dsmr_mqtt_topic, "dsmr_sample_interval_s": settings.dsmr_sample_interval_s, + "dsmr_tariff_topic": settings.dsmr_tariff_topic, "tibber_api_token": settings.tibber_api_token, "tibber_home_id": settings.tibber_home_id, } diff --git a/app/services/dsmr_ingest.py b/app/services/dsmr_ingest.py index 27ba26f..63389ae 100644 --- a/app/services/dsmr_ingest.py +++ b/app/services/dsmr_ingest.py @@ -32,6 +32,7 @@ from __future__ import annotations import json import logging +import threading from datetime import datetime, timezone from typing import TYPE_CHECKING @@ -51,17 +52,81 @@ logger = logging.getLogger(__name__) # change can unsubscribe the old topic before subscribing the new one. _current_dsmr_topic: str | None = None +# Tracks the DSMR tariff topic currently subscribed via the MQTT manager. +_current_tariff_topic: str | None = None + +# Current electricity tariff: 1 = dal/off-peak, 2 = normal/peak. +# Written by the paho network thread, read by the publish job — guarded by a lock. +_current_tariff: int | None = None +_tariff_lock = threading.Lock() + + +def get_current_tariff() -> int | None: + """Return the most recently received electricity tariff (1 or 2), or None.""" + with _tariff_lock: + return _current_tariff + + +def set_current_tariff(value: int | None) -> None: + """Set the current electricity tariff (1 or 2), or clear it with None.""" + with _tariff_lock: + global _current_tariff + _current_tariff = value + + +def handle_tariff_message(payload_bytes: bytes) -> None: + """Parse one DSMR tariff MQTT payload and update the in-memory tariff state. + + Called from the paho network thread; *must* swallow all exceptions so that + a bad payload never crashes the loop or drops the broker connection. + + Accepts payload as bytes or str (paho can deliver either). Ignores + whitespace. Only accepts integer values 1 or 2; anything else is discarded + and the previous known tariff is preserved. + + Parameters + ---------- + payload_bytes: + Raw bytes from the MQTT message (may also be a str in some paho versions). + """ + try: + # Decode bytes → str if needed; strip surrounding whitespace. + if isinstance(payload_bytes, (bytes, bytearray)): + raw = payload_bytes.decode("utf-8", errors="replace").strip() + else: + raw = str(payload_bytes).strip() + + value = int(raw) + if value not in (1, 2): + logger.debug( + "dsmr_ingest.handle_tariff_message: unexpected tariff value %d (expected 1 or 2, ignored).", + value, + ) + return + + set_current_tariff(value) + logger.debug("dsmr_ingest.handle_tariff_message: tariff updated to %d.", value) + + except Exception: + # Malformed payload (e.g. non-numeric); swallow silently to protect network thread. + logger.debug( + "dsmr_ingest.handle_tariff_message: could not parse payload %r (ignored).", + payload_bytes, + ) + def apply_dsmr_subscription(settings: "Settings") -> None: - """(Re)apply the DSMR MQTT subscription to match *settings* — restart-free. + """(Re)apply the DSMR MQTT subscriptions to match *settings* — restart-free. Call this at startup and after every config save. It makes the live MQTT - subscription reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic`` - / ``dsmr_sample_interval_s`` settings without an app restart: + subscriptions reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic`` + / ``dsmr_sample_interval_s`` / ``dsmr_tariff_topic`` settings without an app + restart: - - **Disabled** → unsubscribe any active DSMR subscription. + - **Disabled** → unsubscribe any active DSMR and tariff subscriptions. - **Enabled** → (re)subscribe to ``dsmr_mqtt_topic`` with a handler bound to a *fresh* settings snapshot, so a changed sample interval also takes effect. + Also subscribe to ``dsmr_tariff_topic`` when non-empty. - **Topic changed** → unsubscribe the old topic before subscribing the new one. Idempotent and safe to call when MQTT is not connected (the subscription is @@ -70,15 +135,23 @@ def apply_dsmr_subscription(settings: "Settings") -> None: # Imported here (not at module top) to avoid a circular import at app start. from app.integrations.mqtt import mqtt_manager - global _current_dsmr_topic + global _current_dsmr_topic, _current_tariff_topic if not settings.dsmr_ingest_enabled: if _current_dsmr_topic is not None: mqtt_manager.unsubscribe(_current_dsmr_topic) logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic) _current_dsmr_topic = None + if _current_tariff_topic is not None: + mqtt_manager.unsubscribe(_current_tariff_topic) + logger.info( + "DSMR ingest disabled — unsubscribed from tariff topic=%s.", + _current_tariff_topic, + ) + _current_tariff_topic = None return + # --- Main DSMR telegram topic --- topic = settings.dsmr_mqtt_topic if _current_dsmr_topic is not None and _current_dsmr_topic != topic: mqtt_manager.unsubscribe(_current_dsmr_topic) @@ -90,6 +163,24 @@ def apply_dsmr_subscription(settings: "Settings") -> None: _current_dsmr_topic = topic logger.info("DSMR ingest enabled — subscribed to topic=%s.", topic) + # --- DSMR tariff topic (dual-tariff slot indicator) --- + tariff_topic = settings.dsmr_tariff_topic if settings.dsmr_tariff_topic else "" + if tariff_topic: + if _current_tariff_topic is not None and _current_tariff_topic != tariff_topic: + mqtt_manager.unsubscribe(_current_tariff_topic) + mqtt_manager.subscribe(tariff_topic, lambda payload: handle_tariff_message(payload)) + _current_tariff_topic = tariff_topic + logger.info("DSMR tariff topic — subscribed to topic=%s.", tariff_topic) + else: + # tariff_topic is empty → unsubscribe any existing tariff subscription. + if _current_tariff_topic is not None: + mqtt_manager.unsubscribe(_current_tariff_topic) + logger.info( + "DSMR tariff topic cleared — unsubscribed from topic=%s.", + _current_tariff_topic, + ) + _current_tariff_topic = None + def handle_message(payload_bytes: bytes, settings: "Settings") -> None: """Parse one DSMR MQTT payload and persist it if it passes the sample filter. diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 50938ba..a3aed49 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -732,7 +732,7 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings( def test_get_config_includes_dsmr_section(client: TestClient) -> None: - """GET /api/config must include a DSMR section with expected fields.""" + """GET /api/config must include a DSMR section with expected fields including DSMR_TARIFF_TOPIC.""" _login(client) response = client.get("/api/config") @@ -746,6 +746,9 @@ def test_get_config_includes_dsmr_section(client: TestClient) -> None: assert "DSMR_INGEST_ENABLED" in env_names assert "DSMR_MQTT_TOPIC" in env_names assert "DSMR_SAMPLE_INTERVAL_S" in env_names + assert "DSMR_TARIFF_TOPIC" in env_names, ( + f"DSMR_TARIFF_TOPIC must be present in DSMR section; got {env_names}" + ) def test_get_config_includes_tibber_section(client: TestClient) -> None: @@ -903,6 +906,51 @@ def test_put_config_invalid_dsmr_sample_interval_returns_422_and_does_not_write( assert rows.get("DSMR_SAMPLE_INTERVAL_S") != "not-a-number" +def test_put_config_dsmr_tariff_topic_persists_and_reflects_in_get( + client: TestClient, test_database_urls +) -> None: + """DSMR_TARIFF_TOPIC must persist via PUT and be readable via GET /api/config.""" + _login(client) + + new_topic = "meter/tariff/slot" + payload = _full_config_payload({"DSMR_TARIFF_TOPIC": new_topic}) + with patch("app.services.dsmr_ingest.apply_dsmr_subscription"): + response = client.put( + "/api/config", + json={"updates": payload}, + headers={"X-CSRF-Token": "token"}, + ) + assert response.status_code == 200 + + # The updated value must appear in the GET response. + get_resp = client.get("/api/config") + body = get_resp.json() + dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR") + tariff_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_TARIFF_TOPIC") + assert tariff_field["value"] == new_topic, ( + f"Expected DSMR_TARIFF_TOPIC to be {new_topic!r}, got {tariff_field['value']!r}" + ) + + +def test_put_config_dsmr_tariff_topic_in_settings_payload(client: TestClient) -> None: + """DSMR_TARIFF_TOPIC must appear in _settings_payload (GET /api/config returns it).""" + _login(client) + + # The default value from Settings must appear in the DSMR section. + response = client.get("/api/config") + body = response.json() + + dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR") + tariff_field = next( + (f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_TARIFF_TOPIC"), None + ) + assert tariff_field is not None, "DSMR_TARIFF_TOPIC must appear in DSMR config section" + # Default value should be the DSMR reader meter-stats topic. + assert tariff_field["value"] == "dsmr/meter-stats/electricity_tariff", ( + f"Unexpected default for DSMR_TARIFF_TOPIC: {tariff_field['value']!r}" + ) + + def test_get_config_tibber_api_token_value_masked_after_save( client: TestClient, test_database_urls ) -> None: diff --git a/tests/test_dsmr_ingest.py b/tests/test_dsmr_ingest.py index 8afb7fd..488c4a4 100644 --- a/tests/test_dsmr_ingest.py +++ b/tests/test_dsmr_ingest.py @@ -410,3 +410,95 @@ def test_timestamp_with_utc_offset_suffix(dsmr_db): _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 diff --git a/tests/test_dsmr_subscription_apply.py b/tests/test_dsmr_subscription_apply.py index 37621a9..6aa3323 100644 --- a/tests/test_dsmr_subscription_apply.py +++ b/tests/test_dsmr_subscription_apply.py @@ -26,11 +26,18 @@ class _FakeMqtt: self.unsubscribe_calls.append(topic) -def _settings(*, enabled: bool = True, topic: str = "dsmr/json", interval: int = 10): +def _settings( + *, + enabled: bool = True, + topic: str = "dsmr/json", + interval: int = 10, + tariff_topic: str = "", +): s = MagicMock() s.dsmr_ingest_enabled = enabled s.dsmr_mqtt_topic = topic s.dsmr_sample_interval_s = interval + s.dsmr_tariff_topic = tariff_topic return s @@ -40,8 +47,9 @@ def fake_mqtt(monkeypatch): # apply_dsmr_subscription does `from app.integrations.mqtt import mqtt_manager` # at call time, so patching the module attribute is picked up. monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake) - # Reset (and auto-restore) the module-level "currently subscribed topic". + # Reset (and auto-restore) the module-level "currently subscribed topics". monkeypatch.setattr(dsmr_ingest, "_current_dsmr_topic", None) + monkeypatch.setattr(dsmr_ingest, "_current_tariff_topic", None) return fake @@ -90,3 +98,76 @@ def test_disabled_when_never_enabled_is_noop(fake_mqtt): assert fake_mqtt.subscribe_calls == [] assert fake_mqtt.unsubscribe_calls == [] assert dsmr_ingest._current_dsmr_topic is None + + +# --------------------------------------------------------------------------- +# Tariff topic subscription management +# --------------------------------------------------------------------------- + + +def test_enabled_with_tariff_topic_subscribes_both(fake_mqtt): + """When enabled and tariff_topic is non-empty, both topics must be subscribed.""" + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff") + ) + + subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls] + assert "dsmr/json" in subscribed_topics + assert "dsmr/meter-stats/electricity_tariff" in subscribed_topics + assert len(fake_mqtt.subscribe_calls) == 2 + assert dsmr_ingest._current_tariff_topic == "dsmr/meter-stats/electricity_tariff" + + +def test_enabled_with_empty_tariff_topic_subscribes_only_main(fake_mqtt): + """When tariff_topic is empty, only the main DSMR topic is subscribed.""" + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic="") + ) + + assert len(fake_mqtt.subscribe_calls) == 1 + assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json" + assert dsmr_ingest._current_tariff_topic is None + + +def test_disabled_after_tariff_subscription_unsubscribes_both(fake_mqtt): + """Disabling ingest must also unsubscribe the tariff topic.""" + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff") + ) + dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False)) + + assert "dsmr/json" in fake_mqtt.unsubscribe_calls + assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls + assert dsmr_ingest._current_dsmr_topic is None + assert dsmr_ingest._current_tariff_topic is None + + +def test_tariff_topic_change_resubscribes(fake_mqtt): + """Changing the tariff topic must unsubscribe the old one and subscribe the new one.""" + old_tariff = "dsmr/meter-stats/electricity_tariff" + new_tariff = "meter/tariff" + + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic=old_tariff) + ) + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic=new_tariff) + ) + + assert old_tariff in fake_mqtt.unsubscribe_calls + subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls] + assert new_tariff in subscribed_topics + assert dsmr_ingest._current_tariff_topic == new_tariff + + +def test_tariff_topic_cleared_unsubscribes(fake_mqtt): + """Setting tariff_topic to empty after it was subscribed must unsubscribe it.""" + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff") + ) + dsmr_ingest.apply_dsmr_subscription( + _settings(enabled=True, topic="dsmr/json", tariff_topic="") + ) + + assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls + assert dsmr_ingest._current_tariff_topic is None diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index 402c17c..0e52a8b 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -1117,3 +1117,211 @@ def test_export_revenue_total_falls_back_to_sum_when_no_active_contract(energy_d assert value == pytest.approx(2.00, rel=1e-9), ( f"Expected pure SUM=2.00 with no active contract, got {value!r}" ) + + +# --------------------------------------------------------------------------- +# 15. Tariff-aware pricing: buy_price_now / sell_price_now honour _current_tariff +# --------------------------------------------------------------------------- + + +_DUAL_TARIFF_PRICING = { + "kind": "manual", + "buy_dal": "0.2500", + "buy_normal": "0.2700", + "sell_dal": "0.0900", + "sell_normal": "0.0950", + "energy_tax": "0.1234", + "ode": "0.0015", +} + + +@pytest.fixture() +def reset_tariff(monkeypatch): + """Reset dsmr_ingest._current_tariff to None before/after each tariff test.""" + from app.services import dsmr_ingest as _di + + monkeypatch.setattr(_di, "_current_tariff", None) + yield + + +def _insert_manual_period(energy_db) -> None: + """Insert a single non-degraded manual pricing period into energy_db.""" + t0 = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_period( + session, + period_start=t0, + import_cost=0.10, + export_revenue=0.05, + currency="EUR", + pricing=_DUAL_TARIFF_PRICING, + degraded=False, + ) + session.commit() + + +def test_buy_price_tariff_1_returns_dal(energy_db, reset_tariff) -> None: + """buy_price_now must return buy_dal when tariff=1 (off-peak).""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + _insert_manual_period(energy_db) + set_current_tariff(1) + + with Session(energy_db) as session: + catalog = build_catalog(session) + buy_entry = next(e for e in catalog if e.entity.key == "energy.buy_price_now") + value = buy_entry.entity.value_getter(session) + + assert value == pytest.approx(0.2500), f"Expected buy_dal=0.2500, got {value!r}" + + +def test_buy_price_tariff_2_returns_normal(energy_db, reset_tariff) -> None: + """buy_price_now must return buy_normal when tariff=2 (peak).""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + _insert_manual_period(energy_db) + set_current_tariff(2) + + with Session(energy_db) as session: + catalog = build_catalog(session) + buy_entry = next(e for e in catalog if e.entity.key == "energy.buy_price_now") + value = buy_entry.entity.value_getter(session) + + assert value == pytest.approx(0.2700), f"Expected buy_normal=0.2700, got {value!r}" + + +def test_buy_price_tariff_none_falls_back_to_normal(energy_db, reset_tariff) -> None: + """buy_price_now must return buy_normal when tariff is None (no DSMR tariff received yet).""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + _insert_manual_period(energy_db) + set_current_tariff(None) # explicitly None (no tariff received) + + with Session(energy_db) as session: + catalog = build_catalog(session) + buy_entry = next(e for e in catalog if e.entity.key == "energy.buy_price_now") + value = buy_entry.entity.value_getter(session) + + assert value == pytest.approx(0.2700), f"Expected buy_normal=0.2700 fallback, got {value!r}" + + +def test_sell_price_tariff_1_returns_dal(energy_db, reset_tariff) -> None: + """sell_price_now must return sell_dal when tariff=1 (off-peak).""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + _insert_manual_period(energy_db) + set_current_tariff(1) + + with Session(energy_db) as session: + catalog = build_catalog(session) + sell_entry = next(e for e in catalog if e.entity.key == "energy.sell_price_now") + value = sell_entry.entity.value_getter(session) + + assert value == pytest.approx(0.0900), f"Expected sell_dal=0.0900, got {value!r}" + + +def test_sell_price_tariff_2_returns_normal(energy_db, reset_tariff) -> None: + """sell_price_now must return sell_normal when tariff=2 (peak).""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + _insert_manual_period(energy_db) + set_current_tariff(2) + + with Session(energy_db) as session: + catalog = build_catalog(session) + sell_entry = next(e for e in catalog if e.entity.key == "energy.sell_price_now") + value = sell_entry.entity.value_getter(session) + + assert value == pytest.approx(0.0950), f"Expected sell_normal=0.0950, got {value!r}" + + +def test_sell_price_tariff_none_falls_back_to_normal(energy_db, reset_tariff) -> None: + """sell_price_now must return sell_normal when tariff is None.""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + _insert_manual_period(energy_db) + set_current_tariff(None) + + with Session(energy_db) as session: + catalog = build_catalog(session) + sell_entry = next(e for e in catalog if e.entity.key == "energy.sell_price_now") + value = sell_entry.entity.value_getter(session) + + assert value == pytest.approx(0.0950), f"Expected sell_normal=0.0950 fallback, got {value!r}" + + +def test_tibber_buy_price_not_affected_by_tariff(energy_db, reset_tariff) -> None: + """Tibber buy_price_now must NOT be affected by the DSMR tariff — always uses 'buy' key.""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + t0 = datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc) + tibber_pricing = { + "kind": "tibber", + "buy": "0.3100", + "sell": "0.1500", + "energy_tax": "0.1234", + "sell_adjust": "0.0100", + "total": "0.3100", + } + + with Session(energy_db) as session: + _make_period( + session, + period_start=t0, + pricing=tibber_pricing, + degraded=False, + ) + session.commit() + + # Tibber pricing must return the same value regardless of tariff. + for tariff_val in (1, 2, None): + set_current_tariff(tariff_val) + with Session(energy_db) as session: + catalog = build_catalog(session) + buy_entry = next(e for e in catalog if e.entity.key == "energy.buy_price_now") + value = buy_entry.entity.value_getter(session) + assert value == pytest.approx(0.3100), ( + f"Tibber buy price must be 0.3100 regardless of tariff={tariff_val!r}, got {value!r}" + ) + + +def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> None: + """Tibber sell_price_now must NOT be affected by the DSMR tariff — always uses 'sell' key.""" + from app.integrations.expose import build_catalog + from app.services.dsmr_ingest import set_current_tariff + + t0 = datetime(2026, 4, 1, 9, 15, tzinfo=timezone.utc) + tibber_pricing = { + "kind": "tibber", + "buy": "0.3100", + "sell": "0.1500", + "energy_tax": "0.1234", + "sell_adjust": "0.0100", + "total": "0.3100", + } + + with Session(energy_db) as session: + _make_period( + session, + period_start=t0, + pricing=tibber_pricing, + degraded=False, + ) + session.commit() + + for tariff_val in (1, 2, None): + set_current_tariff(tariff_val) + with Session(energy_db) as session: + catalog = build_catalog(session) + sell_entry = next(e for e in catalog if e.entity.key == "energy.sell_price_now") + value = sell_entry.entity.value_getter(session) + assert value == pytest.approx(0.1500), ( + f"Tibber sell price must be 0.1500 regardless of tariff={tariff_val!r}, got {value!r}" + )