M8-R06: refresh Tibber prices after startup and configuration

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 0924e8df52
commit 09abe05f66
8 changed files with 275 additions and 14 deletions
+12
View File
@@ -22,6 +22,7 @@ from app.schemas.config import (
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
from app.services.tibber_prices import active_tibber_contract_exists, trigger_tibber_refresh
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -65,6 +66,10 @@ def put_config(
# Detect whether any MQTT-related key is being submitted (non-secret change # Detect whether any MQTT-related key is being submitted (non-secret change
# or non-blank secret change) so we know to reconnect after saving. # or non-blank secret change) so we know to reconnect after saving.
mqtt_keys_submitted = any(k.lower() in MQTT_SETTINGS_KEYS for k in body.updates) mqtt_keys_submitted = any(k.lower() in MQTT_SETTINGS_KEYS for k in body.updates)
tibber_values_before = (
settings.tibber_api_token,
settings.tibber_home_id,
)
try: try:
save_config_updates(db, body.updates, settings) save_config_updates(db, body.updates, settings)
@@ -92,6 +97,13 @@ def put_config(
from app.services.dsmr_ingest import apply_dsmr_subscription from app.services.dsmr_ingest import apply_dsmr_subscription
apply_dsmr_subscription(refreshed_settings) apply_dsmr_subscription(refreshed_settings)
tibber_values_changed = tibber_values_before != (
refreshed_settings.tibber_api_token,
refreshed_settings.tibber_home_id,
)
if tibber_values_changed and active_tibber_contract_exists(db):
trigger_tibber_refresh()
sections_raw = build_config_sections(db, refreshed_settings) sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw)) return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
+4
View File
@@ -56,6 +56,7 @@ from app.services.contracts import (
get_contract_or_none, get_contract_or_none,
list_contracts, list_contracts,
) )
from app.services.tibber_prices import trigger_tibber_refresh
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -273,6 +274,7 @@ def patch_energy_contract(
scope-local mutual exclusion. scope-local mutual exclusion.
""" """
contract = _get_contract_or_404(db, contract_id) contract = _get_contract_or_404(db, contract_id)
was_active = contract.active
if body.name is not None: if body.name is not None:
contract.name = body.name contract.name = body.name
@@ -285,6 +287,8 @@ def patch_energy_contract(
db.commit() db.commit()
db.refresh(contract) db.refresh(contract)
if body.active is True and not was_active and contract.kind == "tibber":
trigger_tibber_refresh()
return _contract_detail(db, contract) return _contract_detail(db, contract)
+7 -10
View File
@@ -1,6 +1,7 @@
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -38,7 +39,7 @@ from app.services.dsmr_ingest import apply_dsmr_subscription
from app.services.public_ip import check_public_ipv4_and_notify from app.services.public_ip import check_public_ipv4_and_notify
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
from app.services.ha_discovery import publish_discovery, publish_states from app.services.ha_discovery import publish_discovery, publish_states
from app.services.tibber_prices import refresh_prices from app.services.tibber_prices import run_tibber_refresh_best_effort
from app.services.energy_cost import compute_closed_periods from app.services.energy_cost import compute_closed_periods
from app.services.meter_cost import compute_closed_periods as compute_closed_meter_cost_periods from app.services.meter_cost import compute_closed_periods as compute_closed_meter_cost_periods
from app.services.warmtelink_worker import warmtelink_worker_manager from app.services.warmtelink_worker import warmtelink_worker_manager
@@ -91,15 +92,7 @@ def _run_scheduled_tibber_refresh() -> None:
so that a single failed fetch does not crash the scheduler or affect the so that a single failed fetch does not crash the scheduler or affect the
other background jobs. other background jobs.
""" """
session_local = get_session_local() run_tibber_refresh_best_effort()
session: Session = session_local()
try:
runtime_settings = build_runtime_settings(session, get_settings())
refresh_prices(session, runtime_settings)
except Exception:
logger.exception("_run_scheduled_tibber_refresh: unexpected error")
finally:
session.close()
def _run_scheduled_energy_cost() -> None: def _run_scheduled_energy_cost() -> None:
@@ -264,6 +257,10 @@ async def lifespan(_: FastAPI):
replace_existing=True, replace_existing=True,
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
# APScheduler otherwise waits one full interval before its first run.
# This preserves the hourly cadence while requesting a non-blocking
# startup fetch as soon as the scheduler starts.
next_run_time=datetime.now(UTC),
) )
# Energy cost billing: compute uncalculated closed 15-minute periods every minute. # Energy cost billing: compute uncalculated closed 15-minute periods every minute.
# The job is a no-op when no active contract or DSMR data is present, so it is # The job is a no-op when no active contract or DSMR data is present, so it is
+66 -3
View File
@@ -30,6 +30,7 @@ Design decisions
from __future__ import annotations from __future__ import annotations
import logging import logging
from threading import Lock, Thread
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
@@ -41,7 +42,10 @@ from app.models.energy import EnergyContract, TibberPrice
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _active_tibber_contract_exists(session: Session) -> bool: _background_refresh_lock = Lock()
def active_tibber_contract_exists(session: Session) -> bool:
"""Return True if there is an active contract with kind='tibber'.""" """Return True if there is an active contract with kind='tibber'."""
row = session.execute( row = session.execute(
select(EnergyContract).where( select(EnergyContract).where(
@@ -84,14 +88,17 @@ def refresh_prices(session: Session, settings: object) -> int:
logger.debug("refresh_prices: tibber_api_token is empty — no-op") logger.debug("refresh_prices: tibber_api_token is empty — no-op")
return 0 return 0
if not _active_tibber_contract_exists(session): if not active_tibber_contract_exists(session):
logger.debug("refresh_prices: no active tibber contract — no-op") logger.debug("refresh_prices: no active tibber contract — no-op")
return 0 return 0
home_id: str = getattr(settings, "tibber_home_id", "") or "" home_id: str = getattr(settings, "tibber_home_id", "") or ""
home_id_or_none: str | None = home_id.strip() or None home_id_or_none: str | None = home_id.strip() or None
logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none) # Neither the API token nor the selected home identifier is safe to emit in
# diagnostics. The fetch client receives them, but logs only describe the
# operation itself.
logger.info("refresh_prices: fetching Tibber price range")
# May raise TibberError or TibberAuthError — let them propagate. # May raise TibberError or TibberAuthError — let them propagate.
price_points = fetch_price_range(token, home_id_or_none) price_points = fetch_price_range(token, home_id_or_none)
@@ -137,3 +144,59 @@ def refresh_prices(session: Session, settings: object) -> int:
logger.info("refresh_prices: upserted %d price points", upserted) logger.info("refresh_prices: upserted %d price points", upserted)
return upserted return upserted
def run_tibber_refresh_best_effort() -> bool:
"""Run one refresh with an isolated session, skipping concurrent requests.
This is shared by the hourly scheduler and immediate post-commit triggers.
It intentionally catches all failures: refresh is advisory and must never
make app startup or a successfully committed configuration/contract update
appear to have failed. The boolean reports whether this invocation owned
the work; it is primarily useful for tests and diagnostics.
"""
if not _background_refresh_lock.acquire(blocking=False):
logger.debug("Tibber refresh already running; skipping duplicate request")
return False
session: Session | None = None
try:
# Local imports keep the pure refresh service free of app startup import
# cycles, while every background invocation gets a fresh DB session.
from app.config import get_settings
from app.db import get_session_local
from app.services.config_page import build_runtime_settings
session = get_session_local()()
refresh_prices(session, build_runtime_settings(session, get_settings()))
except Exception as exc:
# Exception text can contain remote request details. Keep diagnostics
# useful without allowing a token or home id to escape through logging.
logger.warning("Tibber price refresh failed (%s)", type(exc).__name__)
if session is not None:
try:
session.rollback()
except Exception:
logger.warning("Tibber price refresh rollback failed")
finally:
if session is not None:
try:
session.close()
except Exception:
logger.warning("Tibber price refresh session close failed")
_background_refresh_lock.release()
return True
def trigger_tibber_refresh() -> None:
"""Request a non-blocking, best-effort Tibber refresh after a DB commit."""
try:
Thread(
target=run_tibber_refresh_best_effort,
name="tibber-price-refresh",
daemon=True,
).start()
except Exception as exc:
# Starting the optional worker must not turn an already committed API
# operation into a failure; avoid logging exception text for secrecy.
logger.warning("Unable to start Tibber price refresh (%s)", type(exc).__name__)
+49
View File
@@ -946,6 +946,55 @@ def test_get_config_tibber_api_token_value_masked_after_save(
assert "some-tibber-token" not in resp_after.text assert "some-tibber-token" not in resp_after.text
def test_put_tibber_config_triggers_refresh_only_for_active_tibber_contract(
client: TestClient, test_database_urls
) -> None:
"""Saved Tibber credentials request a refresh only when their contract is active."""
_login(client)
conn = sqlite3.connect(test_database_urls["app_path"])
try:
conn.execute(
"INSERT INTO energy_contract (name, kind, scope, active, currency, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
("Tibber", "tibber", "electricity", True, "EUR"),
)
conn.commit()
finally:
conn.close()
with patch("app.api.routes.api.config.trigger_tibber_refresh") as trigger:
response = client.put(
"/api/config",
json={"updates": _full_config_payload({"TIBBER_API_TOKEN": "new-secret-token"})},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 200
trigger.assert_called_once()
with patch("app.api.routes.api.config.trigger_tibber_refresh") as trigger:
response = client.put(
"/api/config",
json={"updates": _full_config_payload({"TIBBER_API_TOKEN": ""})},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 200
trigger.assert_not_called()
def test_put_tibber_config_does_not_refresh_without_active_tibber_contract(client: TestClient) -> None:
_login(client)
with patch("app.api.routes.api.config.trigger_tibber_refresh") as trigger:
response = client.put(
"/api/config",
json={"updates": _full_config_payload({"TIBBER_HOME_ID": "new-home-id"})},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 200
trigger.assert_not_called()
def test_post_mqtt_test_uses_db_broker_host( def test_post_mqtt_test_uses_db_broker_host(
client: TestClient, test_database_urls client: TestClient, test_database_urls
) -> None: ) -> None:
+47
View File
@@ -48,6 +48,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from unittest.mock import patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -614,6 +615,52 @@ def test_activate_contract_mutual_exclusion(contracts_client):
assert active_contracts[0].id == id_b assert active_contracts[0].id == id_b
def test_activating_tibber_contract_triggers_refresh_only_after_commit(contracts_client):
"""Inactive→active Tibber is the only contract transition that requests refresh."""
client, engine = contracts_client
_login(client)
created = client.post(
"/api/energy/contracts",
json=_tibber_payload(),
headers={"X-CSRF-Token": _CSRF},
)
contract_id = created.json()["id"]
with patch("app.api.routes.api.energy_contracts.trigger_tibber_refresh") as trigger:
response = client.patch(
f"/api/energy/contracts/{contract_id}",
json={"active": True},
headers={"X-CSRF-Token": _CSRF},
)
assert response.status_code == 200
assert trigger.call_count == 1
with Session(engine) as session:
assert session.get(EnergyContract, contract_id).active is True
with patch("app.api.routes.api.energy_contracts.trigger_tibber_refresh") as trigger:
response = client.patch(
f"/api/energy/contracts/{contract_id}",
json={"active": True},
headers={"X-CSRF-Token": _CSRF},
)
assert response.status_code == 200
trigger.assert_not_called()
manual = client.post(
"/api/energy/contracts",
json=_manual_payload(),
headers={"X-CSRF-Token": _CSRF},
).json()
with patch("app.api.routes.api.energy_contracts.trigger_tibber_refresh") as trigger:
response = client.patch(
f"/api/energy/contracts/{manual['id']}",
json={"active": True},
headers={"X-CSRF-Token": _CSRF},
)
assert response.status_code == 200
trigger.assert_not_called()
def test_deactivate_contract(contracts_client): def test_deactivate_contract(contracts_client):
"""PATCH active=false deactivates the contract without touching others.""" """PATCH active=false deactivates the contract without touching others."""
client, _ = contracts_client client, _ = contracts_client
+37
View File
@@ -152,3 +152,40 @@ def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value(
get_settings.cache_clear() get_settings.cache_clear()
reset_db_caches() reset_db_caches()
def test_lifespan_schedules_immediate_and_hourly_tibber_refresh(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The single Tibber interval job starts immediately while retaining its hourly trigger."""
import app.main as main
app_database_url = _prepare_app_db(tmp_path)
added_jobs = []
class _Scheduler:
def __init__(self, **_kwargs): pass
def add_job(self, func, **kwargs): added_jobs.append((func, kwargs))
def start(self): pass
def shutdown(self, **_kwargs): pass
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
monkeypatch.setattr(main, "BackgroundScheduler", _Scheduler)
monkeypatch.setattr(main.mqtt_manager, "connect", lambda _settings: None)
monkeypatch.setattr(main.mqtt_manager, "disconnect", lambda: None)
monkeypatch.setattr(main, "apply_dsmr_subscription", lambda _settings: None)
monkeypatch.setattr(main.warmtelink_worker_manager, "start", lambda: None)
monkeypatch.setattr(main.warmtelink_worker_manager, "shutdown", lambda: None)
get_settings.cache_clear()
reset_db_caches()
anyio.run(_run_lifespan, create_app())
tibber_jobs = [kwargs for func, kwargs in added_jobs if func is main._run_scheduled_tibber_refresh]
assert len(tibber_jobs) == 1
assert tibber_jobs[0]["id"] == "tibber-refresh"
assert tibber_jobs[0]["max_instances"] == 1
assert tibber_jobs[0]["next_run_time"] is not None
get_settings.cache_clear()
reset_db_caches()
+53 -1
View File
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
from app.integrations.tibber.client import PricePoint, TibberError from app.integrations.tibber.client import PricePoint, TibberError
from app.models.energy import EnergyContract, EnergyContractVersion, TibberPrice from app.models.energy import EnergyContract, EnergyContractVersion, TibberPrice
from app.services.tibber_prices import refresh_prices from app.services.tibber_prices import refresh_prices, run_tibber_refresh_best_effort, trigger_tibber_refresh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -392,3 +392,55 @@ def test_refresh_prices_propagates_client_error(energy_db, monkeypatch):
# No rows should have been written. # No rows should have been written.
assert _count_tibber_price_rows(session) == 0 assert _count_tibber_price_rows(session) == 0
def test_best_effort_refresh_uses_isolated_session_and_sanitises_failure(monkeypatch, caplog):
"""Background failures close their own session and never log supplied secrets."""
import app.db as db_module
import app.services.config_page as config_page
import app.services.tibber_prices as tibber_prices
token = "secret-token-must-not-appear"
home_id = "secret-home-must-not-appear"
settings = _FakeSettings(token=token, home_id=home_id)
class _Session:
rolled_back = False
closed = False
def rollback(self):
self.rolled_back = True
def close(self):
self.closed = True
session = _Session()
monkeypatch.setattr(db_module, "get_session_local", lambda: lambda: session)
monkeypatch.setattr(config_page, "build_runtime_settings", lambda *_args: settings)
monkeypatch.setattr(tibber_prices, "refresh_prices", lambda *_args: (_ for _ in ()).throw(RuntimeError(token)))
assert run_tibber_refresh_best_effort() is True
assert session.rolled_back is True
assert session.closed is True
assert token not in caplog.text
assert home_id not in caplog.text
def test_trigger_tibber_refresh_starts_daemon_without_running_inline(monkeypatch):
"""API triggers are non-blocking; the worker owns the eventual refresh."""
import app.services.tibber_prices as tibber_prices
captured = {}
class _Thread:
def __init__(self, **kwargs):
captured.update(kwargs)
def start(self):
captured["started"] = True
monkeypatch.setattr(tibber_prices, "Thread", _Thread)
trigger_tibber_refresh()
assert captured["started"] is True
assert captured["daemon"] is True
assert captured["target"] is run_tibber_refresh_best_effort