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:
@@ -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.
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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,7 +478,12 @@ 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":
|
||||||
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:
|
else:
|
||||||
# Unknown kind — attempt common keys gracefully.
|
# Unknown kind — attempt common keys gracefully.
|
||||||
raw = snap.get("buy") or snap.get("buy_normal")
|
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 ---
|
# --- 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,7 +527,12 @@ 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":
|
||||||
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:
|
else:
|
||||||
raw = snap.get("sell") or snap.get("sell_normal")
|
raw = snap.get("sell") or snap.get("sell_normal")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user