M6-T05: add Tibber GraphQL client + price refresh service + schedule

- app/integrations/tibber/client.py: fetch_price_range/fetch_current_price
  (priceInfoRange QUARTER_HOURLY, startsAt->UTC, no fixed node count assumption),
  TibberError/TibberAuthError; token never logged or put in exception text.
- app/services/tibber_prices.py: refresh_prices upserts by starts_at (idempotent),
  no-op unless an active kind=tibber contract + token exist.
- main.py: hourly tibber-refresh job (existing jobs/MQTT untouched). Tests added.
This commit is contained in:
2026-06-23 21:35:29 +02:00
parent 4284bd40a7
commit 7e266a21eb
7 changed files with 1351 additions and 1 deletions
+39
View File
@@ -31,6 +31,7 @@ from app.services.config_page import build_runtime_settings, seed_missing_config
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 scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__)
@@ -63,6 +64,33 @@ def _run_scheduled_modbus_poll() -> None:
session.close()
def _run_scheduled_tibber_refresh() -> None:
"""Scheduled job: fetch Tibber 15-minute prices and upsert into tibber_price.
Runs every hour so that:
- Today's prices are available from startup.
- Tomorrow's prices (published by Tibber around 13:00 CET / 11:00 UTC) are
picked up within an hour of publication without requiring a server restart.
The job is a no-op when:
- No active energy contract with kind="tibber" exists.
- The Tibber API token is empty in the runtime settings.
Any client exceptions (auth failures, network errors) are caught and logged
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()
def _run_scheduled_ha_state_publish() -> None:
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
@@ -148,6 +176,17 @@ async def lifespan(_: FastAPI):
max_instances=1,
coalesce=True,
)
# Tibber price refresh: fetch today + tomorrow every hour.
# The job is a no-op when no active tibber contract or token is configured,
# so it is safe to register unconditionally.
scheduler.add_job(
_run_scheduled_tibber_refresh,
trigger=IntervalTrigger(hours=1),
id="tibber-refresh",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.start()
# MQTT: connect using DB-merged runtime settings so broker configured via UI