M6-T09: add Energy data API (prices/costs/summary/dsmr-latest/recompute/tibber-test)
- routes/api/energy.py + schemas/energy.py: GET prices (tibber points or manual tariff, buy/sell consistent with the pricing strategies), GET costs (time window + limit, ascending), GET costs/summary (summarize passthrough), GET dsmr/latest, POST costs/recompute (idempotent, <=366d guard, CSRF), POST tibber/test (three-state, token never echoed, CSRF). - main.py registers router; OpenAPI re-exported; tests for all endpoints.
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
"""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
|
||||
|
||||
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.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if start is None:
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end is None:
|
||||
end = start + 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"),
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.energy import router as api_energy_router
|
||||
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
|
||||
from app.api.routes.api.expose import router as api_expose_router
|
||||
from app.api.routes.api.modbus import router as api_modbus_router
|
||||
@@ -288,6 +289,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(status.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_energy_router)
|
||||
app.include_router(api_energy_contracts_router)
|
||||
app.include_router(api_expose_router)
|
||||
app.include_router(api_modbus_router)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Pydantic schemas for the Energy data API (M6-T09).
|
||||
|
||||
Covers six endpoint groups under /api/energy:
|
||||
- GET /prices — price curve (tibber 15min points or manual tariff)
|
||||
- GET /costs — energy_cost_period rows (time-range, paginated)
|
||||
- GET /costs/summary — aggregated metered + standing charges − credits
|
||||
- GET /dsmr/latest — most recent dsmr_reading blob
|
||||
- POST /costs/recompute — explicit idempotent recompute (returns count)
|
||||
- POST /tibber/test — three-state Tibber connection test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PricePointSchema(BaseModel):
|
||||
"""A single 15-minute price point (tibber) or placeholder entry."""
|
||||
|
||||
starts_at: datetime
|
||||
buy: float = Field(description="All-in buy price in EUR/kWh (including taxes).")
|
||||
sell: float = Field(description="Net sell price in EUR/kWh.")
|
||||
level: str | None = Field(
|
||||
default=None,
|
||||
description="Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.",
|
||||
)
|
||||
|
||||
|
||||
class ManualTariffSchema(BaseModel):
|
||||
"""Fixed-tariff breakdown for manual contracts.
|
||||
|
||||
Prices are the *effective* buy prices as used by the billing engine
|
||||
(energy_buy_x + energy_tax + ode) and the raw sell prices.
|
||||
"""
|
||||
|
||||
buy_dal: float = Field(description="Effective buy price, low-tariff / dal (EUR/kWh).")
|
||||
buy_normal: float = Field(description="Effective buy price, normal / high-tariff (EUR/kWh).")
|
||||
sell_dal: float = Field(description="Sell price, low-tariff / dal (EUR/kWh).")
|
||||
sell_normal: float = Field(description="Sell price, normal / high-tariff (EUR/kWh).")
|
||||
|
||||
|
||||
class PricesResponse(BaseModel):
|
||||
"""Response for GET /api/energy/prices.
|
||||
|
||||
``kind`` mirrors the active contract kind:
|
||||
- ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.
|
||||
- ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
|
||||
- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
|
||||
|
||||
``currency`` comes from the active contract (or "EUR" fallback).
|
||||
``points`` is always ascending by ``starts_at``.
|
||||
"""
|
||||
|
||||
kind: str | None = Field(
|
||||
description="Active contract kind ('tibber' or 'manual'), or null if no active contract."
|
||||
)
|
||||
currency: str = Field(description="ISO 4217 currency code.")
|
||||
points: list[PricePointSchema] = Field(
|
||||
description=(
|
||||
"15-minute price points for tibber contracts (ascending by starts_at). "
|
||||
"Empty for manual contracts or when no active contract exists."
|
||||
)
|
||||
)
|
||||
tariff: ManualTariffSchema | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Fixed tariff table for manual contracts. "
|
||||
"Null for tibber contracts and when no active contract exists."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CostPeriodSchema(BaseModel):
|
||||
"""One 15-minute billing record from the energy_cost_period table."""
|
||||
|
||||
period_start: datetime
|
||||
d1_kwh: float = Field(description="Delivered low-tariff kWh for this period.")
|
||||
d2_kwh: float = Field(description="Delivered normal-tariff kWh for this period.")
|
||||
r1_kwh: float = Field(description="Returned low-tariff kWh for this period.")
|
||||
r2_kwh: float = Field(description="Returned normal-tariff kWh for this period.")
|
||||
import_cost: float = Field(description="Cost of electricity drawn from grid (EUR).")
|
||||
export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).")
|
||||
net_cost: float = Field(description="import_cost − export_revenue (EUR).")
|
||||
currency: str = Field(description="ISO 4217 currency code.")
|
||||
degraded: bool = Field(
|
||||
description="True when the period was computed with incomplete data."
|
||||
)
|
||||
contract_version_id: int | None = Field(
|
||||
default=None,
|
||||
description="FK to the contract version used for this billing period (null when degraded).",
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CostsResponse(BaseModel):
|
||||
"""Response for GET /api/energy/costs."""
|
||||
|
||||
items: list[CostPeriodSchema]
|
||||
total: int = Field(description="Number of items returned.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
"""Response for GET /api/energy/costs/summary.
|
||||
|
||||
All monetary values are in ``currency``.
|
||||
|
||||
``total_payable = metered_net + fixed_costs − credits``
|
||||
"""
|
||||
|
||||
currency: str
|
||||
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
|
||||
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
|
||||
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
|
||||
fixed_costs: float = Field(
|
||||
description="Standing charges (network_fee + management_fee) apportioned over the interval."
|
||||
)
|
||||
credits: float = Field(
|
||||
description="Energy-tax credit (heffingskorting) apportioned over the interval."
|
||||
)
|
||||
total_payable: float = Field(
|
||||
description="metered_net + fixed_costs − credits (actual amount owed)."
|
||||
)
|
||||
period_count: int = Field(description="Number of non-degraded billing periods in range.")
|
||||
degraded_count: int = Field(description="Number of degraded billing periods in range.")
|
||||
days: float = Field(description="Interval length in days.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DsmrLatestResponse(BaseModel):
|
||||
"""Response for GET /api/energy/dsmr/latest.
|
||||
|
||||
``found`` is False when no dsmr_reading rows exist yet. The front-end
|
||||
should check ``found`` before reading ``recorded_at`` or ``payload``.
|
||||
"""
|
||||
|
||||
found: bool
|
||||
recorded_at: datetime | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecomputeResponse(BaseModel):
|
||||
"""Response for POST /api/energy/costs/recompute."""
|
||||
|
||||
recomputed: int = Field(
|
||||
description=(
|
||||
"Number of 15-minute periods for which a billing record was written "
|
||||
"(inserted or updated). Periods skipped due to missing contract or "
|
||||
"missing Tibber price are not counted."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TibberTestPriceSchema(BaseModel):
|
||||
"""Current Tibber price point returned on a successful test.
|
||||
|
||||
Carries enough fields for the front-end to confirm the API is working and
|
||||
display the live price. The API token is **never** included.
|
||||
"""
|
||||
|
||||
starts_at: datetime
|
||||
total: float
|
||||
energy: float
|
||||
tax: float
|
||||
currency: str
|
||||
level: str | None = None
|
||||
|
||||
|
||||
class TibberTestResponse(BaseModel):
|
||||
"""Three-state response for POST /api/energy/tibber/test.
|
||||
|
||||
Possible ``result`` values:
|
||||
|
||||
- ``"success"`` — Tibber API responded with a valid price.
|
||||
- ``"config-error"`` — Token is missing or not configured.
|
||||
- ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.).
|
||||
|
||||
``price`` is populated only on ``"success"``; it is null otherwise.
|
||||
``message`` always contains a human-readable explanation.
|
||||
"""
|
||||
|
||||
result: Literal["success", "config-error", "failed"]
|
||||
message: str
|
||||
price: TibberTestPriceSchema | None = None
|
||||
Reference in New Issue
Block a user