The Summary cards labelled `metered_import` / `metered_export` as "(kWh)", but both fields are monetary totals (Σ import_cost / Σ export_revenue). Today's page therefore showed "Import 1.339 kWh" when the meter had actually imported 4.188 kWh — the 1.339 was EUR. Cross-checked against the DSMR cumulative registers and Home Assistant: our energy figures were correct all along, only the label was wrong. summarize() now also aggregates the metered energy, reusing the already-fetched non-degraded rows so no extra query is issued: metered_import_kwh = Σ (d1_kwh + d2_kwh) metered_export_kwh = Σ (r1_kwh + r2_kwh) The Import/Export cards show kWh as the headline figure and keep the monetary equivalent as a sub-line, so the split between energy cost and standing charges/credits behind total_payable stays visible. The `_kwh` suffix is now the only thing separating energy from money in this payload, so the docstrings on both summarize() and SummaryResponse call that out explicitly. app/integrations/expose.py reads only the money keys, so the HA outbound sensors are unaffected by the additive fields.
231 lines
8.6 KiB
Python
231 lines
8.6 KiB
Python
"""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.
|
||
|
||
Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
|
||
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
|
||
only the ``_kwh``-suffixed fields carry kWh.
|
||
|
||
``total_payable = metered_net + fixed_costs − credits``
|
||
"""
|
||
|
||
currency: str
|
||
metered_import: float = Field(
|
||
description="Σ import_cost for non-degraded periods (money, in `currency`)."
|
||
)
|
||
metered_export: float = Field(
|
||
description="Σ export_revenue for non-degraded periods (money, in `currency`)."
|
||
)
|
||
metered_net: float = Field(
|
||
description="Σ net_cost for non-degraded periods (money, in `currency`)."
|
||
)
|
||
metered_import_kwh: float = Field(
|
||
description="Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
|
||
)
|
||
metered_export_kwh: float = Field(
|
||
description="Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
|
||
)
|
||
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
|