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
+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:
"""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:
+92
View File
@@ -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
+83 -2
View File
@@ -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
+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), (
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}"
)