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:
2026-06-23 23:05:45 +02:00
parent c61fc2d4ba
commit 57f2459f60
7 changed files with 3177 additions and 1 deletions
+580
View File
@@ -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"),
)
+2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app import models # noqa: F401 from app import models # noqa: F401
from app.api.routes.api.config import router as api_config_router 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.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.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.expose import router as api_expose_router
from app.api.routes.api.modbus import router as api_modbus_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(status.router)
app.include_router(api_config_router) app.include_router(api_config_router)
app.include_router(api_data_router) app.include_router(api_data_router)
app.include_router(api_energy_router)
app.include_router(api_energy_contracts_router) app.include_router(api_energy_contracts_router)
app.include_router(api_expose_router) app.include_router(api_expose_router)
app.include_router(api_modbus_router) app.include_router(api_modbus_router)
+216
View File
@@ -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
+1 -1
View File
@@ -405,7 +405,7 @@ Phase DAPI + 前端)
- **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。 - **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。
### M6-T09 — Energy 数据 API ### M6-T09 — Energy 数据 API
- **Status**: `todo` · **Depends**: M6-T05, M6-T07 - **Status**: `done` · **Depends**: M6-T05, M6-T07
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。 - **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py``modify app/main.py`(注册);`create tests/test_api_energy.py` - **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py``modify app/main.py`(注册);`create tests/test_api_energy.py`
- **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。 - **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。
+828
View File
@@ -695,6 +695,409 @@
} }
} }
}, },
"/api/energy/prices": {
"get": {
"tags": [
"api-energy"
],
"summary": "Get Prices",
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total energy_tax sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
"operationId": "get_prices_api_energy_prices_get",
"parameters": [
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"description": "Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted.",
"title": "Start"
},
"description": "Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted."
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"description": "Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted.",
"title": "End"
},
"description": "Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 5000,
"minimum": 1,
"description": "Maximum number of Tibber price points to return.",
"default": 500,
"title": "Limit"
},
"description": "Maximum number of Tibber price points to return."
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PricesResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/costs": {
"get": {
"tags": [
"api-energy"
],
"summary": "Get Costs",
"description": "Return energy_cost_period rows within a time window.\n\nRows are ordered by ``period_start`` ascending. When the window contains\nmore rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),\nthen reversed to ascending order — identical to the modbus readings pattern.\n\nQuery parameters:\n- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).\n- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).\n- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).",
"operationId": "get_costs_api_energy_costs_get",
"parameters": [
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"description": "Inclusive lower bound for period_start (ISO 8601).",
"title": "Start"
},
"description": "Inclusive lower bound for period_start (ISO 8601)."
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"description": "Inclusive upper bound for period_start (ISO 8601).",
"title": "End"
},
"description": "Inclusive upper bound for period_start (ISO 8601)."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 5000,
"minimum": 1,
"description": "Maximum number of cost periods to return (default 500, max 5000).",
"default": 500,
"title": "Limit"
},
"description": "Maximum number of cost periods to return (default 500, max 5000)."
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CostsResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/costs/summary": {
"get": {
"tags": [
"api-energy"
],
"summary": "Get Costs Summary",
"description": "Aggregate billing for a time interval.\n\nCalls ``energy_cost.summarize(session, start, end)`` which computes:\n\n total_payable = Σ(net_cost) + fixed_costs credits\n\nwhere ``fixed_costs`` is (network_fee + management_fee) apportioned to the\ninterval length in days (÷30 per month), and ``credits`` is heffingskorting\napportioned similarly (÷365 per year).\n\nBoth ``fixed_costs`` and ``credits`` are derived from the **currently active\ncontract version at ``end``**. When no active contract exists they are 0.",
"operationId": "get_costs_summary_api_energy_costs_summary_get",
"parameters": [
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"description": "Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day.",
"title": "Start"
},
"description": "Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day."
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"description": "Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data).",
"title": "End"
},
"description": "Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data)."
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SummaryResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/dsmr/latest": {
"get": {
"tags": [
"api-energy"
],
"summary": "Get Dsmr Latest",
"description": "Return the most recent dsmr_reading row.\n\nReturns ``{\"found\": false, \"recorded_at\": null, \"payload\": null}`` (200, not\n404) when no rows exist yet, so the front-end can distinguish \"no data\" from\na server error.",
"operationId": "get_dsmr_latest_api_energy_dsmr_latest_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DsmrLatestResponse"
}
}
}
}
}
}
},
"/api/energy/costs/recompute": {
"post": {
"tags": [
"api-energy"
],
"summary": "Post Recompute",
"description": "Idempotently recompute billing records in a time window.\n\nCalls ``energy_cost.recompute_range(session, start, end)`` which overwrites\nexisting rows (including successful ones) for every UTC quarter-hour boundary\nin ``[start, end)``.\n\n**Idempotency**: repeated calls with the same window produce the same\noutcome. No rows are deleted; only upserted.\n\n**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.\nRequests exceeding this return 422.\n\nReturns the number of periods for which a billing record was written.\nPeriods skipped due to missing contract or missing Tibber price are not counted.",
"operationId": "post_recompute_api_energy_costs_recompute_post",
"parameters": [
{
"name": "start",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time",
"description": "Inclusive start of the recompute window (ISO 8601). Required.",
"title": "Start"
},
"description": "Inclusive start of the recompute window (ISO 8601). Required."
},
{
"name": "end",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time",
"description": "Exclusive end of the recompute window (ISO 8601). Required.",
"title": "End"
},
"description": "Exclusive end of the recompute window (ISO 8601). Required."
},
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecomputeResponse"
}
}
}
},
"422": {
"description": "Validation error (missing window or range too large)"
}
}
}
},
"/api/energy/tibber/test": {
"post": {
"tags": [
"api-energy"
],
"summary": "Post Tibber Test",
"description": "Test Tibber API connectivity by fetching the current price point.\n\nThree possible outcomes:\n\n- **200** ``{ result: \"success\", message: ..., price: {...} }``\n The Tibber API responded with a valid current price. ``price`` contains\n starts_at, total, energy, tax, currency, and level.\n\n- **400** ``{ result: \"config-error\", message: ... }``\n The Tibber API token is empty or not configured.\n\n- **502** ``{ result: \"failed\", message: ... }``\n The API call failed (authentication rejected, network error, timeout,\n unexpected response, etc.).\n\nThe API token is **never** included in the response body or logged.",
"operationId": "post_tibber_test_api_energy_tibber_test_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TibberTestResponse"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TibberTestResponse"
}
}
},
"description": "Bad Request"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TibberTestResponse"
}
}
},
"description": "Bad Gateway"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/profiles": { "/api/energy/profiles": {
"get": { "get": {
"tags": [ "tags": [
@@ -2461,6 +2864,110 @@
"title": "ContractVersionResponse", "title": "ContractVersionResponse",
"description": "Response schema for a single EnergyContractVersion row." "description": "Response schema for a single EnergyContractVersion row."
}, },
"CostPeriodSchema": {
"properties": {
"period_start": {
"type": "string",
"format": "date-time",
"title": "Period Start"
},
"d1_kwh": {
"type": "number",
"title": "D1 Kwh",
"description": "Delivered low-tariff kWh for this period."
},
"d2_kwh": {
"type": "number",
"title": "D2 Kwh",
"description": "Delivered normal-tariff kWh for this period."
},
"r1_kwh": {
"type": "number",
"title": "R1 Kwh",
"description": "Returned low-tariff kWh for this period."
},
"r2_kwh": {
"type": "number",
"title": "R2 Kwh",
"description": "Returned normal-tariff kWh for this period."
},
"import_cost": {
"type": "number",
"title": "Import Cost",
"description": "Cost of electricity drawn from grid (EUR)."
},
"export_revenue": {
"type": "number",
"title": "Export Revenue",
"description": "Revenue from electricity fed to grid (EUR)."
},
"net_cost": {
"type": "number",
"title": "Net Cost",
"description": "import_cost export_revenue (EUR)."
},
"currency": {
"type": "string",
"title": "Currency",
"description": "ISO 4217 currency code."
},
"degraded": {
"type": "boolean",
"title": "Degraded",
"description": "True when the period was computed with incomplete data."
},
"contract_version_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Contract Version Id",
"description": "FK to the contract version used for this billing period (null when degraded)."
}
},
"type": "object",
"required": [
"period_start",
"d1_kwh",
"d2_kwh",
"r1_kwh",
"r2_kwh",
"import_cost",
"export_revenue",
"net_cost",
"currency",
"degraded"
],
"title": "CostPeriodSchema",
"description": "One 15-minute billing record from the energy_cost_period table."
},
"CostsResponse": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/CostPeriodSchema"
},
"type": "array",
"title": "Items"
},
"total": {
"type": "integer",
"title": "Total",
"description": "Number of items returned."
}
},
"type": "object",
"required": [
"items",
"total"
],
"title": "CostsResponse",
"description": "Response for GET /api/energy/costs."
},
"DeviceInfoSchema": { "DeviceInfoSchema": {
"properties": { "properties": {
"identifiers": { "identifiers": {
@@ -2483,6 +2990,44 @@
"title": "DeviceInfoSchema", "title": "DeviceInfoSchema",
"description": "HA device grouping info for an exposable entity." "description": "HA device grouping info for an exposable entity."
}, },
"DsmrLatestResponse": {
"properties": {
"found": {
"type": "boolean",
"title": "Found"
},
"recorded_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Recorded At"
},
"payload": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Payload"
}
},
"type": "object",
"required": [
"found"
],
"title": "DsmrLatestResponse",
"description": "Response for GET /api/energy/dsmr/latest.\n\n``found`` is False when no dsmr_reading rows exist yet. The front-end\nshould check ``found`` before reading ``recorded_at`` or ``payload``."
},
"ExposableEntitySchema": { "ExposableEntitySchema": {
"properties": { "properties": {
"key": { "key": {
@@ -2746,6 +3291,39 @@
], ],
"title": "LoginRequest" "title": "LoginRequest"
}, },
"ManualTariffSchema": {
"properties": {
"buy_dal": {
"type": "number",
"title": "Buy Dal",
"description": "Effective buy price, low-tariff / dal (EUR/kWh)."
},
"buy_normal": {
"type": "number",
"title": "Buy Normal",
"description": "Effective buy price, normal / high-tariff (EUR/kWh)."
},
"sell_dal": {
"type": "number",
"title": "Sell Dal",
"description": "Sell price, low-tariff / dal (EUR/kWh)."
},
"sell_normal": {
"type": "number",
"title": "Sell Normal",
"description": "Sell price, normal / high-tariff (EUR/kWh)."
}
},
"type": "object",
"required": [
"buy_dal",
"buy_normal",
"sell_dal",
"sell_normal"
],
"title": "ManualTariffSchema",
"description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices."
},
"MetricInfo": { "MetricInfo": {
"properties": { "properties": {
"key": { "key": {
@@ -3377,6 +3955,93 @@
"title": "PooUpdateRequest", "title": "PooUpdateRequest",
"description": "PATCH body for a poo record — all fields optional; PK field excluded." "description": "PATCH body for a poo record — all fields optional; PK field excluded."
}, },
"PricePointSchema": {
"properties": {
"starts_at": {
"type": "string",
"format": "date-time",
"title": "Starts At"
},
"buy": {
"type": "number",
"title": "Buy",
"description": "All-in buy price in EUR/kWh (including taxes)."
},
"sell": {
"type": "number",
"title": "Sell",
"description": "Net sell price in EUR/kWh."
},
"level": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Level",
"description": "Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual."
}
},
"type": "object",
"required": [
"starts_at",
"buy",
"sell"
],
"title": "PricePointSchema",
"description": "A single 15-minute price point (tibber) or placeholder entry."
},
"PricesResponse": {
"properties": {
"kind": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Kind",
"description": "Active contract kind ('tibber' or 'manual'), or null if no active contract."
},
"currency": {
"type": "string",
"title": "Currency",
"description": "ISO 4217 currency code."
},
"points": {
"items": {
"$ref": "#/components/schemas/PricePointSchema"
},
"type": "array",
"title": "Points",
"description": "15-minute price points for tibber contracts (ascending by starts_at). Empty for manual contracts or when no active contract exists."
},
"tariff": {
"anyOf": [
{
"$ref": "#/components/schemas/ManualTariffSchema"
},
{
"type": "null"
}
],
"description": "Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists."
}
},
"type": "object",
"required": [
"kind",
"currency",
"points"
],
"title": "PricesResponse",
"description": "Response for GET /api/energy/prices.\n\n``kind`` mirrors the active contract kind:\n- ``\"tibber\"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.\n- ``\"manual\"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.\n- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.\n\n``currency`` comes from the active contract (or \"EUR\" fallback).\n``points`` is always ascending by ``starts_at``."
},
"ProfileSummary": { "ProfileSummary": {
"properties": { "properties": {
"name": { "name": {
@@ -3596,6 +4261,21 @@
], ],
"title": "PublicIPStateSchema" "title": "PublicIPStateSchema"
}, },
"RecomputeResponse": {
"properties": {
"recomputed": {
"type": "integer",
"title": "Recomputed",
"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."
}
},
"type": "object",
"required": [
"recomputed"
],
"title": "RecomputeResponse",
"description": "Response for POST /api/energy/costs/recompute."
},
"RepublishResponse": { "RepublishResponse": {
"properties": { "properties": {
"ok": { "ok": {
@@ -3687,6 +4367,154 @@
], ],
"title": "StatusResponse" "title": "StatusResponse"
}, },
"SummaryResponse": {
"properties": {
"currency": {
"type": "string",
"title": "Currency"
},
"metered_import": {
"type": "number",
"title": "Metered Import",
"description": "Σ import_cost for non-degraded periods."
},
"metered_export": {
"type": "number",
"title": "Metered Export",
"description": "Σ export_revenue for non-degraded periods."
},
"metered_net": {
"type": "number",
"title": "Metered Net",
"description": "Σ net_cost for non-degraded periods."
},
"fixed_costs": {
"type": "number",
"title": "Fixed Costs",
"description": "Standing charges (network_fee + management_fee) apportioned over the interval."
},
"credits": {
"type": "number",
"title": "Credits",
"description": "Energy-tax credit (heffingskorting) apportioned over the interval."
},
"total_payable": {
"type": "number",
"title": "Total Payable",
"description": "metered_net + fixed_costs credits (actual amount owed)."
},
"period_count": {
"type": "integer",
"title": "Period Count",
"description": "Number of non-degraded billing periods in range."
},
"degraded_count": {
"type": "integer",
"title": "Degraded Count",
"description": "Number of degraded billing periods in range."
},
"days": {
"type": "number",
"title": "Days",
"description": "Interval length in days."
}
},
"type": "object",
"required": [
"currency",
"metered_import",
"metered_export",
"metered_net",
"fixed_costs",
"credits",
"total_payable",
"period_count",
"degraded_count",
"days"
],
"title": "SummaryResponse",
"description": "Response for GET /api/energy/costs/summary.\n\nAll monetary values are in ``currency``.\n\n``total_payable = metered_net + fixed_costs credits``"
},
"TibberTestPriceSchema": {
"properties": {
"starts_at": {
"type": "string",
"format": "date-time",
"title": "Starts At"
},
"total": {
"type": "number",
"title": "Total"
},
"energy": {
"type": "number",
"title": "Energy"
},
"tax": {
"type": "number",
"title": "Tax"
},
"currency": {
"type": "string",
"title": "Currency"
},
"level": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Level"
}
},
"type": "object",
"required": [
"starts_at",
"total",
"energy",
"tax",
"currency"
],
"title": "TibberTestPriceSchema",
"description": "Current Tibber price point returned on a successful test.\n\nCarries enough fields for the front-end to confirm the API is working and\ndisplay the live price. The API token is **never** included."
},
"TibberTestResponse": {
"properties": {
"result": {
"type": "string",
"enum": [
"success",
"config-error",
"failed"
],
"title": "Result"
},
"message": {
"type": "string",
"title": "Message"
},
"price": {
"anyOf": [
{
"$ref": "#/components/schemas/TibberTestPriceSchema"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"result",
"message"
],
"title": "TibberTestResponse",
"description": "Three-state response for POST /api/energy/tibber/test.\n\nPossible ``result`` values:\n\n- ``\"success\"`` — Tibber API responded with a valid price.\n- ``\"config-error\"`` — Token is missing or not configured.\n- ``\"failed\"`` — API call failed (auth rejected, network error, timeout, etc.).\n\n``price`` is populated only on ``\"success\"``; it is null otherwise.\n``message`` always contains a human-readable explanation."
},
"TotpDisableRequest": { "TotpDisableRequest": {
"properties": { "properties": {
"password": { "password": {
+695
View File
@@ -516,6 +516,338 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
/api/energy/prices:
get:
tags:
- api-energy
summary: Get Prices
description: "Return the price curve for the active contract.\n\n**Tibber contracts**\
\ (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``,\
\ ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned\
\ (most recent first\n within the window, then reversed to ascending order\
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
\ price)\n - ``sell = total energy_tax sell_adjust`` (from active\
\ version values)\n - ``level`` (Tibber price level,\
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
\ + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal\
\ = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"\
EUR\", points=[], tariff=null (200)."
operationId: get_prices_api_energy_prices_get
parameters:
- name: start
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
description: Inclusive start of the time window (ISO 8601). Defaults to
the start of today UTC when omitted.
title: Start
description: Inclusive start of the time window (ISO 8601). Defaults to the
start of today UTC when omitted.
- name: end
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
description: Inclusive end of the time window (ISO 8601). Defaults to the
end of tomorrow UTC when omitted.
title: End
description: Inclusive end of the time window (ISO 8601). Defaults to the
end of tomorrow UTC when omitted.
- name: limit
in: query
required: false
schema:
type: integer
maximum: 5000
minimum: 1
description: Maximum number of Tibber price points to return.
default: 500
title: Limit
description: Maximum number of Tibber price points to return.
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/PricesResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/costs:
get:
tags:
- api-energy
summary: Get Costs
description: '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}).'
operationId: get_costs_api_energy_costs_get
parameters:
- name: start
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
description: Inclusive lower bound for period_start (ISO 8601).
title: Start
description: Inclusive lower bound for period_start (ISO 8601).
- name: end
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
description: Inclusive upper bound for period_start (ISO 8601).
title: End
description: Inclusive upper bound for period_start (ISO 8601).
- name: limit
in: query
required: false
schema:
type: integer
maximum: 5000
minimum: 1
description: Maximum number of cost periods to return (default 500, max
5000).
default: 500
title: Limit
description: Maximum number of cost periods to return (default 500, max 5000).
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/CostsResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/costs/summary:
get:
tags:
- api-energy
summary: Get Costs Summary
description: "Aggregate billing for a time interval.\n\nCalls ``energy_cost.summarize(session,\
\ start, end)`` which computes:\n\n total_payable = Σ(net_cost) + fixed_costs\
\ credits\n\nwhere ``fixed_costs`` is (network_fee + management_fee) apportioned\
\ to the\ninterval length in days (÷30 per month), and ``credits`` is heffingskorting\n\
apportioned similarly (÷365 per year).\n\nBoth ``fixed_costs`` and ``credits``\
\ are derived from the **currently active\ncontract version at ``end``**.\
\ When no active contract exists they are 0."
operationId: get_costs_summary_api_energy_costs_summary_get
parameters:
- name: start
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
description: Inclusive start of the summary interval (ISO 8601). Defaults
to the start of the current UTC day.
title: Start
description: Inclusive start of the summary interval (ISO 8601). Defaults
to the start of the current UTC day.
- name: end
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
description: Exclusive end of the summary interval (ISO 8601). Defaults
to the start of the next UTC day (i.e. today's full data).
title: End
description: Exclusive end of the summary interval (ISO 8601). Defaults to
the start of the next UTC day (i.e. today's full data).
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SummaryResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/dsmr/latest:
get:
tags:
- api-energy
summary: Get Dsmr Latest
description: '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.'
operationId: get_dsmr_latest_api_energy_dsmr_latest_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/DsmrLatestResponse'
/api/energy/costs/recompute:
post:
tags:
- api-energy
summary: Post Recompute
description: '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.'
operationId: post_recompute_api_energy_costs_recompute_post
parameters:
- name: start
in: query
required: true
schema:
type: string
format: date-time
description: Inclusive start of the recompute window (ISO 8601). Required.
title: Start
description: Inclusive start of the recompute window (ISO 8601). Required.
- name: end
in: query
required: true
schema:
type: string
format: date-time
description: Exclusive end of the recompute window (ISO 8601). Required.
title: End
description: Exclusive end of the recompute window (ISO 8601). Required.
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/RecomputeResponse'
'422':
description: Validation error (missing window or range too large)
/api/energy/tibber/test:
post:
tags:
- api-energy
summary: Post Tibber Test
description: "Test Tibber API connectivity by fetching the current price point.\n\
\nThree possible outcomes:\n\n- **200** ``{ result: \"success\", message:\
\ ..., price: {...} }``\n The Tibber API responded with a valid current price.\
\ ``price`` contains\n starts_at, total, energy, tax, currency, and level.\n\
\n- **400** ``{ result: \"config-error\", message: ... }``\n The Tibber API\
\ token is empty or not configured.\n\n- **502** ``{ result: \"failed\", \
\ message: ... }``\n The API call failed (authentication rejected, network\
\ error, timeout,\n unexpected response, etc.).\n\nThe API token is **never**\
\ included in the response body or logged."
operationId: post_tibber_test_api_energy_tibber_test_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/TibberTestResponse'
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/TibberTestResponse'
description: Bad Request
'502':
content:
application/json:
schema:
$ref: '#/components/schemas/TibberTestResponse'
description: Bad Gateway
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/profiles: /api/energy/profiles:
get: get:
tags: tags:
@@ -1899,6 +2231,86 @@ components:
- created_at - created_at
title: ContractVersionResponse title: ContractVersionResponse
description: Response schema for a single EnergyContractVersion row. description: Response schema for a single EnergyContractVersion row.
CostPeriodSchema:
properties:
period_start:
type: string
format: date-time
title: Period Start
d1_kwh:
type: number
title: D1 Kwh
description: Delivered low-tariff kWh for this period.
d2_kwh:
type: number
title: D2 Kwh
description: Delivered normal-tariff kWh for this period.
r1_kwh:
type: number
title: R1 Kwh
description: Returned low-tariff kWh for this period.
r2_kwh:
type: number
title: R2 Kwh
description: Returned normal-tariff kWh for this period.
import_cost:
type: number
title: Import Cost
description: Cost of electricity drawn from grid (EUR).
export_revenue:
type: number
title: Export Revenue
description: Revenue from electricity fed to grid (EUR).
net_cost:
type: number
title: Net Cost
description: import_cost export_revenue (EUR).
currency:
type: string
title: Currency
description: ISO 4217 currency code.
degraded:
type: boolean
title: Degraded
description: True when the period was computed with incomplete data.
contract_version_id:
anyOf:
- type: integer
- type: 'null'
title: Contract Version Id
description: FK to the contract version used for this billing period (null
when degraded).
type: object
required:
- period_start
- d1_kwh
- d2_kwh
- r1_kwh
- r2_kwh
- import_cost
- export_revenue
- net_cost
- currency
- degraded
title: CostPeriodSchema
description: One 15-minute billing record from the energy_cost_period table.
CostsResponse:
properties:
items:
items:
$ref: '#/components/schemas/CostPeriodSchema'
type: array
title: Items
total:
type: integer
title: Total
description: Number of items returned.
type: object
required:
- items
- total
title: CostsResponse
description: Response for GET /api/energy/costs.
DeviceInfoSchema: DeviceInfoSchema:
properties: properties:
identifiers: identifiers:
@@ -1915,6 +2327,33 @@ components:
- name - name
title: DeviceInfoSchema title: DeviceInfoSchema
description: HA device grouping info for an exposable entity. description: HA device grouping info for an exposable entity.
DsmrLatestResponse:
properties:
found:
type: boolean
title: Found
recorded_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Recorded At
payload:
anyOf:
- additionalProperties: true
type: object
- type: 'null'
title: Payload
type: object
required:
- found
title: DsmrLatestResponse
description: '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``.'
ExposableEntitySchema: ExposableEntitySchema:
properties: properties:
key: key:
@@ -2097,6 +2536,37 @@ components:
- username - username
- password - password
title: LoginRequest title: LoginRequest
ManualTariffSchema:
properties:
buy_dal:
type: number
title: Buy Dal
description: Effective buy price, low-tariff / dal (EUR/kWh).
buy_normal:
type: number
title: Buy Normal
description: Effective buy price, normal / high-tariff (EUR/kWh).
sell_dal:
type: number
title: Sell Dal
description: Sell price, low-tariff / dal (EUR/kWh).
sell_normal:
type: number
title: Sell Normal
description: Sell price, normal / high-tariff (EUR/kWh).
type: object
required:
- buy_dal
- buy_normal
- sell_dal
- sell_normal
title: ManualTariffSchema
description: '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.'
MetricInfo: MetricInfo:
properties: properties:
key: key:
@@ -2534,6 +3004,81 @@ components:
type: object type: object
title: PooUpdateRequest title: PooUpdateRequest
description: PATCH body for a poo record — all fields optional; PK field excluded. description: PATCH body for a poo record — all fields optional; PK field excluded.
PricePointSchema:
properties:
starts_at:
type: string
format: date-time
title: Starts At
buy:
type: number
title: Buy
description: All-in buy price in EUR/kWh (including taxes).
sell:
type: number
title: Sell
description: Net sell price in EUR/kWh.
level:
anyOf:
- type: string
- type: 'null'
title: Level
description: Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.
type: object
required:
- starts_at
- buy
- sell
title: PricePointSchema
description: A single 15-minute price point (tibber) or placeholder entry.
PricesResponse:
properties:
kind:
anyOf:
- type: string
- type: 'null'
title: Kind
description: Active contract kind ('tibber' or 'manual'), or null if no
active contract.
currency:
type: string
title: Currency
description: ISO 4217 currency code.
points:
items:
$ref: '#/components/schemas/PricePointSchema'
type: array
title: Points
description: 15-minute price points for tibber contracts (ascending by starts_at).
Empty for manual contracts or when no active contract exists.
tariff:
anyOf:
- $ref: '#/components/schemas/ManualTariffSchema'
- type: 'null'
description: Fixed tariff table for manual contracts. Null for tibber contracts
and when no active contract exists.
type: object
required:
- kind
- currency
- points
title: PricesResponse
description: '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``.'
ProfileSummary: ProfileSummary:
properties: properties:
name: name:
@@ -2689,6 +3234,19 @@ components:
- last_check_error - last_check_error
- last_provider - last_provider
title: PublicIPStateSchema title: PublicIPStateSchema
RecomputeResponse:
properties:
recomputed:
type: integer
title: Recomputed
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.
type: object
required:
- recomputed
title: RecomputeResponse
description: Response for POST /api/energy/costs/recompute.
RepublishResponse: RepublishResponse:
properties: properties:
ok: ok:
@@ -2755,6 +3313,143 @@ components:
required: required:
- status - status
title: StatusResponse title: StatusResponse
SummaryResponse:
properties:
currency:
type: string
title: Currency
metered_import:
type: number
title: Metered Import
description: Σ import_cost for non-degraded periods.
metered_export:
type: number
title: Metered Export
description: Σ export_revenue for non-degraded periods.
metered_net:
type: number
title: Metered Net
description: Σ net_cost for non-degraded periods.
fixed_costs:
type: number
title: Fixed Costs
description: Standing charges (network_fee + management_fee) apportioned
over the interval.
credits:
type: number
title: Credits
description: Energy-tax credit (heffingskorting) apportioned over the interval.
total_payable:
type: number
title: Total Payable
description: metered_net + fixed_costs credits (actual amount owed).
period_count:
type: integer
title: Period Count
description: Number of non-degraded billing periods in range.
degraded_count:
type: integer
title: Degraded Count
description: Number of degraded billing periods in range.
days:
type: number
title: Days
description: Interval length in days.
type: object
required:
- currency
- metered_import
- metered_export
- metered_net
- fixed_costs
- credits
- total_payable
- period_count
- degraded_count
- days
title: SummaryResponse
description: 'Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``.
``total_payable = metered_net + fixed_costs credits``'
TibberTestPriceSchema:
properties:
starts_at:
type: string
format: date-time
title: Starts At
total:
type: number
title: Total
energy:
type: number
title: Energy
tax:
type: number
title: Tax
currency:
type: string
title: Currency
level:
anyOf:
- type: string
- type: 'null'
title: Level
type: object
required:
- starts_at
- total
- energy
- tax
- currency
title: TibberTestPriceSchema
description: '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.'
TibberTestResponse:
properties:
result:
type: string
enum:
- success
- config-error
- failed
title: Result
message:
type: string
title: Message
price:
anyOf:
- $ref: '#/components/schemas/TibberTestPriceSchema'
- type: 'null'
type: object
required:
- result
- message
title: TibberTestResponse
description: '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.'
TotpDisableRequest: TotpDisableRequest:
properties: properties:
password: password:
+855
View File
@@ -0,0 +1,855 @@
"""Tests for M6-T09: Energy data API.
Coverage
--------
GET /api/energy/prices
- unauthenticated → 401
- no active contract → 200, kind=null, points=[], tariff=null
- tibber active contract → 200, points from tibber_price, tariff=null
- manual active contract → 200, points=[], tariff with effective prices
- limit parameter caps tibber points
GET /api/energy/costs
- unauthenticated → 401
- empty DB → 200, items=[]
- returns rows ordered by period_start ascending
- limit parameter caps results
GET /api/energy/costs/summary
- unauthenticated → 401
- empty DB → 200, all-zeros (no active contract, no periods)
- returns summarize() result structure
GET /api/energy/dsmr/latest
- unauthenticated → 401
- no data → 200, found=false
- with data → 200, found=true, correct payload
POST /api/energy/costs/recompute
- unauthenticated → 401
- missing CSRF → 403
- end ≤ start → 422
- window > 366 days → 422
- valid call → 200, recomputed=int (idempotent)
POST /api/energy/tibber/test
- unauthenticated → 401
- missing CSRF → 403
- token empty → 400 config-error
- mock client success → 200 success with price data
- mock client TibberAuthError → 502 failed
- mock client TibberError → 502 failed
- token never appears in success/failed responses
Auth/CSRF matrix: all write endpoints need session + CSRF.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.integrations.tibber.client import PricePoint, TibberAuthError, TibberError
from app.models.energy import (
DsmrReading,
EnergyCostPeriod,
EnergyContract,
EnergyContractVersion,
TibberPrice,
)
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_CSRF = "test-csrf-token"
_MANUAL_VALUES: dict[str, Any] = {
"energy": {
"buy": {"normal": 0.40, "dal": 0.30},
"sell": {"normal": 0.10, "dal": 0.10},
"energy_tax": 0.1108,
"ode": 0.0,
},
"standing": {
"network_fee": 25.0,
"management_fee": 9.87,
},
"credits": {
"heffingskorting": 600.0,
},
}
_TIBBER_VALUES: dict[str, Any] = {
"energy": {
"energy_tax": 0.1108,
"sell_adjust": 0.0,
},
"standing": {
"management_fee": 5.99,
"network_fee": 25.0,
},
"credits": {
"heffingskorting": 600.0,
},
}
def _login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
def _set_app_config(engine, key: str, value: str) -> None:
"""Upsert a key/value pair into the app_config table.
Used by tibber test helpers to inject a non-empty TIBBER_API_TOKEN without
needing to patch get_app_settings (which is shared with auth infrastructure).
"""
from sqlalchemy.orm import Session as _Session
from app.models.config import AppConfigEntry
now = datetime.now(UTC)
with _Session(engine) as session:
existing = session.execute(
__import__("sqlalchemy").select(AppConfigEntry).where(AppConfigEntry.key == key)
).scalar_one_or_none()
if existing is not None:
existing.value = value
existing.updated_at = now
else:
session.add(AppConfigEntry(key=key, value=value, updated_at=now))
session.commit()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def energy_client(auth_database):
"""TestClient + SQLAlchemy engine + FastAPI app for Energy API tests."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as test_client:
yield test_client, engine, fastapi_app
engine.dispose()
def _make_active_manual_contract(engine) -> tuple[EnergyContract, EnergyContractVersion]:
"""Insert an active manual EnergyContract with one version and return both."""
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Test Manual",
kind="manual",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
version = EnergyContractVersion(
contract_id=contract.id,
effective_from=now - timedelta(days=30),
effective_to=None,
values=_MANUAL_VALUES,
created_at=now,
)
session.add(version)
session.commit()
# capture ids before session closes
contract_id = contract.id
version_id = version.id
# Return plain data structs (not ORM objects tied to closed session)
class _ContractStub:
id = contract_id
class _VersionStub:
id = version_id
return _ContractStub(), _VersionStub() # type: ignore[return-value]
def _make_active_tibber_contract(engine) -> tuple[int, int]:
"""Insert an active tibber EnergyContract with one version and return (contract_id, version_id)."""
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Test Tibber",
kind="tibber",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
version = EnergyContractVersion(
contract_id=contract.id,
effective_from=now - timedelta(days=30),
effective_to=None,
values=_TIBBER_VALUES,
created_at=now,
)
session.add(version)
session.commit()
return contract.id, version.id
def _make_tibber_prices(engine, count: int = 3) -> list[datetime]:
"""Insert ``count`` TibberPrice rows starting from one hour ago; return starts_at list."""
now = datetime.now(UTC).replace(second=0, microsecond=0)
base = now - timedelta(hours=1)
starts = [base + timedelta(minutes=15 * i) for i in range(count)]
with Session(engine) as session:
for s in starts:
row = TibberPrice(
starts_at=s,
resolution="QUARTER_HOURLY",
energy=0.18,
tax=0.065,
total=0.245,
level="NORMAL",
currency="EUR",
fetched_at=now,
)
session.add(row)
session.commit()
return starts
def _make_cost_periods(engine, count: int = 3, version_id: int | None = None) -> list[datetime]:
"""Insert ``count`` EnergyCostPeriod rows starting from one hour ago; return period_start list."""
now = datetime.now(UTC).replace(second=0, microsecond=0)
base = now - timedelta(hours=2)
starts = []
with Session(engine) as session:
for i in range(count):
t0 = base + timedelta(minutes=15 * i)
period = EnergyCostPeriod(
period_start=t0,
d1_kwh=0.1 * i,
d2_kwh=0.05 * i,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.05 * i,
export_revenue=0.0,
net_cost=0.05 * i,
currency="EUR",
pricing={"kind": "manual"},
contract_version_id=version_id,
degraded=False,
computed_at=now,
)
session.add(period)
starts.append(t0)
session.commit()
return starts
def _make_dsmr_reading(engine, recorded_at: datetime | None = None) -> datetime:
"""Insert one DsmrReading and return its recorded_at."""
now = recorded_at or datetime.now(UTC)
with Session(engine) as session:
row = DsmrReading(
recorded_at=now,
source_id=42,
payload={"electricity_delivered_1": "100.0", "test": True},
)
session.add(row)
session.commit()
return now
# ---------------------------------------------------------------------------
# GET /api/energy/prices — auth
# ---------------------------------------------------------------------------
def test_prices_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/prices")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/prices — no active contract
# ---------------------------------------------------------------------------
def test_prices_no_contract_returns_empty(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/prices")
assert resp.status_code == 200
body = resp.json()
assert body["kind"] is None
assert body["points"] == []
assert body["tariff"] is None
assert "currency" in body
# ---------------------------------------------------------------------------
# GET /api/energy/prices — manual contract
# ---------------------------------------------------------------------------
def test_prices_manual_contract_returns_tariff(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
resp = client.get("/api/energy/prices")
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "manual"
assert body["currency"] == "EUR"
assert body["points"] == []
assert body["tariff"] is not None
tariff = body["tariff"]
# buy_dal = 0.30 + 0.1108 + 0.0 = 0.4108
assert abs(tariff["buy_dal"] - 0.4108) < 1e-6, f"buy_dal wrong: {tariff['buy_dal']}"
# buy_normal = 0.40 + 0.1108 + 0.0 = 0.5108
assert abs(tariff["buy_normal"] - 0.5108) < 1e-6, f"buy_normal wrong: {tariff['buy_normal']}"
# sell_dal = 0.10 (no tax added)
assert abs(tariff["sell_dal"] - 0.10) < 1e-6
# sell_normal = 0.10
assert abs(tariff["sell_normal"] - 0.10) < 1e-6
# ---------------------------------------------------------------------------
# GET /api/energy/prices — tibber contract
# ---------------------------------------------------------------------------
def test_prices_tibber_contract_returns_points(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_tibber_contract(engine)
_make_tibber_prices(engine, count=3)
# Use a wide enough window to include our test prices.
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "tibber"
assert body["tariff"] is None
assert isinstance(body["points"], list)
assert len(body["points"]) == 3
# Verify ascending order.
starts_at_list = [p["starts_at"] for p in body["points"]]
assert starts_at_list == sorted(starts_at_list)
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0
for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
assert p["level"] == "NORMAL"
def test_prices_tibber_limit_caps_results(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_tibber_contract(engine)
_make_tibber_prices(engine, count=5)
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get(
"/api/energy/prices", params={"start": start, "end": end, "limit": 2}
)
assert resp.status_code == 200
body = resp.json()
assert len(body["points"]) <= 2
# ---------------------------------------------------------------------------
# GET /api/energy/costs — auth
# ---------------------------------------------------------------------------
def test_costs_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/costs")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/costs — empty
# ---------------------------------------------------------------------------
def test_costs_empty_returns_empty(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/costs")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
# ---------------------------------------------------------------------------
# GET /api/energy/costs — with data
# ---------------------------------------------------------------------------
def test_costs_returns_sorted_ascending(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=3)
resp = client.get("/api/energy/costs")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 3
period_starts = [item["period_start"] for item in body["items"]]
assert period_starts == sorted(period_starts), "Items must be ascending by period_start"
def test_costs_limit_caps_results(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=5)
resp = client.get("/api/energy/costs?limit=2")
assert resp.status_code == 200
body = resp.json()
assert body["total"] <= 2
assert len(body["items"]) <= 2
def test_costs_schema_fields_present(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=1)
resp = client.get("/api/energy/costs")
assert resp.status_code == 200
item = resp.json()["items"][0]
for field in (
"period_start",
"d1_kwh", "d2_kwh", "r1_kwh", "r2_kwh",
"import_cost", "export_revenue", "net_cost",
"currency", "degraded",
):
assert field in item, f"Missing field: {field}"
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary — auth
# ---------------------------------------------------------------------------
def test_summary_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/costs/summary")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary — empty
# ---------------------------------------------------------------------------
def test_summary_empty_returns_zeros(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/costs/summary")
assert resp.status_code == 200
body = resp.json()
assert body["period_count"] == 0
assert body["metered_net"] == 0.0
assert "total_payable" in body
assert "currency" in body
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary — with data
# ---------------------------------------------------------------------------
def test_summary_returns_correct_structure(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=2)
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
end = datetime.now(UTC).isoformat()
resp = client.get(
"/api/energy/costs/summary", params={"start": start, "end": end}
)
assert resp.status_code == 200
body = resp.json()
for field in (
"currency",
"metered_import",
"metered_export",
"metered_net",
"fixed_costs",
"credits",
"total_payable",
"period_count",
"degraded_count",
"days",
):
assert field in body, f"Missing field in summary: {field}"
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest — auth
# ---------------------------------------------------------------------------
def test_dsmr_latest_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/dsmr/latest")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest — no data
# ---------------------------------------------------------------------------
def test_dsmr_latest_no_data_returns_not_found(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/dsmr/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is False
assert body["recorded_at"] is None
assert body["payload"] is None
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest — with data
# ---------------------------------------------------------------------------
def test_dsmr_latest_returns_most_recent(energy_client):
client, engine, _app = energy_client
_login(client)
# Insert two readings; the more recent one should be returned.
older = datetime.now(UTC) - timedelta(minutes=5)
newer = datetime.now(UTC) - timedelta(seconds=30)
with Session(engine) as session:
session.add(DsmrReading(recorded_at=older, source_id=1, payload={"order": "first"}))
session.add(DsmrReading(recorded_at=newer, source_id=2, payload={"order": "second"}))
session.commit()
resp = client.get("/api/energy/dsmr/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is True
assert body["payload"]["order"] == "second"
assert body["recorded_at"] is not None
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute — auth / CSRF
# ---------------------------------------------------------------------------
def test_recompute_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={"start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat()},
)
assert resp.status_code == 401
def test_recompute_missing_csrf_returns_403(energy_client):
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={"start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat()},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute — validation
# ---------------------------------------------------------------------------
def test_recompute_end_before_start_returns_422(energy_client):
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={
"start": now.isoformat(),
"end": (now - timedelta(hours=1)).isoformat(),
},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_recompute_window_too_large_returns_422(energy_client):
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={
"start": (now - timedelta(days=400)).isoformat(),
"end": now.isoformat(),
},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute — success (idempotent)
# ---------------------------------------------------------------------------
def test_recompute_returns_count(energy_client):
client, _, _app = energy_client
_login(client)
# A window with no data is fine — recompute returns 0 but is not an error.
now = datetime.now(UTC)
params = {
"start": (now - timedelta(hours=1)).isoformat(),
"end": now.isoformat(),
}
resp = client.post(
"/api/energy/costs/recompute",
params=params,
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert "recomputed" in body
assert isinstance(body["recomputed"], int)
def test_recompute_is_idempotent(energy_client):
"""Calling recompute twice with the same window must produce the same result."""
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
params = {
"start": (now - timedelta(hours=1)).isoformat(),
"end": now.isoformat(),
}
headers = {"X-CSRF-Token": _CSRF}
resp1 = client.post("/api/energy/costs/recompute", params=params, headers=headers)
resp2 = client.post("/api/energy/costs/recompute", params=params, headers=headers)
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["recomputed"] == resp2.json()["recomputed"]
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — auth / CSRF
# ---------------------------------------------------------------------------
def test_tibber_test_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.post("/api/energy/tibber/test")
assert resp.status_code == 401
def test_tibber_test_missing_csrf_returns_403(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.post("/api/energy/tibber/test")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — token empty → 400 config-error
# ---------------------------------------------------------------------------
def test_tibber_test_no_token_returns_config_error(energy_client):
"""When tibber_api_token is empty, expect 400 config-error regardless of CSRF."""
client, _, _app = energy_client
_login(client)
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
# Token is empty by default (no config in test DB).
assert resp.status_code == 400
body = resp.json()
assert body["result"] == "config-error"
assert "message" in body
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — mock success
# ---------------------------------------------------------------------------
def test_tibber_test_success(energy_client):
"""Token stored in DB app_config + mocked HTTP → 200 success with price data."""
client, engine, _app = energy_client
_login(client)
# Set TIBBER_API_TOKEN in the app_config table so get_app_settings returns it.
_set_app_config(engine, "TIBBER_API_TOKEN", "fake-tibber-token")
mock_price = PricePoint(
starts_at=datetime(2026, 6, 23, 14, 0, 0, tzinfo=UTC),
total=0.245,
energy=0.18,
tax=0.065,
currency="EUR",
level="NORMAL",
resolution="QUARTER_HOURLY",
)
with patch(
"app.api.routes.api.energy.fetch_current_price",
return_value=mock_price,
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["result"] == "success"
assert body["price"] is not None
assert body["price"]["total"] == 0.245
assert body["price"]["currency"] == "EUR"
assert body["price"]["level"] == "NORMAL"
# Token must NOT appear anywhere in the response.
assert "fake-tibber-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — mock TibberAuthError → 502
# ---------------------------------------------------------------------------
def test_tibber_test_auth_error_returns_502(energy_client):
"""Bad token stored in DB + mocked TibberAuthError → 502 failed."""
client, engine, _app = energy_client
_login(client)
_set_app_config(engine, "TIBBER_API_TOKEN", "bad-tibber-token")
with patch(
"app.api.routes.api.energy.fetch_current_price",
side_effect=TibberAuthError("auth failed"),
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 502
body = resp.json()
assert body["result"] == "failed"
assert "bad-tibber-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — mock TibberError → 502
# ---------------------------------------------------------------------------
def test_tibber_test_network_error_returns_502(energy_client):
"""Token in DB + mocked TibberError (network timeout) → 502 failed."""
client, engine, _app = energy_client
_login(client)
_set_app_config(engine, "TIBBER_API_TOKEN", "some-tibber-token")
with patch(
"app.api.routes.api.energy.fetch_current_price",
side_effect=TibberError("network timeout"),
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 502
body = resp.json()
assert body["result"] == "failed"
assert "some-tibber-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — token never in response (extra check)
# ---------------------------------------------------------------------------
def test_tibber_test_token_not_in_response(energy_client):
"""Even in the success response, the Tibber token must not appear."""
client, engine, _app = energy_client
_login(client)
secret_token = "super-secret-tibber-token-xyz"
_set_app_config(engine, "TIBBER_API_TOKEN", secret_token)
mock_price = PricePoint(
starts_at=datetime(2026, 6, 23, 14, 0, 0, tzinfo=UTC),
total=0.30,
energy=0.22,
tax=0.08,
currency="EUR",
level="CHEAP",
resolution="QUARTER_HOURLY",
)
with patch(
"app.api.routes.api.energy.fetch_current_price",
return_value=mock_price,
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert secret_token not in resp.text