- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py). - summarize: fixed fee / tax credit accrue only for elapsed whole local days, integrated across contract versions — no future-day inflation, no version-switch collapse. - MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline apportionment), anchored at the active contract's earliest version effective_from so the total sensors stay monotonic and never jump backward. - effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC. - frontend ContractForm sends local-midnight naive datetime (was UTC midnight). - get_costs_summary default window uses server-local "today".
585 lines
20 KiB
Python
585 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_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_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_adjust = _d(energy.get("sell_adjust", 0))
|
||
|
||
points = []
|
||
for row in rows:
|
||
total = _d(row.total)
|
||
sell = float(total - energy_tax - 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"),
|
||
)
|