Files
home-automation/app/api/routes/api/energy.py
T
tliu93 8b91146c29
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s
FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
  integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
  apportionment), anchored at the active contract's earliest version effective_from so the
  total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
2026-06-25 11:13:30 +02:00

585 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from app.services.timezone import local_midnight_utc, local_now
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.
"""
if start is None or end is None:
# Default to the server's local today: [local_midnight, next_local_midnight).
# This ensures "today" aligns with the local calendar day (NL time) rather
# than UTC midnight.
local_today = local_now().date()
if start is None:
start = local_midnight_utc(local_today)
if end is None:
end = local_midnight_utc(local_today + 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"),
)