From 57f2459f602931c7f6b12a3f94d8db3d9ae29f55 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Tue, 23 Jun 2026 23:05:45 +0200 Subject: [PATCH] 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. --- app/api/routes/api/energy.py | 580 ++++++++++++++++ app/main.py | 2 + app/schemas/energy.py | 216 ++++++ docs/design/m6-tibber-dynamic-energy.md | 2 +- openapi/openapi.json | 828 +++++++++++++++++++++++ openapi/openapi.yaml | 695 +++++++++++++++++++ tests/test_api_energy.py | 855 ++++++++++++++++++++++++ 7 files changed, 3177 insertions(+), 1 deletion(-) create mode 100644 app/api/routes/api/energy.py create mode 100644 app/schemas/energy.py create mode 100644 tests/test_api_energy.py diff --git a/app/api/routes/api/energy.py b/app/api/routes/api/energy.py new file mode 100644 index 0000000..a9ec27a --- /dev/null +++ b/app/api/routes/api/energy.py @@ -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"), + ) diff --git a/app/main.py b/app/main.py index 28b1b20..9d45197 100644 --- a/app/main.py +++ b/app/main.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session from app import models # noqa: F401 from app.api.routes.api.config import router as api_config_router from app.api.routes.api.data import router as api_data_router +from app.api.routes.api.energy import router as api_energy_router from app.api.routes.api.energy_contracts import router as api_energy_contracts_router from app.api.routes.api.expose import router as api_expose_router from app.api.routes.api.modbus import router as api_modbus_router @@ -288,6 +289,7 @@ def create_app() -> FastAPI: app.include_router(status.router) app.include_router(api_config_router) app.include_router(api_data_router) + app.include_router(api_energy_router) app.include_router(api_energy_contracts_router) app.include_router(api_expose_router) app.include_router(api_modbus_router) diff --git a/app/schemas/energy.py b/app/schemas/energy.py new file mode 100644 index 0000000..0c90215 --- /dev/null +++ b/app/schemas/energy.py @@ -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 diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index 4112a31..dac29ed 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -405,7 +405,7 @@ Phase D(API + 前端) - **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。 ### M6-T09 — Energy 数据 API -- **Status**: `todo` · **Depends**: M6-T05, M6-T07 +- **Status**: `done` · **Depends**: M6-T05, M6-T07 - **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` - **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。 diff --git a/openapi/openapi.json b/openapi/openapi.json index 582b06f..f9f383a 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -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": { "get": { "tags": [ @@ -2461,6 +2864,110 @@ "title": "ContractVersionResponse", "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": { "properties": { "identifiers": { @@ -2483,6 +2990,44 @@ "title": "DeviceInfoSchema", "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": { "properties": { "key": { @@ -2746,6 +3291,39 @@ ], "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": { "properties": { "key": { @@ -3377,6 +3955,93 @@ "title": "PooUpdateRequest", "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": { "properties": { "name": { @@ -3596,6 +4261,21 @@ ], "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": { "properties": { "ok": { @@ -3687,6 +4367,154 @@ ], "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": { "properties": { "password": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a11277e..d38b4c6 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -516,6 +516,338 @@ paths: application/json: schema: $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: get: tags: @@ -1899,6 +2231,86 @@ components: - created_at title: ContractVersionResponse 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: properties: identifiers: @@ -1915,6 +2327,33 @@ components: - name title: DeviceInfoSchema 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: properties: key: @@ -2097,6 +2536,37 @@ components: - username - password 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: properties: key: @@ -2534,6 +3004,81 @@ components: type: object title: PooUpdateRequest 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: properties: name: @@ -2689,6 +3234,19 @@ components: - last_check_error - last_provider 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: properties: ok: @@ -2755,6 +3313,143 @@ components: required: - status 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: properties: password: diff --git a/tests/test_api_energy.py b/tests/test_api_energy.py new file mode 100644 index 0000000..fc593f9 --- /dev/null +++ b/tests/test_api_energy.py @@ -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 + +