Files
tliu93 7e266a21eb 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.
2026-06-23 21:35:29 +02:00

140 lines
5.3 KiB
Python

"""Service layer for fetching and persisting Tibber 15-minute electricity prices.
``refresh_prices`` is the main entry point. It is designed to be called from a
scheduled background job (see ``app/main.py``) and from tests.
Design decisions
----------------
- **Guard clause**: if no active contract with ``kind="tibber"`` exists, or if
the Tibber API token is empty in the runtime settings, the function is a
complete no-op (returns 0) and does not raise. This means the scheduler can
call ``refresh_prices`` unconditionally; the service itself decides whether to
do anything based on current configuration.
- **Upsert idempotency**: rows are matched by ``starts_at`` (the unique
constraint on ``tibber_price``). If a row already exists, its price fields
and ``fetched_at`` are updated in-place; if it does not exist, a new row is
inserted. Running ``refresh_prices`` twice in a row must not double-insert.
- **No destructive operations**: this service never deletes ``tibber_price``
rows. Only INSERT or UPDATE.
- **Exception propagation**: exceptions from the Tibber client are *not*
swallowed here. The scheduled job wrapper in ``main.py`` is responsible for
catching and logging errors so that a single fetch failure does not crash the
scheduler.
- **SQLite timezone note**: ``DateTime(timezone=True)`` columns come back as
timezone-naive UTC datetimes on read. We store UTC-aware datetimes on write
(``starts_at`` from ``PricePoint`` is already UTC-aware; ``fetched_at`` is
``datetime.now(UTC)``). Reads elsewhere that compare against these values
must normalise accordingly.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.integrations.tibber.client import fetch_price_range
from app.models.energy import EnergyContract, TibberPrice
logger = logging.getLogger(__name__)
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(
EnergyContract.active.is_(True),
EnergyContract.kind == "tibber",
).limit(1)
).scalar_one_or_none()
return row is not None
def refresh_prices(session: Session, settings: object) -> int:
"""Fetch today-and-tomorrow Tibber prices and upsert them into ``tibber_price``.
Parameters
----------
session:
An active SQLAlchemy session. The caller is responsible for closing it;
this function commits each upserted row individually to keep transactions
short.
settings:
A runtime settings object that exposes ``tibber_api_token`` and
``tibber_home_id`` attributes (typically a ``Settings`` or merged
runtime-settings instance from ``build_runtime_settings``).
Returns
-------
int
Number of rows upserted (inserted or updated). Returns 0 for a no-op.
Notes
-----
- No-op (returns 0) when:
* No active contract exists with ``kind="tibber"``, OR
* ``settings.tibber_api_token`` is empty or whitespace.
- Exceptions from the Tibber client are *not* caught here; they propagate to
the caller (the scheduled job wrapper swallows them and logs).
"""
token: str = getattr(settings, "tibber_api_token", "") or ""
if not token.strip():
logger.debug("refresh_prices: tibber_api_token is empty — no-op")
return 0
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)
# May raise TibberError or TibberAuthError — let them propagate.
price_points = fetch_price_range(token, home_id_or_none)
if not price_points:
logger.info("refresh_prices: API returned zero price points — nothing to upsert")
return 0
fetched_at = datetime.now(UTC)
upserted = 0
for point in price_points:
existing = session.execute(
select(TibberPrice).where(TibberPrice.starts_at == point.starts_at)
).scalar_one_or_none()
if existing is not None:
# Update in-place — price fields may change (e.g. Tibber corrects a
# forecast), but starts_at and resolution stay the same.
existing.total = point.total
existing.energy = point.energy
existing.tax = point.tax
existing.level = point.level
existing.currency = point.currency
existing.fetched_at = fetched_at
else:
session.add(
TibberPrice(
starts_at=point.starts_at,
resolution=point.resolution,
total=point.total,
energy=point.energy,
tax=point.tax,
level=point.level,
currency=point.currency,
fetched_at=fetched_at,
)
)
upserted += 1
session.commit()
logger.info("refresh_prices: upserted %d price points", upserted)
return upserted