M8-R06: refresh Tibber prices after startup and configuration
This commit is contained in:
@@ -22,6 +22,7 @@ from app.schemas.config import (
|
||||
from app.services.auth import AuthenticatedSession
|
||||
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.tibber_prices import active_tibber_contract_exists, trigger_tibber_refresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,6 +66,10 @@ def put_config(
|
||||
# Detect whether any MQTT-related key is being submitted (non-secret change
|
||||
# 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)
|
||||
tibber_values_before = (
|
||||
settings.tibber_api_token,
|
||||
settings.tibber_home_id,
|
||||
)
|
||||
|
||||
try:
|
||||
save_config_updates(db, body.updates, settings)
|
||||
@@ -92,6 +97,13 @@ def put_config(
|
||||
from app.services.dsmr_ingest import apply_dsmr_subscription
|
||||
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)
|
||||
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from app.services.contracts import (
|
||||
get_contract_or_none,
|
||||
list_contracts,
|
||||
)
|
||||
from app.services.tibber_prices import trigger_tibber_refresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -273,6 +274,7 @@ def patch_energy_contract(
|
||||
scope-local mutual exclusion.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
was_active = contract.active
|
||||
|
||||
if body.name is not None:
|
||||
contract.name = body.name
|
||||
@@ -285,6 +287,8 @@ def patch_energy_contract(
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
if body.active is True and not was_active and contract.kind == "tibber":
|
||||
trigger_tibber_refresh()
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
|
||||
+7
-10
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
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.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
|
||||
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.meter_cost import compute_closed_periods as compute_closed_meter_cost_periods
|
||||
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
|
||||
other background jobs.
|
||||
"""
|
||||
session_local = get_session_local()
|
||||
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()
|
||||
run_tibber_refresh_best_effort()
|
||||
|
||||
|
||||
def _run_scheduled_energy_cost() -> None:
|
||||
@@ -264,6 +257,10 @@ async def lifespan(_: FastAPI):
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
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.
|
||||
# The job is a no-op when no active contract or DSMR data is present, so it is
|
||||
|
||||
@@ -30,6 +30,7 @@ Design decisions
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from threading import Lock, Thread
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -41,7 +42,10 @@ from app.models.energy import EnergyContract, TibberPrice
|
||||
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'."""
|
||||
row = session.execute(
|
||||
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")
|
||||
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")
|
||||
return 0
|
||||
|
||||
home_id: str = getattr(settings, "tibber_home_id", "") or ""
|
||||
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.
|
||||
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)
|
||||
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__)
|
||||
|
||||
Reference in New Issue
Block a user