- 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.
140 lines
5.3 KiB
Python
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
|