M6-T09: add Energy data API (prices/costs/summary/dsmr-latest/recompute/tibber-test)

- routes/api/energy.py + schemas/energy.py: GET prices (tibber points or manual
  tariff, buy/sell consistent with the pricing strategies), GET costs (time
  window + limit, ascending), GET costs/summary (summarize passthrough), GET
  dsmr/latest, POST costs/recompute (idempotent, <=366d guard, CSRF), POST
  tibber/test (three-state, token never echoed, CSRF).
- main.py registers router; OpenAPI re-exported; tests for all endpoints.
This commit is contained in:
2026-06-23 23:05:45 +02:00
parent c61fc2d4ba
commit 57f2459f60
7 changed files with 3177 additions and 1 deletions
+695
View File
@@ -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: