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.
This commit is contained in:
2026-06-24 15:39:21 +02:00
parent 6f8cb05eab
commit 9bc46f8d2d
9 changed files with 578 additions and 12 deletions
+3
View File
@@ -52,6 +52,9 @@ HA_DISCOVERY_PREFIX=homeassistant
# DSMR_INGEST_ENABLED=false # DSMR_INGEST_ENABLED=false
# DSMR_MQTT_TOPIC=dsmr/json # DSMR_MQTT_TOPIC=dsmr/json
# DSMR_SAMPLE_INTERVAL_S=10 # 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). # Optional: Tibber dynamic pricing credentials (M6).
# Only used when an active energy contract with kind=tibber is configured. # Only used when an active energy contract with kind=tibber is configured.
+3
View File
@@ -63,6 +63,9 @@ class Settings(BaseSettings):
dsmr_ingest_enabled: bool = False dsmr_ingest_enabled: bool = False
dsmr_mqtt_topic: str = "dsmr/json" dsmr_mqtt_topic: str = "dsmr/json"
dsmr_sample_interval_s: int = 10 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 dynamic pricing credentials (M6; only used when active contract kind=tibber).
tibber_api_token: str = "" tibber_api_token: str = ""
+35 -2
View File
@@ -449,9 +449,21 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
# --- value_getter: current buy price --- # --- value_getter: current buy price ---
def _make_buy_price_getter() -> Callable[["Session"], Any]: 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: def _getter(sess: "Session") -> Any:
from app.models.energy import EnergyCostPeriod as _ECP from app.models.energy import EnergyCostPeriod as _ECP
from app.services.dsmr_ingest import get_current_tariff
period = ( period = (
sess.query(_ECP) sess.query(_ECP)
@@ -466,6 +478,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
if kind == "tibber": if kind == "tibber":
raw = snap.get("buy") raw = snap.get("buy")
elif kind == "manual": elif kind == "manual":
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") raw = snap.get("buy_normal")
else: else:
# Unknown kind — attempt common keys gracefully. # Unknown kind — attempt common keys gracefully.
@@ -482,9 +499,20 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
# --- value_getter: current sell price --- # --- value_getter: current sell price ---
def _make_sell_price_getter() -> Callable[["Session"], Any]: 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: def _getter(sess: "Session") -> Any:
from app.models.energy import EnergyCostPeriod as _ECP from app.models.energy import EnergyCostPeriod as _ECP
from app.services.dsmr_ingest import get_current_tariff
period = ( period = (
sess.query(_ECP) sess.query(_ECP)
@@ -499,6 +527,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
if kind == "tibber": if kind == "tibber":
raw = snap.get("sell") raw = snap.get("sell")
elif kind == "manual": elif kind == "manual":
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") raw = snap.get("sell_normal")
else: else:
raw = snap.get("sell") or snap.get("sell_normal") raw = snap.get("sell") or snap.get("sell_normal")
+7
View File
@@ -138,6 +138,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
"DSMR Sample Interval (s)", "DSMR Sample Interval (s)",
input_type="number", input_type="number",
), ),
ConfigField(
"DSMR",
"DSMR_TARIFF_TOPIC",
"dsmr_tariff_topic",
"DSMR Tariff Topic",
),
ConfigField( ConfigField(
"Tibber", "Tibber",
"TIBBER_API_TOKEN", "TIBBER_API_TOKEN",
@@ -348,6 +354,7 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled, "dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic, "dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s, "dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
"dsmr_tariff_topic": settings.dsmr_tariff_topic,
"tibber_api_token": settings.tibber_api_token, "tibber_api_token": settings.tibber_api_token,
"tibber_home_id": settings.tibber_home_id, "tibber_home_id": settings.tibber_home_id,
} }
+96 -5
View File
@@ -32,6 +32,7 @@ from __future__ import annotations
import json import json
import logging import logging
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -51,17 +52,81 @@ logger = logging.getLogger(__name__)
# change can unsubscribe the old topic before subscribing the new one. # change can unsubscribe the old topic before subscribing the new one.
_current_dsmr_topic: str | None = None _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: 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 Call this at startup and after every config save. It makes the live MQTT
subscription reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic`` subscriptions reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic``
/ ``dsmr_sample_interval_s`` settings without an app restart: / ``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 - **Enabled** → (re)subscribe to ``dsmr_mqtt_topic`` with a handler bound to
a *fresh* settings snapshot, so a changed sample interval also takes effect. 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. - **Topic changed** → unsubscribe the old topic before subscribing the new one.
Idempotent and safe to call when MQTT is not connected (the subscription is 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. # Imported here (not at module top) to avoid a circular import at app start.
from app.integrations.mqtt import mqtt_manager 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 not settings.dsmr_ingest_enabled:
if _current_dsmr_topic is not None: if _current_dsmr_topic is not None:
mqtt_manager.unsubscribe(_current_dsmr_topic) mqtt_manager.unsubscribe(_current_dsmr_topic)
logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic) logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic)
_current_dsmr_topic = None _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 return
# --- Main DSMR telegram topic ---
topic = settings.dsmr_mqtt_topic topic = settings.dsmr_mqtt_topic
if _current_dsmr_topic is not None and _current_dsmr_topic != topic: if _current_dsmr_topic is not None and _current_dsmr_topic != topic:
mqtt_manager.unsubscribe(_current_dsmr_topic) mqtt_manager.unsubscribe(_current_dsmr_topic)
@@ -90,6 +163,24 @@ def apply_dsmr_subscription(settings: "Settings") -> None:
_current_dsmr_topic = topic _current_dsmr_topic = topic
logger.info("DSMR ingest enabled — subscribed to topic=%s.", 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: def handle_message(payload_bytes: bytes, settings: "Settings") -> None:
"""Parse one DSMR MQTT payload and persist it if it passes the sample filter. """Parse one DSMR MQTT payload and persist it if it passes the sample filter.
+49 -1
View File
@@ -732,7 +732,7 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
def test_get_config_includes_dsmr_section(client: TestClient) -> None: 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) _login(client)
response = client.get("/api/config") 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_INGEST_ENABLED" in env_names
assert "DSMR_MQTT_TOPIC" in env_names assert "DSMR_MQTT_TOPIC" in env_names
assert "DSMR_SAMPLE_INTERVAL_S" 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: 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" 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( def test_get_config_tibber_api_token_value_masked_after_save(
client: TestClient, test_database_urls client: TestClient, test_database_urls
) -> None: ) -> None:
+92
View File
@@ -410,3 +410,95 @@ def test_timestamp_with_utc_offset_suffix(dsmr_db):
_call_handle_message(data, settings, SessionLocal) _call_handle_message(data, settings, SessionLocal)
assert _count_readings(SessionLocal) == 1 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
+83 -2
View File
@@ -26,11 +26,18 @@ class _FakeMqtt:
self.unsubscribe_calls.append(topic) 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 = MagicMock()
s.dsmr_ingest_enabled = enabled s.dsmr_ingest_enabled = enabled
s.dsmr_mqtt_topic = topic s.dsmr_mqtt_topic = topic
s.dsmr_sample_interval_s = interval s.dsmr_sample_interval_s = interval
s.dsmr_tariff_topic = tariff_topic
return s return s
@@ -40,8 +47,9 @@ def fake_mqtt(monkeypatch):
# apply_dsmr_subscription does `from app.integrations.mqtt import mqtt_manager` # apply_dsmr_subscription does `from app.integrations.mqtt import mqtt_manager`
# at call time, so patching the module attribute is picked up. # at call time, so patching the module attribute is picked up.
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake) 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_dsmr_topic", None)
monkeypatch.setattr(dsmr_ingest, "_current_tariff_topic", None)
return fake return fake
@@ -90,3 +98,76 @@ def test_disabled_when_never_enabled_is_noop(fake_mqtt):
assert fake_mqtt.subscribe_calls == [] assert fake_mqtt.subscribe_calls == []
assert fake_mqtt.unsubscribe_calls == [] assert fake_mqtt.unsubscribe_calls == []
assert dsmr_ingest._current_dsmr_topic is None 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
+208
View File
@@ -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), ( assert value == pytest.approx(2.00, rel=1e-9), (
f"Expected pure SUM=2.00 with no active contract, got {value!r}" 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}"
)