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 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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -405,7 +405,7 @@ Phase D(API + 前端)
|
|||||||
- **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;查询走索引 + 窗口/上限。
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user