Tibber's API `total` already includes the buy-side inkoopvergoeding
(verified from production data: total = spot×1.21 + energy_tax 0.11085 +
inkoopvergoeding 0.0248). Under net metering Tibber pays back
`total − verkoopvergoeding` per returned kWh (NL: EUR 0.28 -> 0.2552), so the
two EUR 0.0248 fees do NOT cancel — the feed-in price sits 0.0248 below buy.
Model the verkoopvergoeding as a first-class, always-subtracted contract
field `energy.sell_fee` (default 0.0248) instead of folding it into
`sell_adjust`. New sell formula:
sell = total − energy_tax − sell_fee − sell_adjust
`sell_adjust` now carries only the net-metering energy-tax refund
(= −energy_tax). Applied in both the billing strategy and the /prices
endpoint; recorded in the pricing snapshot. Frontend renders the field
automatically (dynamic profile form). Docs (references, m6) corrected to
drop the wrong "fees cancel" premise.
587 lines
20 KiB
Python
587 lines
20 KiB
Python
"""Energy data API: prices, costs, summary, DSMR, recompute, Tibber test (M6-T09).
|
||
|
||
All endpoints are under /api/energy, require an authenticated session, and
|
||
write endpoints (POST) additionally require a non-empty X-CSRF-Token header.
|
||
|
||
Route prefix note
|
||
-----------------
|
||
This router shares the ``/api/energy`` prefix with ``energy_contracts.py``
|
||
(which handles contract CRUD at /contracts/* and /profiles). The sub-paths
|
||
used here (/prices, /costs, /costs/summary, /costs/recompute, /dsmr/latest,
|
||
/tibber/test) are disjoint from the contract router's paths, so there is no
|
||
conflict.
|
||
|
||
Tibber token security
|
||
---------------------
|
||
``POST /api/energy/tibber/test`` calls the Tibber API but **never** echoes the
|
||
token in the response body or in log messages. Three-state logic mirrors the
|
||
MQTT test endpoint (M5-T10, app/api/routes/api/config.py::post_mqtt_test):
|
||
|
||
200 { result: "success", message: ..., price: {...} }
|
||
400 { result: "config-error", message: ... }
|
||
502 { result: "failed", message: ... }
|
||
|
||
Recompute safety
|
||
----------------
|
||
``POST /api/energy/costs/recompute`` is idempotent: it calls
|
||
``energy_cost.recompute_range`` which upserts existing rows without deleting
|
||
anything. The endpoint enforces a maximum time-window of 366 days to avoid
|
||
unbounded recomputation triggered by erroneous client requests.
|
||
|
||
Prices endpoint behaviour
|
||
-------------------------
|
||
``GET /api/energy/prices`` queries the ``tibber_price`` table for tibber
|
||
contracts, or derives the effective fixed-tariff prices for manual contracts
|
||
using the same formula as the billing engine (_manual_strategy in strategies.py):
|
||
|
||
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
|
||
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
|
||
sell_dal = energy.sell.dal (no tax added to sell price)
|
||
sell_normal = energy.sell.normal
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Query, status
|
||
from fastapi.responses import JSONResponse
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.api.routes.api.deps import require_csrf, require_session
|
||
from app.dependencies import get_app_settings, get_db
|
||
from app.config import Settings
|
||
from app.integrations.tibber.client import (
|
||
TibberAuthError,
|
||
TibberError,
|
||
fetch_current_price,
|
||
)
|
||
from app.models.energy import DsmrReading, EnergyCostPeriod, TibberPrice
|
||
from app.schemas.energy import (
|
||
CostPeriodSchema,
|
||
CostsResponse,
|
||
DsmrLatestResponse,
|
||
ManualTariffSchema,
|
||
PricePointSchema,
|
||
PricesResponse,
|
||
RecomputeResponse,
|
||
SummaryResponse,
|
||
TibberTestPriceSchema,
|
||
TibberTestResponse,
|
||
)
|
||
from app.services.auth import AuthenticatedSession
|
||
from app.services.contracts import active_contract_version_at
|
||
from app.services.energy_cost import recompute_range, summarize
|
||
from app.services.timezone import local_midnight_utc, local_now
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/energy", tags=["api-energy"])
|
||
|
||
# Maximum number of cost periods returned per request (mirrors modbus readings cap).
|
||
_COSTS_LIMIT_MAX = 5000
|
||
|
||
# Maximum allowed time-window for recompute to prevent unbounded computation.
|
||
_RECOMPUTE_MAX_DAYS = 366
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _as_utc(dt: datetime) -> datetime:
|
||
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=UTC)
|
||
return dt
|
||
|
||
|
||
def _manual_tariff_from_values(values: dict[str, Any]) -> ManualTariffSchema:
|
||
"""Derive the effective fixed tariff from a manual contract version's values dict.
|
||
|
||
Mirrors the _manual_strategy formula (strategies.py):
|
||
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
|
||
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
|
||
sell_dal = energy.sell.dal (no tax added)
|
||
sell_normal = energy.sell.normal
|
||
"""
|
||
from decimal import Decimal
|
||
|
||
def _d(v: Any) -> Decimal:
|
||
return Decimal(str(v or 0))
|
||
|
||
energy = values.get("energy", {})
|
||
buy = energy.get("buy", {})
|
||
sell = energy.get("sell", {})
|
||
energy_tax = _d(energy.get("energy_tax", 0))
|
||
ode = _d(energy.get("ode", 0))
|
||
|
||
buy_dal = _d(buy.get("dal", 0)) + energy_tax + ode
|
||
buy_normal = _d(buy.get("normal", 0)) + energy_tax + ode
|
||
sell_dal = _d(sell.get("dal", 0))
|
||
sell_normal = _d(sell.get("normal", 0))
|
||
|
||
return ManualTariffSchema(
|
||
buy_dal=float(buy_dal),
|
||
buy_normal=float(buy_normal),
|
||
sell_dal=float(sell_dal),
|
||
sell_normal=float(sell_normal),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /api/energy/prices
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/prices", response_model=PricesResponse)
|
||
def get_prices(
|
||
start: datetime | None = Query(
|
||
default=None,
|
||
description="Inclusive start of the time window (ISO 8601). "
|
||
"Defaults to the start of today UTC when omitted.",
|
||
),
|
||
end: datetime | None = Query(
|
||
default=None,
|
||
description="Inclusive end of the time window (ISO 8601). "
|
||
"Defaults to the end of tomorrow UTC when omitted.",
|
||
),
|
||
limit: int = Query(
|
||
default=500,
|
||
ge=1,
|
||
le=_COSTS_LIMIT_MAX,
|
||
description="Maximum number of Tibber price points to return.",
|
||
),
|
||
db: Session = Depends(get_db),
|
||
_auth: AuthenticatedSession = Depends(require_session),
|
||
) -> PricesResponse:
|
||
"""Return the price curve for the active contract.
|
||
|
||
**Tibber contracts** (kind="tibber"):
|
||
Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending
|
||
by ``starts_at``. At most ``limit`` rows are returned (most recent first
|
||
within the window, then reversed to ascending order — identical to the
|
||
modbus readings pattern).
|
||
|
||
Response ``points`` carries per-slot:
|
||
- ``buy = total`` (Tibber all-inclusive price)
|
||
- ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)
|
||
- ``level`` (Tibber price level, may be null)
|
||
|
||
``tariff`` is null.
|
||
|
||
**Manual contracts** (kind="manual"):
|
||
``points`` is empty. ``tariff`` carries the four effective prices
|
||
derived using the billing engine formula:
|
||
- ``buy_dal = energy.buy.dal + energy_tax + ode``
|
||
- ``buy_normal = energy.buy.normal + energy_tax + ode``
|
||
- ``sell_dal = energy.sell.dal``
|
||
- ``sell_normal = energy.sell.normal``
|
||
|
||
**No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200).
|
||
"""
|
||
now = datetime.now(UTC)
|
||
|
||
# Default window: today + tomorrow.
|
||
if start is None:
|
||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
if end is None:
|
||
end = (start + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
start_utc = _as_utc(start)
|
||
end_utc = _as_utc(end)
|
||
|
||
# Resolve the active contract version at the start of the window.
|
||
version = active_contract_version_at(db, start_utc)
|
||
|
||
if version is None:
|
||
return PricesResponse(
|
||
kind=None,
|
||
currency="EUR",
|
||
points=[],
|
||
tariff=None,
|
||
)
|
||
|
||
contract = version.contract
|
||
currency = contract.currency
|
||
|
||
if contract.kind == "tibber":
|
||
# Fetch tibber_price rows in the window.
|
||
stmt = (
|
||
select(TibberPrice)
|
||
.where(
|
||
TibberPrice.starts_at >= start_utc,
|
||
TibberPrice.starts_at <= end_utc,
|
||
)
|
||
.order_by(TibberPrice.starts_at.desc())
|
||
.limit(limit)
|
||
)
|
||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||
|
||
# Derive sell price per-point using version values
|
||
# (energy_tax + sell_fee + sell_adjust).
|
||
from decimal import Decimal
|
||
|
||
def _d(v: Any) -> Decimal:
|
||
return Decimal(str(v or 0))
|
||
|
||
energy = version.values.get("energy", {}) if version.values else {}
|
||
energy_tax = _d(energy.get("energy_tax", 0))
|
||
sell_fee = _d(energy.get("sell_fee", 0))
|
||
sell_adjust = _d(energy.get("sell_adjust", 0))
|
||
|
||
points = []
|
||
for row in rows:
|
||
total = _d(row.total)
|
||
sell = float(total - energy_tax - sell_fee - sell_adjust)
|
||
points.append(
|
||
PricePointSchema(
|
||
starts_at=_as_utc(row.starts_at),
|
||
buy=row.total,
|
||
sell=sell,
|
||
level=row.level,
|
||
)
|
||
)
|
||
|
||
return PricesResponse(
|
||
kind="tibber",
|
||
currency=currency,
|
||
points=points,
|
||
tariff=None,
|
||
)
|
||
|
||
elif contract.kind == "manual":
|
||
tariff = _manual_tariff_from_values(version.values or {})
|
||
return PricesResponse(
|
||
kind="manual",
|
||
currency=currency,
|
||
points=[],
|
||
tariff=tariff,
|
||
)
|
||
|
||
else:
|
||
# Unknown kind — return empty response gracefully.
|
||
return PricesResponse(
|
||
kind=contract.kind,
|
||
currency=currency,
|
||
points=[],
|
||
tariff=None,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /api/energy/costs
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/costs", response_model=CostsResponse)
|
||
def get_costs(
|
||
start: datetime | None = Query(
|
||
default=None,
|
||
description="Inclusive lower bound for period_start (ISO 8601).",
|
||
),
|
||
end: datetime | None = Query(
|
||
default=None,
|
||
description="Inclusive upper bound for period_start (ISO 8601).",
|
||
),
|
||
limit: int = Query(
|
||
default=500,
|
||
ge=1,
|
||
le=_COSTS_LIMIT_MAX,
|
||
description=f"Maximum number of cost periods to return (default 500, max {_COSTS_LIMIT_MAX}).",
|
||
),
|
||
db: Session = Depends(get_db),
|
||
_auth: AuthenticatedSession = Depends(require_session),
|
||
) -> CostsResponse:
|
||
"""Return energy_cost_period rows within a time window.
|
||
|
||
Rows are ordered by ``period_start`` ascending. When the window contains
|
||
more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
|
||
then reversed to ascending order — identical to the modbus readings pattern.
|
||
|
||
Query parameters:
|
||
- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
|
||
- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).
|
||
- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).
|
||
"""
|
||
stmt = (
|
||
select(EnergyCostPeriod)
|
||
.order_by(EnergyCostPeriod.period_start.desc())
|
||
.limit(limit)
|
||
)
|
||
|
||
if start is not None:
|
||
stmt = stmt.where(EnergyCostPeriod.period_start >= _as_utc(start))
|
||
if end is not None:
|
||
stmt = stmt.where(EnergyCostPeriod.period_start <= _as_utc(end))
|
||
|
||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||
items = [CostPeriodSchema.model_validate(r) for r in rows]
|
||
return CostsResponse(items=items, total=len(items))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /api/energy/costs/summary
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/costs/summary", response_model=SummaryResponse)
|
||
def get_costs_summary(
|
||
start: datetime | None = Query(
|
||
default=None,
|
||
description=(
|
||
"Inclusive start of the summary interval (ISO 8601). "
|
||
"Defaults to the start of the current UTC day."
|
||
),
|
||
),
|
||
end: datetime | None = Query(
|
||
default=None,
|
||
description=(
|
||
"Exclusive end of the summary interval (ISO 8601). "
|
||
"Defaults to the start of the next UTC day (i.e. today's full data)."
|
||
),
|
||
),
|
||
db: Session = Depends(get_db),
|
||
_auth: AuthenticatedSession = Depends(require_session),
|
||
) -> SummaryResponse:
|
||
"""Aggregate billing for a time interval.
|
||
|
||
Calls ``energy_cost.summarize(session, start, end)`` which computes:
|
||
|
||
total_payable = Σ(net_cost) + fixed_costs − credits
|
||
|
||
where ``fixed_costs`` is (network_fee + management_fee) apportioned to the
|
||
interval length in days (÷30 per month), and ``credits`` is heffingskorting
|
||
apportioned similarly (÷365 per year).
|
||
|
||
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
|
||
contract version at ``end``**. When no active contract exists they are 0.
|
||
"""
|
||
if start is None or end is None:
|
||
# Default to the server's local today: [local_midnight, next_local_midnight).
|
||
# This ensures "today" aligns with the local calendar day (NL time) rather
|
||
# than UTC midnight.
|
||
local_today = local_now().date()
|
||
if start is None:
|
||
start = local_midnight_utc(local_today)
|
||
if end is None:
|
||
end = local_midnight_utc(local_today + timedelta(days=1))
|
||
|
||
result = summarize(db, _as_utc(start), _as_utc(end))
|
||
return SummaryResponse(**result)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /api/energy/dsmr/latest
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/dsmr/latest", response_model=DsmrLatestResponse)
|
||
def get_dsmr_latest(
|
||
db: Session = Depends(get_db),
|
||
_auth: AuthenticatedSession = Depends(require_session),
|
||
) -> DsmrLatestResponse:
|
||
"""Return the most recent dsmr_reading row.
|
||
|
||
Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
|
||
404) when no rows exist yet, so the front-end can distinguish "no data" from
|
||
a server error.
|
||
"""
|
||
row = db.execute(
|
||
select(DsmrReading)
|
||
.order_by(DsmrReading.recorded_at.desc())
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
|
||
if row is None:
|
||
return DsmrLatestResponse(found=False)
|
||
|
||
return DsmrLatestResponse(
|
||
found=True,
|
||
recorded_at=_as_utc(row.recorded_at),
|
||
payload=row.payload,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST /api/energy/costs/recompute
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post(
|
||
"/costs/recompute",
|
||
responses={
|
||
200: {"model": RecomputeResponse},
|
||
422: {"description": "Validation error (missing window or range too large)"},
|
||
},
|
||
)
|
||
def post_recompute(
|
||
start: datetime = Query(
|
||
...,
|
||
description="Inclusive start of the recompute window (ISO 8601). Required.",
|
||
),
|
||
end: datetime = Query(
|
||
...,
|
||
description="Exclusive end of the recompute window (ISO 8601). Required.",
|
||
),
|
||
db: Session = Depends(get_db),
|
||
_auth: AuthenticatedSession = Depends(require_session),
|
||
_csrf: None = Depends(require_csrf),
|
||
) -> RecomputeResponse:
|
||
"""Idempotently recompute billing records in a time window.
|
||
|
||
Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
|
||
existing rows (including successful ones) for every UTC quarter-hour boundary
|
||
in ``[start, end)``.
|
||
|
||
**Idempotency**: repeated calls with the same window produce the same
|
||
outcome. No rows are deleted; only upserted.
|
||
|
||
**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.
|
||
Requests exceeding this return 422.
|
||
|
||
Returns the number of periods for which a billing record was written.
|
||
Periods skipped due to missing contract or missing Tibber price are not counted.
|
||
"""
|
||
start_utc = _as_utc(start)
|
||
end_utc = _as_utc(end)
|
||
|
||
if end_utc <= start_utc:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
detail="'end' must be strictly after 'start'.",
|
||
)
|
||
|
||
span_days = (end_utc - start_utc).total_seconds() / 86400
|
||
if span_days > _RECOMPUTE_MAX_DAYS:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
detail=(
|
||
f"Time window is {span_days:.1f} days which exceeds the maximum of "
|
||
f"{_RECOMPUTE_MAX_DAYS} days. Use a smaller window."
|
||
),
|
||
)
|
||
|
||
n = recompute_range(db, start_utc, end_utc)
|
||
logger.info(
|
||
"POST /api/energy/costs/recompute [%s, %s): wrote %d period(s).",
|
||
start_utc.isoformat(),
|
||
end_utc.isoformat(),
|
||
n,
|
||
)
|
||
return RecomputeResponse(recomputed=n)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST /api/energy/tibber/test
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post(
|
||
"/tibber/test",
|
||
responses={
|
||
200: {"model": TibberTestResponse},
|
||
400: {"model": TibberTestResponse},
|
||
502: {"model": TibberTestResponse},
|
||
},
|
||
)
|
||
def post_tibber_test(
|
||
settings: Settings = Depends(get_app_settings),
|
||
_auth: AuthenticatedSession = Depends(require_session),
|
||
_csrf: None = Depends(require_csrf),
|
||
) -> JSONResponse:
|
||
"""Test Tibber API connectivity by fetching the current price point.
|
||
|
||
Three possible outcomes:
|
||
|
||
- **200** ``{ result: "success", message: ..., price: {...} }``
|
||
The Tibber API responded with a valid current price. ``price`` contains
|
||
starts_at, total, energy, tax, currency, and level.
|
||
|
||
- **400** ``{ result: "config-error", message: ... }``
|
||
The Tibber API token is empty or not configured.
|
||
|
||
- **502** ``{ result: "failed", message: ... }``
|
||
The API call failed (authentication rejected, network error, timeout,
|
||
unexpected response, etc.).
|
||
|
||
The API token is **never** included in the response body or logged.
|
||
"""
|
||
token = settings.tibber_api_token
|
||
home_id = settings.tibber_home_id or None # treat empty string as None
|
||
|
||
if not token:
|
||
logger.info("POST /api/energy/tibber/test: no token configured.")
|
||
return JSONResponse(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
content=TibberTestResponse(
|
||
result="config-error",
|
||
message=(
|
||
"Tibber API token is not configured. "
|
||
"Set TIBBER_API_TOKEN in the Config page."
|
||
),
|
||
price=None,
|
||
).model_dump(mode="json"),
|
||
)
|
||
|
||
try:
|
||
price_point = fetch_current_price(token, home_id)
|
||
except TibberAuthError:
|
||
logger.warning("POST /api/energy/tibber/test: authentication failed.")
|
||
return JSONResponse(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
content=TibberTestResponse(
|
||
result="failed",
|
||
message=(
|
||
"Tibber API authentication failed. "
|
||
"Check that your API token is correct."
|
||
),
|
||
price=None,
|
||
).model_dump(mode="json"),
|
||
)
|
||
except TibberError as exc:
|
||
logger.warning("POST /api/energy/tibber/test: API call failed — %s", exc)
|
||
return JSONResponse(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
content=TibberTestResponse(
|
||
result="failed",
|
||
message=f"Tibber API call failed: {exc}",
|
||
price=None,
|
||
).model_dump(mode="json"),
|
||
)
|
||
|
||
price_schema = TibberTestPriceSchema(
|
||
starts_at=price_point.starts_at,
|
||
total=price_point.total,
|
||
energy=price_point.energy,
|
||
tax=price_point.tax,
|
||
currency=price_point.currency,
|
||
level=price_point.level,
|
||
)
|
||
|
||
logger.info(
|
||
"POST /api/energy/tibber/test: success (starts_at=%s, total=%s %s).",
|
||
price_point.starts_at.isoformat(),
|
||
price_point.total,
|
||
price_point.currency,
|
||
)
|
||
|
||
return JSONResponse(
|
||
status_code=status.HTTP_200_OK,
|
||
content=TibberTestResponse(
|
||
result="success",
|
||
message=(
|
||
f"Tibber API connected. Current price: "
|
||
f"{price_point.total} {price_point.currency}/kWh "
|
||
f"(starts {price_point.starts_at.isoformat()})."
|
||
),
|
||
price=price_schema,
|
||
).model_dump(mode="json"),
|
||
)
|