Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
682e06d256 | ||
|
|
d7f0731d1c | ||
|
|
9bc46f8d2d | ||
|
|
6f8cb05eab | ||
|
|
effe434637 | ||
|
|
9d3e28a529 | ||
|
|
7e61b0a938 | ||
|
|
e8521351d7 | ||
|
|
8d4f496ff4 | ||
|
|
78f3cc4776 | ||
|
|
96e88861d4 | ||
|
|
57f2459f60 | ||
|
|
c61fc2d4ba | ||
|
|
d35ac7d752 | ||
|
|
ef48032adb | ||
|
|
7e266a21eb | ||
|
|
4284bd40a7 | ||
|
|
bedea196c3 | ||
|
|
df54f5518b | ||
|
|
18fe18281b |
@@ -43,5 +43,23 @@ MQTT_TLS_ENABLED=false
|
|||||||
|
|
||||||
# Optional: Home Assistant MQTT Discovery.
|
# Optional: Home Assistant MQTT Discovery.
|
||||||
# Requires MQTT_ENABLED=true and a running MQTT broker.
|
# Requires MQTT_ENABLED=true and a running MQTT broker.
|
||||||
|
# HA_DISCOVERY_PREFIX is the config topic prefix (must be "homeassistant" for HA auto-discovery).
|
||||||
|
# HA_STATE_TOPIC_PREFIX is the prefix for state/availability topics (separate from discovery).
|
||||||
HA_DISCOVERY_ENABLED=false
|
HA_DISCOVERY_ENABLED=false
|
||||||
HA_DISCOVERY_PREFIX=homeassistant
|
HA_DISCOVERY_PREFIX=homeassistant
|
||||||
|
HA_STATE_TOPIC_PREFIX=home_automation
|
||||||
|
|
||||||
|
# Optional: DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
|
||||||
|
# Set DSMR_INGEST_ENABLED=true to subscribe to the DSMR MQTT topic and
|
||||||
|
# store 10-second downsampled telegram frames in dsmr_reading.
|
||||||
|
# DSMR_INGEST_ENABLED=false
|
||||||
|
# DSMR_MQTT_TOPIC=dsmr/json
|
||||||
|
# DSMR_SAMPLE_INTERVAL_S=10
|
||||||
|
# DSMR tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak).
|
||||||
|
# Empty string disables tariff-aware pricing (buy/sell_price_now always show normal rate).
|
||||||
|
# DSMR_TARIFF_TOPIC=dsmr/meter-stats/electricity_tariff
|
||||||
|
|
||||||
|
# Optional: Tibber dynamic pricing credentials (M6).
|
||||||
|
# Only used when an active energy contract with kind=tibber is configured.
|
||||||
|
# TIBBER_API_TOKEN=
|
||||||
|
# TIBBER_HOME_ID=
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
- **Modbus 设备采集**:通过 YAML profile(首个:SDM120 电表)按设备周期轮询 Modbus-TCP 网关,解码工程量并落通用读数表(`modbus_device` + `modbus_reading`)
|
- **Modbus 设备采集**:通过 YAML profile(首个:SDM120 电表)按设备周期轮询 Modbus-TCP 网关,解码工程量并落通用读数表(`modbus_device` + `modbus_reading`)
|
||||||
- **MQTT + Home Assistant Discovery**:以可勾选方式把 Modbus 设备/工程量注册为 HA device/entity(含 binary_sensor online),state 周期发布;配置变更可重连重发
|
- **MQTT + Home Assistant Discovery**:以可勾选方式把 Modbus 设备/工程量注册为 HA device/entity(含 binary_sensor online),state 周期发布;配置变更可重连重发
|
||||||
- **前端侧边栏 + Energy 视图**:侧边导航替换顶栏;Energy 页管理 Modbus 设备、展示最新读数与 Recharts 走势图;Config 页 Accordion 分区展开;Expose 设置勾选 HA 可暴露实体
|
- **前端侧边栏 + Energy 视图**:侧边导航替换顶栏;Energy 页管理 Modbus 设备、展示最新读数与 Recharts 走势图;Config 页 Accordion 分区展开;Expose 设置勾选 HA 可暴露实体
|
||||||
|
- **DSMR 实时电表接入**:订阅 DSMR Reader 的 `dsmr/json`(每秒一帧)、整帧 JSON blob 按 10 秒降采样落库(`dsmr_reading`)
|
||||||
|
- **通用电价合同层**:YAML profile 定合同结构(manual 固定/双费率 / tibber 动态电价);`EnergyContract`+`EnergyContractVersion` 存 UI 可填的数值,改价加新版本旧版本保留;price strategy 按 kind 出价
|
||||||
|
- **实时买卖电费计算**:每 15 分钟按寄存器差值(`_1`=dal/低、`_2`=normal/高)× 买/卖价算计量电费,快照不可变;日/月/年汇总加固定费减 heffingskorting
|
||||||
|
- **反哺 Home Assistant Energy**:当前买/卖价 + 累计买电支出/卖电收入(`total_increasing`)发成 HA 实体,可直接挂 HA Energy 仪表盘
|
||||||
- pytest 测试与 OpenAPI 导出脚本
|
- pytest 测试与 OpenAPI 导出脚本
|
||||||
- Docker / Compose 部署入口
|
- Docker / Compose 部署入口
|
||||||
|
|
||||||
@@ -36,6 +40,10 @@
|
|||||||
- Modbus 设备定义(`modbus_device` 表)
|
- Modbus 设备定义(`modbus_device` 表)
|
||||||
- Modbus 通用读数(`modbus_reading` 表,JSON payload)
|
- Modbus 通用读数(`modbus_reading` 表,JSON payload)
|
||||||
- HA 实体暴露开关(`exposed_entity_toggle` 表)
|
- HA 实体暴露开关(`exposed_entity_toggle` 表)
|
||||||
|
- DSMR 电表实时读数(`dsmr_reading` 表,整帧 JSON blob,10s 降采样)
|
||||||
|
- 电价合同(`energy_contract` 表)与版本(`energy_contract_version` 表,values JSON)
|
||||||
|
- Tibber 15 分钟电价缓存(`tibber_price` 表,不可变)
|
||||||
|
- 每 15 分钟计量电费(`energy_cost_period` 表,快照价,不可变)
|
||||||
|
|
||||||
配置层只保留一个数据库环境变量:
|
配置层只保留一个数据库环境变量:
|
||||||
|
|
||||||
@@ -47,7 +55,7 @@
|
|||||||
python -m scripts.run_migrations
|
python -m scripts.run_migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含全部表,包括 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`)。
|
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含全部表,包括 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading`、`energy_contract`、`energy_contract_version`、`tibber_price`、`energy_cost_period`)。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
@@ -55,7 +63,7 @@ python -m scripts.run_migrations
|
|||||||
|
|
||||||
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
|
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
|
||||||
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
|
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
|
||||||
- `alembic_app/`: App DB 的 Alembic migration 环境(管理所有表,含 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`)
|
- `alembic_app/`: App DB 的 Alembic migration 环境(管理所有表,含 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading`、`energy_contract`、`energy_contract_version`、`tibber_price`、`energy_cost_period`)
|
||||||
- `tests/`: pytest 测试
|
- `tests/`: pytest 测试
|
||||||
- `docs/`: 当前系统说明文档
|
- `docs/`: 当前系统说明文档
|
||||||
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
||||||
@@ -366,6 +374,64 @@ CLI 工具为受控手工验证而设(设备需接市电),仅暴露读功
|
|||||||
- **Config 页 Accordion**:各大 config section 可独立折叠/展开;「Home Assistant Expose」面板按设备分组勾选可暴露实体、显示 MQTT/Discovery 连接状态、「重新发布 discovery」按钮。
|
- **Config 页 Accordion**:各大 config section 可独立折叠/展开;「Home Assistant Expose」面板按设备分组勾选可暴露实体、显示 MQTT/Discovery 连接状态、「重新发布 discovery」按钮。
|
||||||
- SPA 路由新增 `/energy`。
|
- SPA 路由新增 `/energy`。
|
||||||
|
|
||||||
|
## M6 DSMR 接入 / 电价合同 / 实时电费计算 / HA Energy 反哺
|
||||||
|
|
||||||
|
M6 在 M5 IoT 基建之上接入 DSMR 实时智能电表数据,建立通用电价合同层,按每 15 分钟算出实际买卖电费并反哺 HA Energy。
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
M6 **不新增任何 Python 依赖**,复用 M5 已有的 `httpx`(Tibber GraphQL)、`paho-mqtt`(DSMR 订阅)、`pyyaml`(pricing profile 加载)、`apscheduler`(抓价 job、计费 job)。
|
||||||
|
|
||||||
|
### DSMR 实时电表接入
|
||||||
|
|
||||||
|
订阅 DSMR Reader 的 `dsmr/json` topic(每秒一帧完整 telegram),整帧存为 JSON blob、按 `dsmr_sample_interval_s`(默认 10 秒)降采样落 `dsmr_reading`(`source_id` 幂等去重)。`dsmr_ingest_enabled`(默认 false,opt-in)。
|
||||||
|
|
||||||
|
### 电价合同层
|
||||||
|
|
||||||
|
- **YAML profile 定结构**(仓库内,不放数值):`manual.yaml`(固定/双费率:buy_normal/dal、sell_normal/dal、energy_tax、ode、固定费、heffingskorting);`tibber.yaml`(动态:source=tibber_api,energy_tax、sell_adjust)
|
||||||
|
- **`EnergyContract` + `EnergyContractVersion`**(UI 填数值):改价 = 加新版本行(带 `effective_from`),旧版本保留(审计链);一次只有一个 active 合同
|
||||||
|
- **price strategy**:`manual` 用双费率常数(`buy = energy_buy_档 + energy_tax`,`sell = sell_档`);`tibber` 用 `tibber_price.total` 作买价(已含税,demo 确认 `total=energy+tax`),`total − energy_tax − sell_adjust` 作卖价(卖价残差 `sell_adjust` 默认 0,待真实账单核定)
|
||||||
|
|
||||||
|
### 每 15 分钟计量电费(不可变)
|
||||||
|
|
||||||
|
APScheduler 1 分钟 tick,取每个闭合 15 分钟窗口的 DSMR 寄存器差值(`delivered_1/2`,`returned_1/2`;`_1`=dal/低,`_2`=normal/高,NL 惯例)× 当时合同版本的 strategy 出价,upsert `energy_cost_period`(快照当时价 + `contract_version_id`)。缺价/缺数据时标 `degraded`。`POST /api/energy/costs/recompute` 显式重算。
|
||||||
|
|
||||||
|
日/月/年汇总 = Σnet + 固定费(network_fee + management_fee 按月→天 × 天数)- heffingskorting(按年→天 × 天数),读时计算、不落表。能源税 `energy_tax` 参考值约 0.1108 EUR/kWh(2026 第一档含 VAT,待真实账单核定;该值由 UI 填入合同版本,YAML profile 仅声明字段 unit,代码无写死默认数值)。
|
||||||
|
|
||||||
|
### Tibber 动态电价
|
||||||
|
|
||||||
|
`app/integrations/tibber/client.py` httpx POST GraphQL(`priceInfoRange(QUARTER_HOURLY, first=96)`),解析 `startsAt`/`total`/`energy`/`tax`/`level`,按 `starts_at` upsert `tibber_price`(幂等)。启动 + 每小时抓取今明两天 15 分钟价、幂等 upsert(hourly trigger,确保每日刷新且可补重试);仅当 active 合同 kind=tibber 且 `tibber_api_token` 存在时运行。`POST /api/energy/tibber/test` 试连三态(success 带当前价 / config-error / failed)。
|
||||||
|
|
||||||
|
### 反哺 Home Assistant Energy
|
||||||
|
|
||||||
|
`_energy_cost_provider` 向 expose 框架注册 4 个实体:`buy_price_now`、`sell_price_now`(€/kWh sensor)、`import_cost_total`、`export_revenue_total`(`total_increasing` monetary,可直接挂 HA Energy 仪表盘)。默认未勾选,在 Expose 面板启用。
|
||||||
|
|
||||||
|
### API 端点(M6 新增)
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/energy/contracts` | 列出合同 + active 标记 |
|
||||||
|
| `POST /api/energy/contracts` | 新建合同(kind + 首版本值,按 profile 校验)|
|
||||||
|
| `GET /api/energy/contracts/{id}` | 单个合同 + 版本历史 |
|
||||||
|
| `PATCH /api/energy/contracts/{id}` | 改名 / 激活 |
|
||||||
|
| `POST /api/energy/contracts/{id}/versions` | 加新版本(改价,带生效日期)|
|
||||||
|
| `GET /api/energy/profiles` | 列出 pricing profile 结构(前端按它渲染表单)|
|
||||||
|
| `GET /api/energy/prices` | 区间价格点(曲线)|
|
||||||
|
| `GET /api/energy/costs` | 区间 `energy_cost_period`(走势/明细)|
|
||||||
|
| `GET /api/energy/costs/summary` | 区间汇总(计量电费 + 固定费 − 抵扣)|
|
||||||
|
| `POST /api/energy/costs/recompute` | 幂等重算 |
|
||||||
|
| `GET /api/energy/dsmr/latest` | 最新 `dsmr_reading` |
|
||||||
|
| `POST /api/energy/tibber/test` | 试连 Tibber + 拉当前价,三态 |
|
||||||
|
|
||||||
|
DSMR/Tibber 标量配置复用现有 `GET/PUT /api/config`(新增 `dsmr_ingest_enabled`、`dsmr_mqtt_topic`、`dsmr_sample_interval_s`、`tibber_api_token`(secret)、`tibber_home_id`)。
|
||||||
|
|
||||||
|
### 前端视图(M6 新增,并入 Energy 视图)
|
||||||
|
|
||||||
|
- **Contracts Tab**:合同列表 + 新建/编辑(表单按 `/api/energy/profiles` 结构渲染,不 hardcode 字段)+ 激活 + 改价加版本 + 版本历史只读。
|
||||||
|
- **Prices Tab**:15 分钟价格曲线(tibber 动态或 manual 档位),复用 Recharts。
|
||||||
|
- **Costs Tab**:费用走势/明细 + 汇总卡片(含固定费/抵扣)。
|
||||||
|
- **Config 页 Tibber 测试**:三态(success/config-error/failed)。
|
||||||
|
|
||||||
## Config 持久化
|
## Config 持久化
|
||||||
|
|
||||||
当前 config 页面不会把修改写回 `.env`。
|
当前 config 页面不会把修改写回 `.env`。
|
||||||
@@ -394,6 +460,8 @@ CLI 工具为受控手工验证而设(设备需接市电),仅暴露读功
|
|||||||
- MQTT broker 配置(`MQTT_ENABLED`、`MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD`、`MQTT_TLS_ENABLED`)
|
- MQTT broker 配置(`MQTT_ENABLED`、`MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD`、`MQTT_TLS_ENABLED`)
|
||||||
- Home Assistant Discovery 配置(`HA_DISCOVERY_ENABLED`、`HA_DISCOVERY_PREFIX`)
|
- Home Assistant Discovery 配置(`HA_DISCOVERY_ENABLED`、`HA_DISCOVERY_PREFIX`)
|
||||||
- Modbus 采集配置(`MODBUS_POLLING_ENABLED`)
|
- Modbus 采集配置(`MODBUS_POLLING_ENABLED`)
|
||||||
|
- DSMR 接入配置(`DSMR_INGEST_ENABLED`、`DSMR_MQTT_TOPIC`、`DSMR_SAMPLE_INTERVAL_S`)
|
||||||
|
- Tibber 凭据(`TIBBER_API_TOKEN`(secret)、`TIBBER_HOME_ID`)
|
||||||
|
|
||||||
其中 SMTP password 与其他 secret 字段一致:
|
其中 SMTP password 与其他 secret 字段一致:
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ from app.models.location import Location # noqa: F401
|
|||||||
from app.models.poo import PooRecord # noqa: F401
|
from app.models.poo import PooRecord # noqa: F401
|
||||||
from app.models.modbus import ModbusDevice, ModbusReading # noqa: F401
|
from app.models.modbus import ModbusDevice, ModbusReading # noqa: F401
|
||||||
from app.models.expose import ExposedEntityToggle # noqa: F401
|
from app.models.expose import ExposedEntityToggle # noqa: F401
|
||||||
|
from app.models.energy import ( # noqa: F401
|
||||||
|
DsmrReading,
|
||||||
|
EnergyContract,
|
||||||
|
EnergyContractVersion,
|
||||||
|
TibberPrice,
|
||||||
|
EnergyCostPeriod,
|
||||||
|
)
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""add energy pricing and DSMR metering tables
|
||||||
|
|
||||||
|
Revision ID: 20260623_11_energy_tables
|
||||||
|
Revises: 20260622_10_exposed_entities
|
||||||
|
Create Date: 2026-06-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260623_11_energy_tables"
|
||||||
|
down_revision: Union[str, None] = "20260622_10_exposed_entities"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# dsmr_reading — raw DSMR telegram blobs (10-second down-sampled).
|
||||||
|
# No device table: single P1 smart meter; a ``source`` column can be added later
|
||||||
|
# if a second meter is introduced.
|
||||||
|
op.create_table(
|
||||||
|
"dsmr_reading",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("source_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("source_id", name="uq_dsmr_reading_source_id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_dsmr_reading_recorded_at",
|
||||||
|
"dsmr_reading",
|
||||||
|
["recorded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# energy_contract — contract head (manual or tibber, one active at a time).
|
||||||
|
op.create_table(
|
||||||
|
"energy_contract",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# energy_contract_version — versioned pricing values; append-only for auditability.
|
||||||
|
# Must be created after energy_contract because of the FK dependency.
|
||||||
|
op.create_table(
|
||||||
|
"energy_contract_version",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("contract_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("values", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contract_id"],
|
||||||
|
["energy_contract.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# tibber_price — cached Tibber 15-minute spot prices (immutable once fetched).
|
||||||
|
op.create_table(
|
||||||
|
"tibber_price",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("resolution", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("energy", sa.Float(), nullable=False),
|
||||||
|
sa.Column("tax", sa.Float(), nullable=False),
|
||||||
|
sa.Column("total", sa.Float(), nullable=False),
|
||||||
|
sa.Column("level", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("starts_at", name="uq_tibber_price_starts_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# energy_cost_period — computed 15-minute billing periods (immutable snapshot).
|
||||||
|
# Must be created after energy_contract_version because of the FK dependency.
|
||||||
|
op.create_table(
|
||||||
|
"energy_cost_period",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("d1_kwh", sa.Float(), nullable=False),
|
||||||
|
sa.Column("d2_kwh", sa.Float(), nullable=False),
|
||||||
|
sa.Column("r1_kwh", sa.Float(), nullable=False),
|
||||||
|
sa.Column("r2_kwh", sa.Float(), nullable=False),
|
||||||
|
sa.Column("import_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("export_revenue", sa.Float(), nullable=False),
|
||||||
|
sa.Column("net_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column("pricing", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("contract_version_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("degraded", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("computed_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contract_version_id"],
|
||||||
|
["energy_contract_version.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("period_start", name="uq_energy_cost_period_period_start"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop tables in reverse dependency order: tables with FKs first.
|
||||||
|
|
||||||
|
# energy_cost_period has a FK to energy_contract_version — drop it first.
|
||||||
|
op.drop_table("energy_cost_period")
|
||||||
|
|
||||||
|
# tibber_price has no FK dependencies on our new tables.
|
||||||
|
op.drop_table("tibber_price")
|
||||||
|
|
||||||
|
# energy_contract_version has a FK to energy_contract — drop it before energy_contract.
|
||||||
|
op.drop_table("energy_contract_version")
|
||||||
|
|
||||||
|
# energy_contract has no FK dependencies on our new tables.
|
||||||
|
op.drop_table("energy_contract")
|
||||||
|
|
||||||
|
# dsmr_reading has no FK dependencies.
|
||||||
|
op.drop_index("ix_dsmr_reading_recorded_at", table_name="dsmr_reading")
|
||||||
|
op.drop_table("dsmr_reading")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""decouple dsmr_reading from the telegram id
|
||||||
|
|
||||||
|
The DSMR Reader's own ``id`` field is auto-incrementing but is known to overflow
|
||||||
|
and require a manual reset to zero (a long-standing DSMR firmware quirk). If we
|
||||||
|
keep a UNIQUE constraint on ``source_id`` (the telegram id) and use it for
|
||||||
|
idempotency, an overflow/reset would make legitimately-new telegrams collide with
|
||||||
|
old ids and be silently dropped as "duplicates" — data loss.
|
||||||
|
|
||||||
|
This migration removes that coupling:
|
||||||
|
|
||||||
|
- Drops the UNIQUE constraint on ``source_id`` (the column is kept as a plain,
|
||||||
|
nullable reference value; it is no longer relied upon for uniqueness/dedup).
|
||||||
|
- Makes ``recorded_at`` (the telegram timestamp) UNIQUE instead — a single P1
|
||||||
|
meter emits one telegram per timestamp, so this is a robust, telegram-id-
|
||||||
|
independent idempotency key. The table's own autoincrement ``id`` PK remains
|
||||||
|
the stable internal identity.
|
||||||
|
|
||||||
|
Revision ID: 20260624_12_dsmr_decouple_telegram_id
|
||||||
|
Revises: 20260623_11_energy_tables
|
||||||
|
Create Date: 2026-06-24 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260624_12_dsmr_decouple_telegram_id"
|
||||||
|
down_revision: Union[str, None] = "20260623_11_energy_tables"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# SQLite cannot ALTER away a constraint in place, so recreate the table via
|
||||||
|
# Alembic's batch mode. The table is recreated and rows are copied; existing
|
||||||
|
# data (if any) is preserved. recorded_at must be unique for this to succeed
|
||||||
|
# — a single meter never emits two telegrams at the exact same timestamp.
|
||||||
|
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
|
||||||
|
# Old non-unique index on recorded_at is replaced by the unique constraint.
|
||||||
|
batch_op.drop_index("ix_dsmr_reading_recorded_at")
|
||||||
|
# Telegram id is no longer a uniqueness/idempotency key.
|
||||||
|
batch_op.drop_constraint("uq_dsmr_reading_source_id", type_="unique")
|
||||||
|
# Timestamp becomes the telegram-id-independent dedup key.
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_dsmr_reading_recorded_at", ["recorded_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("uq_dsmr_reading_recorded_at", type_="unique")
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_dsmr_reading_source_id", ["source_id"]
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
"ix_dsmr_reading_recorded_at", ["recorded_at"], unique=False
|
||||||
|
)
|
||||||
@@ -86,6 +86,12 @@ def put_config(
|
|||||||
logger.info("MQTT settings changed — triggering reconnect.")
|
logger.info("MQTT settings changed — triggering reconnect.")
|
||||||
mqtt_manager.reconnect(refreshed_settings)
|
mqtt_manager.reconnect(refreshed_settings)
|
||||||
|
|
||||||
|
# Re-apply the DSMR subscription so enabling/disabling DSMR ingest (or changing
|
||||||
|
# its topic / sample interval) takes effect immediately, without an app restart.
|
||||||
|
# Done after any MQTT reconnect so it operates on the current client.
|
||||||
|
from app.services.dsmr_ingest import apply_dsmr_subscription
|
||||||
|
apply_dsmr_subscription(refreshed_settings)
|
||||||
|
|
||||||
sections_raw = build_config_sections(db, refreshed_settings)
|
sections_raw = build_config_sections(db, refreshed_settings)
|
||||||
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
|
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""EnergyContract CRUD, versioning, and pricing-profile API (M6-T04).
|
||||||
|
|
||||||
|
All endpoints are under /api/energy, require an authenticated session, and
|
||||||
|
write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
|
||||||
|
header.
|
||||||
|
|
||||||
|
There is deliberately no DELETE endpoint for contracts: the FK RESTRICT
|
||||||
|
constraint on energy_contract_version.contract_id and
|
||||||
|
energy_cost_period.contract_version_id prevents accidental deletion of
|
||||||
|
contracts that have billing history. Users should instead deactivate a
|
||||||
|
contract (PATCH active=false) to stop it from being used.
|
||||||
|
|
||||||
|
Route ordering note
|
||||||
|
-------------------
|
||||||
|
GET /api/energy/profiles and GET /api/energy/contracts/{id} are on separate
|
||||||
|
path prefixes (/energy/profiles vs /energy/contracts/{id}) so there is no
|
||||||
|
ambiguity even without extra ordering gymnastics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.routes.api.deps import require_csrf, require_session
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.integrations.pricing.profiles import (
|
||||||
|
ProfileNotFoundError,
|
||||||
|
ProfileValidationError,
|
||||||
|
list_profiles,
|
||||||
|
)
|
||||||
|
from app.schemas.energy_contract import (
|
||||||
|
ContractCreate,
|
||||||
|
ContractDetailResponse,
|
||||||
|
ContractListResponse,
|
||||||
|
ContractPatch,
|
||||||
|
ContractResponse,
|
||||||
|
ContractVersionResponse,
|
||||||
|
ProfilesResponse,
|
||||||
|
VersionCreate,
|
||||||
|
)
|
||||||
|
from app.services.auth import AuthenticatedSession
|
||||||
|
from app.services.contracts import (
|
||||||
|
ContractVersionError,
|
||||||
|
activate_contract,
|
||||||
|
add_version,
|
||||||
|
create_contract,
|
||||||
|
deactivate_contract,
|
||||||
|
get_contract_or_none,
|
||||||
|
list_contracts,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/energy", tags=["api-energy-contracts"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_contract_or_404(db: Session, contract_id: int):
|
||||||
|
"""Return the contract with the given id or raise 404."""
|
||||||
|
contract = get_contract_or_none(db, contract_id)
|
||||||
|
if contract is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Energy contract {contract_id!r} not found.",
|
||||||
|
)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def _contract_detail(db: Session, contract) -> ContractDetailResponse:
|
||||||
|
"""Build a ContractDetailResponse for *contract*, loading versions."""
|
||||||
|
# Eager-load versions ordered by effective_from for consistent display.
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.energy import EnergyContractVersion
|
||||||
|
|
||||||
|
versions = list(
|
||||||
|
db.execute(
|
||||||
|
select(EnergyContractVersion)
|
||||||
|
.where(EnergyContractVersion.contract_id == contract.id)
|
||||||
|
.order_by(EnergyContractVersion.effective_from)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
version_schemas = [ContractVersionResponse.model_validate(v) for v in versions]
|
||||||
|
return ContractDetailResponse(
|
||||||
|
id=contract.id,
|
||||||
|
name=contract.name,
|
||||||
|
kind=contract.kind,
|
||||||
|
active=contract.active,
|
||||||
|
currency=contract.currency,
|
||||||
|
created_at=contract.created_at,
|
||||||
|
updated_at=contract.updated_at,
|
||||||
|
versions=version_schemas,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_422_for_profile_error(exc: Exception) -> Any:
|
||||||
|
"""Convert a ProfileValidationError or ProfileNotFoundError into HTTP 422."""
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles", response_model=ProfilesResponse)
|
||||||
|
def get_profiles(
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ProfilesResponse:
|
||||||
|
"""List all available pricing profile structures.
|
||||||
|
|
||||||
|
Returns the full profile structure for each supported contract kind
|
||||||
|
(``manual`` and ``tibber``). The front-end uses this to dynamically
|
||||||
|
render the correct fields and labels for the contract creation/editing form.
|
||||||
|
"""
|
||||||
|
return ProfilesResponse(profiles=list_profiles())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contracts", response_model=ContractListResponse)
|
||||||
|
def list_energy_contracts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ContractListResponse:
|
||||||
|
"""List all energy contracts with their active status.
|
||||||
|
|
||||||
|
Returns a flat list (no embedded version history); use
|
||||||
|
GET /api/energy/contracts/{id} to fetch the full version history for a
|
||||||
|
specific contract.
|
||||||
|
"""
|
||||||
|
contracts = list_contracts(db)
|
||||||
|
items = [ContractResponse.model_validate(c) for c in contracts]
|
||||||
|
return ContractListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/contracts",
|
||||||
|
response_model=ContractDetailResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_energy_contract(
|
||||||
|
body: ContractCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ContractDetailResponse:
|
||||||
|
"""Create a new energy contract with an initial pricing version.
|
||||||
|
|
||||||
|
The ``values`` dict is validated against the YAML profile for the given
|
||||||
|
``kind``; non-conforming values result in 422 Unprocessable Entity.
|
||||||
|
The new contract is created with ``active=False``; use
|
||||||
|
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
|
||||||
|
"""
|
||||||
|
effective_from = body.effective_from or datetime.now(UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
contract = create_contract(
|
||||||
|
db,
|
||||||
|
name=body.name,
|
||||||
|
kind=body.kind,
|
||||||
|
currency=body.currency,
|
||||||
|
values=body.values,
|
||||||
|
effective_from=effective_from,
|
||||||
|
)
|
||||||
|
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||||
|
_raise_422_for_profile_error(exc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contract)
|
||||||
|
logger.info("Created energy contract id=%d name=%r kind=%s", contract.id, contract.name, contract.kind)
|
||||||
|
return _contract_detail(db, contract)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/contracts/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contracts/{contract_id}", response_model=ContractDetailResponse)
|
||||||
|
def get_energy_contract(
|
||||||
|
contract_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ContractDetailResponse:
|
||||||
|
"""Return a single energy contract with its full version history.
|
||||||
|
|
||||||
|
Versions are ordered by ``effective_from`` ascending so the caller can
|
||||||
|
easily inspect the pricing timeline.
|
||||||
|
"""
|
||||||
|
contract = _get_contract_or_404(db, contract_id)
|
||||||
|
return _contract_detail(db, contract)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/energy/contracts/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/contracts/{contract_id}", response_model=ContractDetailResponse)
|
||||||
|
def patch_energy_contract(
|
||||||
|
contract_id: int,
|
||||||
|
body: ContractPatch,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ContractDetailResponse:
|
||||||
|
"""Partially update a contract: rename or change activation status.
|
||||||
|
|
||||||
|
- ``name``: updates the human-readable label.
|
||||||
|
- ``active=true``: activates this contract (all others are deactivated).
|
||||||
|
- ``active=false``: deactivates this contract (no effect on others).
|
||||||
|
|
||||||
|
At most one contract may be active at any time; the service layer enforces
|
||||||
|
mutual exclusion.
|
||||||
|
"""
|
||||||
|
contract = _get_contract_or_404(db, contract_id)
|
||||||
|
|
||||||
|
if body.name is not None:
|
||||||
|
contract.name = body.name
|
||||||
|
contract.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
if body.active is True:
|
||||||
|
activate_contract(db, contract)
|
||||||
|
elif body.active is False:
|
||||||
|
deactivate_contract(db, contract)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contract)
|
||||||
|
return _contract_detail(db, contract)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/contracts/{id}/versions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/contracts/{contract_id}/versions",
|
||||||
|
response_model=ContractDetailResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def add_contract_version(
|
||||||
|
contract_id: int,
|
||||||
|
body: VersionCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ContractDetailResponse:
|
||||||
|
"""Add a new pricing version to an existing contract.
|
||||||
|
|
||||||
|
This is how price changes are recorded: the current open version is
|
||||||
|
automatically closed (its ``effective_to`` is set to ``body.effective_from``)
|
||||||
|
and a new version is created starting at ``body.effective_from``.
|
||||||
|
|
||||||
|
The ``values`` dict must conform to the contract's pricing profile.
|
||||||
|
Non-conforming values return 422. If ``effective_from`` is not strictly
|
||||||
|
after the previous version's ``effective_from``, 422 is returned without
|
||||||
|
writing any rows.
|
||||||
|
|
||||||
|
Historical versions are never modified; this endpoint is append-only.
|
||||||
|
"""
|
||||||
|
contract = _get_contract_or_404(db, contract_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_version(
|
||||||
|
db,
|
||||||
|
contract,
|
||||||
|
effective_from=body.effective_from,
|
||||||
|
values=body.values,
|
||||||
|
)
|
||||||
|
except ContractVersionError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||||
|
_raise_422_for_profile_error(exc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contract)
|
||||||
|
return _contract_detail(db, contract)
|
||||||
@@ -4,14 +4,14 @@ All endpoints are under /api/modbus, require an authenticated session, and
|
|||||||
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
|
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
|
||||||
non-empty X-CSRF-Token header.
|
non-empty X-CSRF-Token header.
|
||||||
|
|
||||||
Deletion safety (red-line):
|
Deletion safety:
|
||||||
DELETE /api/modbus/devices/{uuid} explicitly queries the application
|
DELETE /api/modbus/devices/{uuid} queries the application layer for
|
||||||
layer for existing modbus_reading rows before deleting. If any exist
|
existing modbus_reading rows before deleting. Without ``cascade`` it
|
||||||
the endpoint returns 409 and suggests disabling the device instead.
|
returns 409 and suggests disabling the device instead — a friendly guard
|
||||||
We do NOT rely on the SQLite FK RESTRICT constraint because SQLite does
|
rather than letting the DB raise. With ``cascade=true`` it removes the
|
||||||
not enforce foreign-key constraints at runtime unless
|
readings (and expose toggles) first, then the device. SQLite FK RESTRICT
|
||||||
PRAGMA foreign_keys=ON is set per connection (and it is not in this
|
*is* enforced at runtime here (the app sets PRAGMA foreign_keys=ON; see
|
||||||
project — see M5-T02 review note OBS-1).
|
app/db.py), so the cascade delete order matters.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,7 +21,7 @@ from datetime import UTC, datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import delete as sa_delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.routes.api.deps import require_csrf, require_session
|
from app.api.routes.api.deps import require_csrf, require_session
|
||||||
@@ -34,9 +34,11 @@ from app.integrations.modbus.profiles import (
|
|||||||
list_profiles,
|
list_profiles,
|
||||||
load_profile,
|
load_profile,
|
||||||
)
|
)
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
from app.models.modbus import ModbusDevice, ModbusReading
|
from app.models.modbus import ModbusDevice, ModbusReading
|
||||||
from app.schemas.modbus import (
|
from app.schemas.modbus import (
|
||||||
MetricInfo,
|
MetricInfo,
|
||||||
|
ModbusDeleteResponse,
|
||||||
ModbusDeviceCreate,
|
ModbusDeviceCreate,
|
||||||
ModbusDeviceListResponse,
|
ModbusDeviceListResponse,
|
||||||
ModbusDeviceResponse,
|
ModbusDeviceResponse,
|
||||||
@@ -220,29 +222,41 @@ def patch_device(
|
|||||||
)
|
)
|
||||||
def delete_device(
|
def delete_device(
|
||||||
uuid: str,
|
uuid: str,
|
||||||
|
cascade: bool = Query(default=False),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: AuthenticatedSession = Depends(require_session),
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
_csrf: None = Depends(require_csrf),
|
_csrf: None = Depends(require_csrf),
|
||||||
) -> None:
|
) -> None | ModbusDeleteResponse:
|
||||||
"""Delete a Modbus device.
|
"""Delete a Modbus device.
|
||||||
|
|
||||||
|
**Default behaviour (cascade=false)**:
|
||||||
Returns 409 Conflict if the device has any associated readings; use
|
Returns 409 Conflict if the device has any associated readings; use
|
||||||
``enabled=false`` to disable it instead.
|
``enabled=false`` to disable it instead.
|
||||||
|
|
||||||
**Application-layer safety**: the check is performed via an explicit
|
**Cascade delete (cascade=true)**:
|
||||||
SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
|
Permanently deletes the device together with all its readings and any
|
||||||
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
|
``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.
|
||||||
|
Also makes a best-effort attempt to clear the device's HA Discovery
|
||||||
|
config topics from MQTT (empty retained payload) before the DB rows
|
||||||
|
are removed. MQTT failures are swallowed — the DB deletion proceeds
|
||||||
|
regardless.
|
||||||
|
Returns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.
|
||||||
|
|
||||||
|
**Application-layer safety**: the 409 guard uses an explicit SELECT COUNT
|
||||||
|
query to return a friendly message. FK RESTRICT is enforced at runtime
|
||||||
|
(the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes
|
||||||
|
readings before the device.
|
||||||
"""
|
"""
|
||||||
|
from app.services.ha_discovery import clear_device_discovery
|
||||||
|
|
||||||
device = _get_device_or_404(db, uuid)
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
# Application-layer guard: refuse deletion if any readings exist.
|
# Application-layer guard: refuse deletion if any readings exist.
|
||||||
# We do NOT rely on SQLite FK RESTRICT — it is not enforced at runtime
|
|
||||||
# without PRAGMA foreign_keys=ON, which is not set in this project.
|
|
||||||
reading_count: int = db.execute(
|
reading_count: int = db.execute(
|
||||||
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
|
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
if reading_count > 0:
|
if reading_count > 0 and not cascade:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=(
|
detail=(
|
||||||
@@ -252,6 +266,60 @@ def delete_device(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
# --- Cascade deletion path ---
|
||||||
|
logger.info(
|
||||||
|
"Cascade-deleting Modbus device %r (uuid=%s): %d reading(s)",
|
||||||
|
device.friendly_name,
|
||||||
|
uuid,
|
||||||
|
reading_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Best-effort HA Discovery cleanup (before DB rows are gone).
|
||||||
|
try:
|
||||||
|
clear_device_discovery(db, uuid)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"delete_device(cascade): HA discovery cleanup raised for uuid=%s; continuing",
|
||||||
|
uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Delete all readings for this device (must happen before device row).
|
||||||
|
deleted_readings_result = db.execute(
|
||||||
|
sa_delete(ModbusReading).where(ModbusReading.device_id == device.id)
|
||||||
|
)
|
||||||
|
readings_deleted: int = deleted_readings_result.rowcount
|
||||||
|
|
||||||
|
# 3. Delete all ExposedEntityToggle rows for this device.
|
||||||
|
# Keys follow the pattern "modbus.<uuid>.<metric>".
|
||||||
|
toggle_prefix = f"modbus.{uuid}.%"
|
||||||
|
deleted_toggles_result = db.execute(
|
||||||
|
sa_delete(ExposedEntityToggle).where(ExposedEntityToggle.key.like(toggle_prefix))
|
||||||
|
)
|
||||||
|
toggles_deleted: int = deleted_toggles_result.rowcount
|
||||||
|
|
||||||
|
# 4. Delete the device itself.
|
||||||
|
db.delete(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Cascade-delete complete for device uuid=%s: %d reading(s), %d toggle(s) removed",
|
||||||
|
uuid,
|
||||||
|
readings_deleted,
|
||||||
|
toggles_deleted,
|
||||||
|
)
|
||||||
|
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
content={
|
||||||
|
"deleted": True,
|
||||||
|
"readings_deleted": readings_deleted,
|
||||||
|
"toggles_deleted": toggles_deleted,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Non-cascade path (no readings exist at this point) ---
|
||||||
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
|
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
|
||||||
db.delete(device)
|
db.delete(device)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ class Settings(BaseSettings):
|
|||||||
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
||||||
ha_discovery_enabled: bool = False
|
ha_discovery_enabled: bool = False
|
||||||
ha_discovery_prefix: str = "homeassistant"
|
ha_discovery_prefix: str = "homeassistant"
|
||||||
|
# State/availability topics use a separate prefix so they live outside the HA discovery
|
||||||
|
# namespace. HA still receives state via the state_topic declared in the discovery config.
|
||||||
|
ha_state_topic_prefix: str = "home_automation"
|
||||||
|
|
||||||
|
# DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
|
||||||
|
# Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True.
|
||||||
|
dsmr_ingest_enabled: bool = False
|
||||||
|
dsmr_mqtt_topic: str = "dsmr/json"
|
||||||
|
dsmr_sample_interval_s: int = 10
|
||||||
|
# DSMR dual-tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak).
|
||||||
|
# Empty string = do not subscribe (tariff-aware pricing disabled).
|
||||||
|
dsmr_tariff_topic: str = "dsmr/meter-stats/electricity_tariff"
|
||||||
|
|
||||||
|
# Tibber dynamic pricing credentials (M6; only used when active contract kind=tibber).
|
||||||
|
tibber_api_token: str = ""
|
||||||
|
tibber_home_id: str = ""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ class DeviceInfo:
|
|||||||
name: str
|
name: str
|
||||||
"""Human-readable device name (may change; triggers HA discovery re-publish)."""
|
"""Human-readable device name (may change; triggers HA discovery re-publish)."""
|
||||||
|
|
||||||
|
provides_availability: bool = True
|
||||||
|
"""Whether this device publishes an availability ("online"/"offline") heartbeat.
|
||||||
|
|
||||||
|
When True (the default — e.g. Modbus devices, which have an ``online``
|
||||||
|
binary_sensor driving availability), the HA Discovery config for each entity
|
||||||
|
declares an ``availability`` topic and HA marks the entity unavailable until
|
||||||
|
"online" is published.
|
||||||
|
|
||||||
|
When False (e.g. the energy-cost device, which has only sensors and no
|
||||||
|
heartbeat source), the config omits ``availability`` so HA treats the entity
|
||||||
|
as *always available* and shows its state as soon as one arrives. Without
|
||||||
|
this, such entities would stay perpetually ``unavailable`` in HA even though
|
||||||
|
their state is being published.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExposableEntity:
|
class ExposableEntity:
|
||||||
@@ -348,3 +363,324 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
|
|
||||||
# Register the modbus provider at module load time.
|
# Register the modbus provider at module load time.
|
||||||
register_provider(_modbus_provider)
|
register_provider(_modbus_provider)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Energy Cost provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||||
|
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||||
|
|
||||||
|
Produces 4 sensor entities grouped under a single HA device "Energy Cost":
|
||||||
|
|
||||||
|
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
||||||
|
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
||||||
|
- ``import_cost_total`` — cumulative import cost (total_increasing, monetary).
|
||||||
|
- ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary).
|
||||||
|
|
||||||
|
Current-price algorithm (source-agnostic, with fallback)
|
||||||
|
---------------------------------------------------------
|
||||||
|
Strategy A: read the ``pricing`` snapshot from the most recent non-degraded
|
||||||
|
``energy_cost_period`` row.
|
||||||
|
|
||||||
|
- For ``kind="tibber"``: the snapshot contains ``"buy"`` and ``"sell"`` keys
|
||||||
|
(per-unit prices in the contract currency).
|
||||||
|
- For ``kind="manual"``: ``"buy_normal"`` and ``"sell_normal"`` are used as
|
||||||
|
representative effective per-unit prices. (Both tariff-slot prices differ
|
||||||
|
only by the base rate; energy_tax and ODE are the same for both, so
|
||||||
|
buy_normal is the higher/conservative single representative value.)
|
||||||
|
|
||||||
|
When no non-degraded period exists or the pricing snapshot lacks the
|
||||||
|
expected keys, ``value_getter`` returns ``None`` (``publish_states``
|
||||||
|
skips None values automatically).
|
||||||
|
|
||||||
|
Cumulative totals
|
||||||
|
-----------------
|
||||||
|
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
||||||
|
``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded
|
||||||
|
to avoid double-counting when they are later overwritten by real values.
|
||||||
|
|
||||||
|
Currency
|
||||||
|
--------
|
||||||
|
Taken from the most recent non-degraded period's ``currency`` column.
|
||||||
|
Falls back to ``"EUR"`` when no such row exists.
|
||||||
|
|
||||||
|
Key convention
|
||||||
|
--------------
|
||||||
|
Fixed stable string keys (not derived from any mutable field or DB id):
|
||||||
|
- ``"energy.buy_price_now"``
|
||||||
|
- ``"energy.sell_price_now"``
|
||||||
|
- ``"energy.import_cost_total"``
|
||||||
|
- ``"energy.export_revenue_total"``
|
||||||
|
|
||||||
|
DeviceInfo identifiers
|
||||||
|
----------------------
|
||||||
|
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that
|
||||||
|
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid
|
||||||
|
(the service uses index [1] as the node_id throughout).
|
||||||
|
"""
|
||||||
|
from app.models.energy import EnergyCostPeriod # local import to avoid circular
|
||||||
|
|
||||||
|
# --- Determine currency and representative pricing from the latest non-degraded row ---
|
||||||
|
|
||||||
|
latest_period: EnergyCostPeriod | None = (
|
||||||
|
session.query(EnergyCostPeriod)
|
||||||
|
.filter(EnergyCostPeriod.degraded.is_(False))
|
||||||
|
.order_by(EnergyCostPeriod.period_start.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
currency: str = "EUR"
|
||||||
|
if latest_period is not None and latest_period.currency:
|
||||||
|
currency = latest_period.currency
|
||||||
|
|
||||||
|
# --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) ---
|
||||||
|
# provides_availability=False: the energy-cost device has only sensors and no
|
||||||
|
# online/offline heartbeat, so its entities must be "always available" in HA.
|
||||||
|
# (Otherwise HA shows them unavailable despite state being published.)
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers=("energy-cost", "energy-cost"),
|
||||||
|
name="Energy Cost",
|
||||||
|
provides_availability=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- value_getter: current buy price ---
|
||||||
|
|
||||||
|
def _make_buy_price_getter() -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter for the current buy price per kWh.
|
||||||
|
|
||||||
|
For ``kind="manual"`` contracts the getter selects the price slot that
|
||||||
|
matches the current DSMR electricity tariff:
|
||||||
|
|
||||||
|
- tariff == 1 (dal / off-peak) → ``buy_dal``
|
||||||
|
- tariff == 2 (normal / peak) → ``buy_normal``
|
||||||
|
- tariff is None / unknown → ``buy_normal`` (safe fallback = current status quo)
|
||||||
|
|
||||||
|
For ``kind="tibber"`` (hourly dynamic pricing) the tariff slot is not
|
||||||
|
applicable; the getter always uses the ``buy`` key from the snapshot.
|
||||||
|
"""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from app.models.energy import EnergyCostPeriod as _ECP
|
||||||
|
from app.services.dsmr_ingest import get_current_tariff
|
||||||
|
|
||||||
|
period = (
|
||||||
|
sess.query(_ECP)
|
||||||
|
.filter(_ECP.degraded.is_(False))
|
||||||
|
.order_by(_ECP.period_start.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if period is None or not period.pricing:
|
||||||
|
return None
|
||||||
|
snap = period.pricing
|
||||||
|
kind = snap.get("kind")
|
||||||
|
if kind == "tibber":
|
||||||
|
raw = snap.get("buy")
|
||||||
|
elif kind == "manual":
|
||||||
|
tariff = get_current_tariff()
|
||||||
|
if tariff == 1:
|
||||||
|
raw = snap.get("buy_dal")
|
||||||
|
else:
|
||||||
|
# tariff == 2, None, or any unexpected value → normal (peak) rate.
|
||||||
|
raw = snap.get("buy_normal")
|
||||||
|
else:
|
||||||
|
# Unknown kind — attempt common keys gracefully.
|
||||||
|
raw = snap.get("buy") or snap.get("buy_normal")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
# --- value_getter: current sell price ---
|
||||||
|
|
||||||
|
def _make_sell_price_getter() -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter for the current sell price per kWh.
|
||||||
|
|
||||||
|
For ``kind="manual"`` contracts the getter selects the price slot that
|
||||||
|
matches the current DSMR electricity tariff:
|
||||||
|
|
||||||
|
- tariff == 1 (dal / off-peak) → ``sell_dal``
|
||||||
|
- tariff == 2 (normal / peak) → ``sell_normal``
|
||||||
|
- tariff is None / unknown → ``sell_normal`` (safe fallback = current status quo)
|
||||||
|
|
||||||
|
For ``kind="tibber"`` the tariff slot is not applicable; always uses ``sell``.
|
||||||
|
"""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from app.models.energy import EnergyCostPeriod as _ECP
|
||||||
|
from app.services.dsmr_ingest import get_current_tariff
|
||||||
|
|
||||||
|
period = (
|
||||||
|
sess.query(_ECP)
|
||||||
|
.filter(_ECP.degraded.is_(False))
|
||||||
|
.order_by(_ECP.period_start.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if period is None or not period.pricing:
|
||||||
|
return None
|
||||||
|
snap = period.pricing
|
||||||
|
kind = snap.get("kind")
|
||||||
|
if kind == "tibber":
|
||||||
|
raw = snap.get("sell")
|
||||||
|
elif kind == "manual":
|
||||||
|
tariff = get_current_tariff()
|
||||||
|
if tariff == 1:
|
||||||
|
raw = snap.get("sell_dal")
|
||||||
|
else:
|
||||||
|
# tariff == 2, None, or any unexpected value → normal (peak) rate.
|
||||||
|
raw = snap.get("sell_normal")
|
||||||
|
else:
|
||||||
|
raw = snap.get("sell") or snap.get("sell_normal")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
# --- value_getter: cumulative import cost (incl. standing charges) ---
|
||||||
|
|
||||||
|
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
||||||
|
|
||||||
|
Value = SUM(import_cost, non-degraded) + standing_charges_cumulative
|
||||||
|
|
||||||
|
where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30.
|
||||||
|
Elapsed days are counted from the active contract version's effective_from date
|
||||||
|
(inclusive of today). Returns None when no non-degraded periods exist.
|
||||||
|
"""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import UTC, datetime as _dt
|
||||||
|
|
||||||
|
from app.models.energy import EnergyCostPeriod as _ECP
|
||||||
|
from app.services.contracts import active_contract_version_at, _as_utc
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
|
||||||
|
total = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||||
|
_ECP.degraded.is_(False)
|
||||||
|
).scalar()
|
||||||
|
if total is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Add prorated standing charges from the active contract version.
|
||||||
|
standing_cumulative = Decimal("0")
|
||||||
|
now_utc = _dt.now(UTC)
|
||||||
|
version = active_contract_version_at(sess, now_utc)
|
||||||
|
if version is not None:
|
||||||
|
vals = version.values or {}
|
||||||
|
standing = vals.get("standing", {})
|
||||||
|
network_fee = Decimal(str(standing.get("network_fee") or 0))
|
||||||
|
management_fee = Decimal(str(standing.get("management_fee") or 0))
|
||||||
|
daily_standing = (network_fee + management_fee) / Decimal("30")
|
||||||
|
|
||||||
|
effective_from = _as_utc(version.effective_from)
|
||||||
|
days = (now_utc.date() - effective_from.date()).days + 1
|
||||||
|
days = max(days, 0)
|
||||||
|
standing_cumulative = daily_standing * days
|
||||||
|
|
||||||
|
return float(Decimal(str(total)) + standing_cumulative)
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
# --- value_getter: cumulative export revenue (incl. tax credit) ---
|
||||||
|
|
||||||
|
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
||||||
|
|
||||||
|
Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative
|
||||||
|
|
||||||
|
where heffingskorting_cumulative = elapsed_days × heffingskorting / 365.
|
||||||
|
Elapsed days are counted from the active contract version's effective_from date
|
||||||
|
(inclusive of today). Returns None when no non-degraded periods exist.
|
||||||
|
"""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import UTC, datetime as _dt
|
||||||
|
|
||||||
|
from app.models.energy import EnergyCostPeriod as _ECP
|
||||||
|
from app.services.contracts import active_contract_version_at, _as_utc
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
|
||||||
|
total = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||||
|
_ECP.degraded.is_(False)
|
||||||
|
).scalar()
|
||||||
|
if total is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Add prorated heffingskorting credit from the active contract version.
|
||||||
|
credit_cumulative = Decimal("0")
|
||||||
|
now_utc = _dt.now(UTC)
|
||||||
|
version = active_contract_version_at(sess, now_utc)
|
||||||
|
if version is not None:
|
||||||
|
vals = version.values or {}
|
||||||
|
credits = vals.get("credits", {})
|
||||||
|
heffingskorting = Decimal(str(credits.get("heffingskorting") or 0))
|
||||||
|
daily_credit = heffingskorting / Decimal("365")
|
||||||
|
|
||||||
|
effective_from = _as_utc(version.effective_from)
|
||||||
|
days = (now_utc.date() - effective_from.date()).days + 1
|
||||||
|
days = max(days, 0)
|
||||||
|
credit_cumulative = daily_credit * days
|
||||||
|
|
||||||
|
return float(Decimal(str(total)) + credit_cumulative)
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
# Price unit string: "<currency>/kWh"
|
||||||
|
price_unit = f"{currency}/kWh"
|
||||||
|
|
||||||
|
entities: list[ExposableEntity] = [
|
||||||
|
ExposableEntity(
|
||||||
|
key="energy.buy_price_now",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class=None,
|
||||||
|
unit=price_unit,
|
||||||
|
name="Energy Buy Price Now",
|
||||||
|
value_getter=_make_buy_price_getter(),
|
||||||
|
state_class="measurement",
|
||||||
|
),
|
||||||
|
ExposableEntity(
|
||||||
|
key="energy.sell_price_now",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class=None,
|
||||||
|
unit=price_unit,
|
||||||
|
name="Energy Sell Price Now",
|
||||||
|
value_getter=_make_sell_price_getter(),
|
||||||
|
state_class="measurement",
|
||||||
|
),
|
||||||
|
ExposableEntity(
|
||||||
|
key="energy.import_cost_total",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="monetary",
|
||||||
|
unit=currency,
|
||||||
|
name="Energy Import Cost (incl. standing)",
|
||||||
|
value_getter=_make_import_cost_getter(),
|
||||||
|
state_class="total",
|
||||||
|
),
|
||||||
|
ExposableEntity(
|
||||||
|
key="energy.export_revenue_total",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="monetary",
|
||||||
|
unit=currency,
|
||||||
|
name="Energy Export Revenue (incl. tax credit)",
|
||||||
|
value_getter=_make_export_revenue_getter(),
|
||||||
|
state_class="total",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
|
||||||
|
# Register the energy cost provider at module load time.
|
||||||
|
register_provider(_energy_cost_provider)
|
||||||
|
|||||||
@@ -18,12 +18,18 @@ Key design decisions
|
|||||||
- **Reconnect**: ``reconnect(settings)`` tears down the old client and
|
- **Reconnect**: ``reconnect(settings)`` tears down the old client and
|
||||||
establishes a fresh connection with the new settings. Callers (e.g. PUT
|
establishes a fresh connection with the new settings. Callers (e.g. PUT
|
||||||
/api/config) call this after saving MQTT-related config values.
|
/api/config) call this after saving MQTT-related config values.
|
||||||
|
- **Subscribe**: ``subscribe(topic, handler)`` registers a topic → handler
|
||||||
|
mapping. Subscriptions are re-established automatically on reconnect.
|
||||||
|
The ``on_message`` callback dispatches incoming payloads to the registered
|
||||||
|
handler; handler exceptions are caught and logged so they never crash the
|
||||||
|
paho loop thread or the network connection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
@@ -68,6 +74,9 @@ class MqttManager:
|
|||||||
self._client: mqtt.Client | None = None
|
self._client: mqtt.Client | None = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
# topic → handler registry; persists across reconnects so subscriptions
|
||||||
|
# are automatically re-established when the client reconnects.
|
||||||
|
self._subscriptions: dict[str, Callable[[bytes], None]] = {}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public properties
|
# Public properties
|
||||||
@@ -142,6 +151,51 @@ class MqttManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("MQTT publish error (topic=%s).", topic)
|
logger.exception("MQTT publish error (topic=%s).", topic)
|
||||||
|
|
||||||
|
def subscribe(self, topic: str, handler: Callable[[bytes], None]) -> None:
|
||||||
|
"""Register *handler* to be called when a message arrives on *topic*.
|
||||||
|
|
||||||
|
If the client is already connected, the subscription is sent to the
|
||||||
|
broker immediately. Otherwise it is queued and will be established
|
||||||
|
the next time ``_on_connect`` fires (including after a reconnect).
|
||||||
|
|
||||||
|
Handler exceptions are swallowed by the ``on_message`` dispatcher so
|
||||||
|
that a buggy handler can never crash the paho loop thread.
|
||||||
|
"""
|
||||||
|
self._subscriptions[topic] = handler
|
||||||
|
# Grab the client reference outside the lock to avoid holding the lock
|
||||||
|
# while calling back into paho (which may itself acquire internal locks).
|
||||||
|
with self._lock:
|
||||||
|
client = self._client
|
||||||
|
connected = self._connected
|
||||||
|
|
||||||
|
if client is not None and connected:
|
||||||
|
try:
|
||||||
|
client.subscribe(topic)
|
||||||
|
logger.debug("MQTT subscribed to topic=%s (immediate).", topic)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT subscribe error (topic=%s).", topic)
|
||||||
|
|
||||||
|
def unsubscribe(self, topic: str) -> None:
|
||||||
|
"""Remove the handler for *topic* and unsubscribe from the broker.
|
||||||
|
|
||||||
|
Idempotent: unknown topics are ignored. Used to apply config changes
|
||||||
|
without a restart (e.g. when DSMR ingest is turned off or its topic
|
||||||
|
changes). If the client is connected, the broker unsubscribe is sent
|
||||||
|
immediately; either way the handler is removed from the registry so it
|
||||||
|
will not be re-subscribed on the next ``_on_connect``.
|
||||||
|
"""
|
||||||
|
self._subscriptions.pop(topic, None)
|
||||||
|
with self._lock:
|
||||||
|
client = self._client
|
||||||
|
connected = self._connected
|
||||||
|
|
||||||
|
if client is not None and connected:
|
||||||
|
try:
|
||||||
|
client.unsubscribe(topic)
|
||||||
|
logger.debug("MQTT unsubscribed from topic=%s.", topic)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT unsubscribe error (topic=%s).", topic)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal helpers — must be called with self._lock held
|
# Internal helpers — must be called with self._lock held
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -168,6 +222,15 @@ class MqttManager:
|
|||||||
else:
|
else:
|
||||||
logger.info("MQTT connected (reason_code=%s).", reason_code)
|
logger.info("MQTT connected (reason_code=%s).", reason_code)
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
# Re-establish all registered subscriptions after (re-)connect.
|
||||||
|
for sub_topic in self._subscriptions:
|
||||||
|
try:
|
||||||
|
_client.subscribe(sub_topic)
|
||||||
|
logger.debug("MQTT re-subscribed to topic=%s.", sub_topic)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"MQTT re-subscribe failed (topic=%s).", sub_topic
|
||||||
|
)
|
||||||
|
|
||||||
# VERSION2 on_disconnect signature:
|
# VERSION2 on_disconnect signature:
|
||||||
# (client, userdata, disconnect_flags, reason_code, properties)
|
# (client, userdata, disconnect_flags, reason_code, properties)
|
||||||
@@ -184,8 +247,29 @@ class MqttManager:
|
|||||||
else:
|
else:
|
||||||
logger.info("MQTT disconnected cleanly.")
|
logger.info("MQTT disconnected cleanly.")
|
||||||
|
|
||||||
|
# VERSION2 on_message signature: (client, userdata, message)
|
||||||
|
def _on_message(
|
||||||
|
_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
message: mqtt.MQTTMessage,
|
||||||
|
) -> None:
|
||||||
|
handler = self._subscriptions.get(message.topic)
|
||||||
|
if handler is None:
|
||||||
|
logger.debug(
|
||||||
|
"MQTT on_message: no handler for topic=%s.", message.topic
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
handler(message.payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"MQTT message handler raised for topic=%s (swallowed).",
|
||||||
|
message.topic,
|
||||||
|
)
|
||||||
|
|
||||||
client.on_connect = _on_connect
|
client.on_connect = _on_connect
|
||||||
client.on_disconnect = _on_disconnect
|
client.on_disconnect = _on_disconnect
|
||||||
|
client.on_message = _on_message
|
||||||
|
|
||||||
# TLS
|
# TLS
|
||||||
if settings.mqtt_tls_enabled:
|
if settings.mqtt_tls_enabled:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Pricing profile framework and strategy registry.
|
||||||
|
|
||||||
|
This package provides:
|
||||||
|
|
||||||
|
- ``profiles``: Pydantic models describing the structure of pricing profiles
|
||||||
|
(``ManualProfile`` / ``TibberProfile``), YAML loaders, and contract-values
|
||||||
|
validation (``load_profile`` / ``list_profiles`` / ``validate_values``).
|
||||||
|
- ``strategies``: A lightweight registry of price-calculation strategies
|
||||||
|
(``register_strategy`` / ``get_strategy``), with built-in implementations for
|
||||||
|
the ``manual`` (fixed dual-tariff) and ``tibber`` (dynamic API) kinds.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Pricing profile loader, validator, and contract-values checker.
|
||||||
|
|
||||||
|
A *pricing profile* is a YAML file that describes the **structure** of an energy
|
||||||
|
contract — which fields exist, their units, and which have defaults. Actual
|
||||||
|
pricing values (the numbers the user fills in via the UI) are stored in the DB
|
||||||
|
as ``EnergyContractVersion.values`` (a JSON blob) and must conform to the
|
||||||
|
corresponding profile's structure.
|
||||||
|
|
||||||
|
Profiles live in ``app/integrations/pricing/profiles/<kind>.yaml`` and are
|
||||||
|
located at runtime relative to *this file* (not CWD), matching the pattern
|
||||||
|
established by the Modbus profile loader.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **Purely data + functions** — no abstract base classes or inheritance.
|
||||||
|
- Two Pydantic models — ``ManualProfile`` and ``TibberProfile`` — capture the
|
||||||
|
different structures of the two supported kinds. A thin union dispatcher in
|
||||||
|
``load_profile`` picks the right one.
|
||||||
|
- ``validate_values(kind, values)`` fills in fields that carry a ``default`` and
|
||||||
|
raises ``ProfileValidationError`` for missing required fields or wrong types.
|
||||||
|
- All errors are subclasses of built-ins so callers need not import this module
|
||||||
|
just to catch them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ValidationError, model_validator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Directory containing the YAML profiles, located relative to *this* file.
|
||||||
|
_PROFILES_DIR = Path(__file__).parent / "profiles"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileNotFoundError(FileNotFoundError):
|
||||||
|
"""Raised when the requested profile YAML file does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileValidationError(ValueError):
|
||||||
|
"""Raised when a profile YAML fails Pydantic validation or when contract
|
||||||
|
values do not conform to the profile structure."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Leaf-node models (a single pricing field: unit + optional default)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FieldSpec(BaseModel):
|
||||||
|
"""Specification for a single numeric pricing field."""
|
||||||
|
|
||||||
|
unit: str
|
||||||
|
"""Physical / monetary unit string (e.g. ``"EUR/kWh"``, ``"EUR/month"``)."""
|
||||||
|
|
||||||
|
default: Optional[float] = None
|
||||||
|
"""Default value used when the field is absent from contract values.
|
||||||
|
``None`` means the field is required (no default)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ManualProfile — fixed / variable dual-tariff contract structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ManualBuySpec(BaseModel):
|
||||||
|
normal: FieldSpec # high-tariff buy price (delivered_2 registers)
|
||||||
|
dal: FieldSpec # low-tariff buy price (delivered_1 registers)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualSellSpec(BaseModel):
|
||||||
|
normal: FieldSpec # high-tariff sell / return price
|
||||||
|
dal: FieldSpec # low-tariff sell / return price
|
||||||
|
|
||||||
|
|
||||||
|
class ManualEnergySpec(BaseModel):
|
||||||
|
dual_tariff: bool # always True for manual profiles
|
||||||
|
buy: ManualBuySpec
|
||||||
|
sell: ManualSellSpec
|
||||||
|
energy_tax: FieldSpec # added to buy price; includes VAT
|
||||||
|
ode: FieldSpec # currently merged into energy_tax; default 0
|
||||||
|
|
||||||
|
|
||||||
|
class ManualStandingSpec(BaseModel):
|
||||||
|
network_fee: FieldSpec # EUR/month
|
||||||
|
management_fee: FieldSpec # EUR/month
|
||||||
|
|
||||||
|
|
||||||
|
class ManualCreditsSpec(BaseModel):
|
||||||
|
heffingskorting: FieldSpec # EUR/year — energy-tax credit deducted at summary
|
||||||
|
|
||||||
|
|
||||||
|
class ManualProfile(BaseModel):
|
||||||
|
"""Complete structure description for a ``manual`` pricing contract."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
label: str
|
||||||
|
energy: ManualEnergySpec
|
||||||
|
standing: ManualStandingSpec
|
||||||
|
credits: ManualCreditsSpec
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_kind(self) -> "ManualProfile":
|
||||||
|
if self.kind != "manual":
|
||||||
|
raise ValueError(f"ManualProfile requires kind='manual', got {self.kind!r}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TibberProfile — dynamic Tibber API contract structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TibberEnergySpec(BaseModel):
|
||||||
|
source: str # must be "tibber_api"
|
||||||
|
energy_tax: FieldSpec # subtracted from total to derive sell price
|
||||||
|
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
||||||
|
|
||||||
|
|
||||||
|
class TibberStandingSpec(BaseModel):
|
||||||
|
management_fee: FieldSpec # EUR/month; has a default
|
||||||
|
network_fee: FieldSpec # EUR/month
|
||||||
|
|
||||||
|
|
||||||
|
class TibberCreditsSpec(BaseModel):
|
||||||
|
heffingskorting: FieldSpec # EUR/year
|
||||||
|
|
||||||
|
|
||||||
|
class TibberProfile(BaseModel):
|
||||||
|
"""Complete structure description for a ``tibber`` pricing contract."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
label: str
|
||||||
|
energy: TibberEnergySpec
|
||||||
|
standing: TibberStandingSpec
|
||||||
|
credits: TibberCreditsSpec
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_kind(self) -> "TibberProfile":
|
||||||
|
if self.kind != "tibber":
|
||||||
|
raise ValueError(f"TibberProfile requires kind='tibber', got {self.kind!r}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# A union type for type hints where either profile is acceptable.
|
||||||
|
AnyProfile = ManualProfile | TibberProfile
|
||||||
|
|
||||||
|
# Map kind → Pydantic model class used for validation.
|
||||||
|
_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile]] = {
|
||||||
|
"manual": ManualProfile,
|
||||||
|
"tibber": TibberProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_profile(kind: str) -> AnyProfile:
|
||||||
|
"""Load and validate a pricing profile by kind name.
|
||||||
|
|
||||||
|
The profile file is expected at
|
||||||
|
``app/integrations/pricing/profiles/<kind>.yaml``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
kind:
|
||||||
|
Profile kind without extension (``"manual"`` or ``"tibber"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ManualProfile | TibberProfile
|
||||||
|
Validated profile model.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ProfileNotFoundError
|
||||||
|
If ``profiles/<kind>.yaml`` does not exist.
|
||||||
|
ProfileValidationError
|
||||||
|
If the YAML is syntactically valid but fails schema validation.
|
||||||
|
"""
|
||||||
|
path = _PROFILES_DIR / f"{kind}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
raise ProfileNotFoundError(
|
||||||
|
f"Pricing profile '{kind}' not found (looked for {path})"
|
||||||
|
)
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
raw = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Choose the right Pydantic model based on the ``kind`` field in the YAML.
|
||||||
|
yaml_kind = raw.get("kind", kind)
|
||||||
|
model_cls = _PROFILE_MODELS.get(yaml_kind)
|
||||||
|
if model_cls is None:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{kind}' has unknown kind={yaml_kind!r}; "
|
||||||
|
f"supported: {list(_PROFILE_MODELS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return model_cls.model_validate(raw)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{kind}' failed validation: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> list[dict[str, Any]]:
|
||||||
|
"""Return structural data for all available pricing profiles.
|
||||||
|
|
||||||
|
Scans ``app/integrations/pricing/profiles/*.yaml``, loads each, and returns
|
||||||
|
a list of dicts (Pydantic model dumps). Profiles that fail to load are
|
||||||
|
skipped with a warning.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict[str, Any]]
|
||||||
|
One entry per valid profile, suitable for JSON serialisation and
|
||||||
|
front-end form rendering.
|
||||||
|
"""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
if not _PROFILES_DIR.exists():
|
||||||
|
return results
|
||||||
|
|
||||||
|
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
|
||||||
|
kind = path.stem
|
||||||
|
try:
|
||||||
|
profile = load_profile(kind)
|
||||||
|
results.append(profile.model_dump())
|
||||||
|
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
|
||||||
|
logger.warning("Skipping malformed pricing profile '%s': %s", kind, exc)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contract-values validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *values* with any ``ode`` default applied if missing."""
|
||||||
|
filled = dict(values)
|
||||||
|
|
||||||
|
energy = dict(filled.get("energy", {}))
|
||||||
|
|
||||||
|
# Apply default for ode (default=0) if absent.
|
||||||
|
if "ode" not in energy and profile.energy.ode.default is not None:
|
||||||
|
energy["ode"] = profile.energy.ode.default
|
||||||
|
|
||||||
|
filled["energy"] = energy
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
|
||||||
|
filled = dict(values)
|
||||||
|
|
||||||
|
energy = dict(filled.get("energy", {}))
|
||||||
|
# Apply default for sell_adjust (default=0) if absent.
|
||||||
|
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
||||||
|
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
||||||
|
filled["energy"] = energy
|
||||||
|
|
||||||
|
standing = dict(filled.get("standing", {}))
|
||||||
|
# Apply default for management_fee if absent.
|
||||||
|
if (
|
||||||
|
"management_fee" not in standing
|
||||||
|
and profile.standing.management_fee.default is not None
|
||||||
|
):
|
||||||
|
standing["management_fee"] = profile.standing.management_fee.default
|
||||||
|
filled["standing"] = standing
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _require_numeric(section: str, key: str, container: dict[str, Any]) -> None:
|
||||||
|
"""Assert that *container[key]* exists and is a number; raise ProfileValidationError."""
|
||||||
|
if key not in container:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Contract values missing required field '{section}.{key}'"
|
||||||
|
)
|
||||||
|
val = container[key]
|
||||||
|
if not isinstance(val, (int, float)):
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Contract values field '{section}.{key}' must be a number, "
|
||||||
|
f"got {type(val).__name__!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manual_values(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
|
||||||
|
"""Validate and fill-defaults for manual contract values.
|
||||||
|
|
||||||
|
Returns the filled values dict on success. Raises ProfileValidationError
|
||||||
|
on missing required fields or wrong types.
|
||||||
|
"""
|
||||||
|
filled = _fill_defaults_manual(values, profile)
|
||||||
|
energy = filled.get("energy", {})
|
||||||
|
buy = energy.get("buy", {})
|
||||||
|
sell = energy.get("sell", {})
|
||||||
|
standing = filled.get("standing", {})
|
||||||
|
credits = filled.get("credits", {})
|
||||||
|
|
||||||
|
# Required energy.buy fields.
|
||||||
|
_require_numeric("energy.buy", "normal", buy)
|
||||||
|
_require_numeric("energy.buy", "dal", buy)
|
||||||
|
# Required energy.sell fields.
|
||||||
|
_require_numeric("energy.sell", "normal", sell)
|
||||||
|
_require_numeric("energy.sell", "dal", sell)
|
||||||
|
# Required energy fields.
|
||||||
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "ode", energy)
|
||||||
|
# Required standing fields.
|
||||||
|
_require_numeric("standing", "network_fee", standing)
|
||||||
|
_require_numeric("standing", "management_fee", standing)
|
||||||
|
# Required credits fields.
|
||||||
|
_require_numeric("credits", "heffingskorting", credits)
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
|
"""Validate and fill-defaults for tibber contract values.
|
||||||
|
|
||||||
|
Returns the filled values dict on success. Raises ProfileValidationError
|
||||||
|
on missing required fields or wrong types.
|
||||||
|
"""
|
||||||
|
filled = _fill_defaults_tibber(values, profile)
|
||||||
|
energy = filled.get("energy", {})
|
||||||
|
standing = filled.get("standing", {})
|
||||||
|
credits = filled.get("credits", {})
|
||||||
|
|
||||||
|
# Required energy fields.
|
||||||
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "sell_adjust", energy)
|
||||||
|
# Required standing fields.
|
||||||
|
_require_numeric("standing", "management_fee", standing)
|
||||||
|
_require_numeric("standing", "network_fee", standing)
|
||||||
|
# Required credits fields.
|
||||||
|
_require_numeric("credits", "heffingskorting", credits)
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate a contract-values dict against the named profile structure.
|
||||||
|
|
||||||
|
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
||||||
|
``sell_adjust``, tibber ``management_fee``) are silently filled in when
|
||||||
|
absent from *values*. Fields with no default that are absent, or fields
|
||||||
|
whose value is not a number, cause a ``ProfileValidationError``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
kind:
|
||||||
|
Profile kind (``"manual"`` or ``"tibber"``).
|
||||||
|
values:
|
||||||
|
Contract values dict as stored in ``EnergyContractVersion.values``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict[str, Any]
|
||||||
|
A (possibly mutated) copy of *values* with defaults applied.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ProfileNotFoundError
|
||||||
|
If the profile YAML for *kind* does not exist.
|
||||||
|
ProfileValidationError
|
||||||
|
If required fields are missing or have wrong types.
|
||||||
|
"""
|
||||||
|
profile = load_profile(kind)
|
||||||
|
if isinstance(profile, ManualProfile):
|
||||||
|
return _validate_manual_values(values, profile)
|
||||||
|
if isinstance(profile, TibberProfile):
|
||||||
|
return _validate_tibber_values(values, profile)
|
||||||
|
# Unreachable with current kinds, but guard for future extensions.
|
||||||
|
raise ProfileValidationError(f"No validator implemented for kind={kind!r}")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
kind: manual
|
||||||
|
label: 固定 / 可变费率(NL,双费率)
|
||||||
|
|
||||||
|
energy:
|
||||||
|
dual_tariff: true # use delivered_1/2, returned_1/2 for low/high tariffs
|
||||||
|
buy:
|
||||||
|
normal: { unit: EUR/kWh } # high-tariff buy price (delivered_2 register)
|
||||||
|
dal: { unit: EUR/kWh } # low-tariff buy price (delivered_1 register)
|
||||||
|
sell:
|
||||||
|
normal: { unit: EUR/kWh } # high-tariff sell / return price
|
||||||
|
dal: { unit: EUR/kWh } # low-tariff sell / return price (currently equal to normal)
|
||||||
|
energy_tax: { unit: EUR/kWh } # energy tax added to buy price (incl. VAT)
|
||||||
|
ode: { unit: EUR/kWh, default: 0 } # currently merged into energy_tax
|
||||||
|
|
||||||
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
management_fee: { unit: EUR/month }
|
||||||
|
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
kind: tibber
|
||||||
|
label: Tibber 动态电价(15 分钟)
|
||||||
|
|
||||||
|
energy:
|
||||||
|
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
||||||
|
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
||||||
|
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
|
||||||
|
|
||||||
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Price-calculation strategy registry and built-in implementations.
|
||||||
|
|
||||||
|
A *strategy* is a plain function registered under a ``kind`` string. Given
|
||||||
|
per-register kWh deltas, the period start time, the active contract-version
|
||||||
|
values, and a DB session, it returns a result dict with:
|
||||||
|
|
||||||
|
{
|
||||||
|
"import_cost": Decimal, # total cost of electricity drawn from grid
|
||||||
|
"export_revenue": Decimal, # total revenue from electricity fed to grid
|
||||||
|
"net_cost": Decimal, # import_cost − export_revenue
|
||||||
|
"pricing": dict, # snapshot of the price inputs used (for auditing)
|
||||||
|
}
|
||||||
|
|
||||||
|
**Import and export are kept separate throughout** — net_cost is only derived
|
||||||
|
at the end. This supports the Dutch no-netting rule (saldering afgebouwd).
|
||||||
|
|
||||||
|
**All monetary arithmetic uses Decimal** converted via ``Decimal(str(x))`` to
|
||||||
|
avoid float binary rounding errors.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **Purely data + functions**: no ABC, no class hierarchy. A ``dict``-based
|
||||||
|
registry is the simplest structure that supports the two current kinds and
|
||||||
|
leaves the door open for future additions (e.g. ``octopus``, ``frank``).
|
||||||
|
- ``deltas`` is a plain dataclass ``PeriodDeltas`` — typed, but no ORM.
|
||||||
|
- Tibber strategy queries ``TibberPrice`` directly from the session; it does not
|
||||||
|
call the Tibber API (that is T05's job).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared data types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PeriodDeltas:
|
||||||
|
"""Per-register kWh deltas for one 15-minute billing period.
|
||||||
|
|
||||||
|
All values are ``Decimal`` and represent ``end_reading − start_reading``
|
||||||
|
for the corresponding cumulative energy register.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
d1: Decimal
|
||||||
|
Delivered (consumed) kWh on the low/dal tariff register (delivered_1).
|
||||||
|
d2: Decimal
|
||||||
|
Delivered (consumed) kWh on the normal/high tariff register (delivered_2).
|
||||||
|
r1: Decimal
|
||||||
|
Returned (fed-to-grid) kWh on the low/dal tariff register (returned_1).
|
||||||
|
r2: Decimal
|
||||||
|
Returned (fed-to-grid) kWh on the normal/high tariff register (returned_2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
d1: Decimal # delivered low-tariff
|
||||||
|
d2: Decimal # delivered high-tariff
|
||||||
|
r1: Decimal # returned low-tariff
|
||||||
|
r2: Decimal # returned high-tariff
|
||||||
|
|
||||||
|
|
||||||
|
# Strategy callable signature.
|
||||||
|
StrategyFn = Callable[
|
||||||
|
[PeriodDeltas, datetime, dict[str, Any], Session],
|
||||||
|
dict[str, Any],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_REGISTRY: dict[str, StrategyFn] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_strategy(kind: str, fn: StrategyFn) -> None:
|
||||||
|
"""Register *fn* as the price-calculation strategy for *kind*.
|
||||||
|
|
||||||
|
Overwrites any previously registered strategy for the same kind (allows
|
||||||
|
monkey-patching in tests).
|
||||||
|
"""
|
||||||
|
_REGISTRY[kind] = fn
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy(kind: str) -> StrategyFn:
|
||||||
|
"""Return the registered strategy for *kind*.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
If no strategy is registered for *kind*.
|
||||||
|
"""
|
||||||
|
if kind not in _REGISTRY:
|
||||||
|
raise KeyError(
|
||||||
|
f"No price strategy registered for kind={kind!r}. "
|
||||||
|
f"Available: {sorted(_REGISTRY)}"
|
||||||
|
)
|
||||||
|
return _REGISTRY[kind]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: Any) -> Decimal:
|
||||||
|
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manual strategy — fixed dual-tariff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_strategy(
|
||||||
|
deltas: PeriodDeltas,
|
||||||
|
t0: datetime,
|
||||||
|
values: dict[str, Any],
|
||||||
|
session: Session,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Calculate billing costs for one period using fixed dual-tariff rates.
|
||||||
|
|
||||||
|
Formula (§3.4):
|
||||||
|
- ``buy_dal = energy.buy.dal + energy.energy_tax + energy.ode``
|
||||||
|
- ``buy_normal = energy.buy.normal + energy.energy_tax + energy.ode``
|
||||||
|
- ``import_cost = Δd1 × buy_dal + Δd2 × buy_normal``
|
||||||
|
- ``sell_dal = energy.sell.dal`` (no tax on return price)
|
||||||
|
- ``sell_normal = energy.sell.normal``
|
||||||
|
- ``export_revenue = Δr1 × sell_dal + Δr2 × sell_normal``
|
||||||
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
The ``pricing`` snapshot contains all per-unit prices used so that the
|
||||||
|
result is fully auditable without re-querying the contract version.
|
||||||
|
"""
|
||||||
|
energy = values.get("energy", {})
|
||||||
|
buy = energy.get("buy", {})
|
||||||
|
sell = energy.get("sell", {})
|
||||||
|
|
||||||
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
ode = _to_decimal(energy.get("ode", 0))
|
||||||
|
|
||||||
|
buy_dal_base = _to_decimal(buy.get("dal", 0))
|
||||||
|
buy_normal_base = _to_decimal(buy.get("normal", 0))
|
||||||
|
sell_dal = _to_decimal(sell.get("dal", 0))
|
||||||
|
sell_normal = _to_decimal(sell.get("normal", 0))
|
||||||
|
|
||||||
|
# Effective buy prices including all taxes.
|
||||||
|
buy_dal = buy_dal_base + energy_tax + ode
|
||||||
|
buy_normal = buy_normal_base + energy_tax + ode
|
||||||
|
|
||||||
|
import_cost = deltas.d1 * buy_dal + deltas.d2 * buy_normal
|
||||||
|
export_revenue = deltas.r1 * sell_dal + deltas.r2 * sell_normal
|
||||||
|
net_cost = import_cost - export_revenue
|
||||||
|
|
||||||
|
pricing_snapshot = {
|
||||||
|
"kind": "manual",
|
||||||
|
"buy_dal": str(buy_dal),
|
||||||
|
"buy_normal": str(buy_normal),
|
||||||
|
"sell_dal": str(sell_dal),
|
||||||
|
"sell_normal": str(sell_normal),
|
||||||
|
"energy_tax": str(energy_tax),
|
||||||
|
"ode": str(ode),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"import_cost": import_cost,
|
||||||
|
"export_revenue": export_revenue,
|
||||||
|
"net_cost": net_cost,
|
||||||
|
"pricing": pricing_snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tibber strategy — dynamic 15-minute spot price
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TibberPriceNotFoundError(LookupError):
|
||||||
|
"""Raised when no TibberPrice row covers the requested period start.
|
||||||
|
|
||||||
|
The billing engine (T07) catches this to mark the period as ``degraded``
|
||||||
|
or skip it until a price becomes available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _tibber_strategy(
|
||||||
|
deltas: PeriodDeltas,
|
||||||
|
t0: datetime,
|
||||||
|
values: dict[str, Any],
|
||||||
|
session: Session,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Calculate billing costs for one period using Tibber dynamic pricing.
|
||||||
|
|
||||||
|
Price lookup:
|
||||||
|
Takes the most recent ``TibberPrice`` row where ``starts_at ≤ t0``
|
||||||
|
(i.e. the slot that was in effect at *t0*). This is a DESC LIMIT 1
|
||||||
|
query on ``starts_at``.
|
||||||
|
|
||||||
|
Formula (§3.4):
|
||||||
|
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
||||||
|
- ``sell = total − energy_tax − sell_adjust``
|
||||||
|
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||||
|
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||||
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||||
|
API price applies to the full delivered/returned volume.
|
||||||
|
|
||||||
|
Negative ``total`` (extreme negative spot prices):
|
||||||
|
When ``total`` is negative the ``sell`` price will also be negative,
|
||||||
|
meaning ``export_revenue`` becomes negative (feeding to grid *costs*
|
||||||
|
money). This is the mathematically correct outcome and is left as-is.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TibberPriceNotFoundError
|
||||||
|
If no ``TibberPrice`` row exists with ``starts_at ≤ t0``. The caller
|
||||||
|
(T07 billing engine) should catch this and mark the period as
|
||||||
|
``degraded`` or skip it for later recomputation.
|
||||||
|
"""
|
||||||
|
from app.models.energy import TibberPrice # local import to avoid circular
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
price_row: TibberPrice | None = (
|
||||||
|
session.query(TibberPrice)
|
||||||
|
.filter(TibberPrice.starts_at <= t0)
|
||||||
|
.order_by(desc(TibberPrice.starts_at))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if price_row is None:
|
||||||
|
raise TibberPriceNotFoundError(
|
||||||
|
f"No TibberPrice found covering t0={t0.isoformat()!r}; "
|
||||||
|
"period cannot be billed until price data is available."
|
||||||
|
)
|
||||||
|
|
||||||
|
energy = values.get("energy", {})
|
||||||
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
|
total = _to_decimal(price_row.total)
|
||||||
|
buy = total
|
||||||
|
sell = total - energy_tax - sell_adjust
|
||||||
|
|
||||||
|
total_delivered = deltas.d1 + deltas.d2
|
||||||
|
total_returned = deltas.r1 + deltas.r2
|
||||||
|
|
||||||
|
import_cost = total_delivered * buy
|
||||||
|
export_revenue = total_returned * sell
|
||||||
|
net_cost = import_cost - export_revenue
|
||||||
|
|
||||||
|
pricing_snapshot = {
|
||||||
|
"kind": "tibber",
|
||||||
|
"tibber_price_starts_at": price_row.starts_at.isoformat(),
|
||||||
|
"tibber_price_id": price_row.id,
|
||||||
|
"total": str(total),
|
||||||
|
"buy": str(buy),
|
||||||
|
"sell": str(sell),
|
||||||
|
"energy_tax": str(energy_tax),
|
||||||
|
"sell_adjust": str(sell_adjust),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"import_cost": import_cost,
|
||||||
|
"export_revenue": export_revenue,
|
||||||
|
"net_cost": net_cost,
|
||||||
|
"pricing": pricing_snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Register built-in strategies at module import time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
register_strategy("manual", _manual_strategy)
|
||||||
|
register_strategy("tibber", _tibber_strategy)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Tibber dynamic electricity pricing integration.
|
||||||
|
|
||||||
|
This package provides:
|
||||||
|
- ``client``: HTTP client for the Tibber GraphQL API (price fetching).
|
||||||
|
- Custom exceptions: ``TibberError`` (base) and ``TibberAuthError`` (auth failure).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from app.integrations.tibber.client import fetch_price_range, TibberAuthError
|
||||||
|
"""
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Tibber GraphQL API client for fetching electricity price data.
|
||||||
|
|
||||||
|
Design decisions
|
||||||
|
----------------
|
||||||
|
- Uses ``httpx`` (already a project dependency) for all HTTP requests.
|
||||||
|
- Token is **never** written to log messages or exception strings to prevent
|
||||||
|
credential leakage into log aggregators.
|
||||||
|
- ``fetch_price_range`` returns a list of ``PricePoint`` objects with UTC-aware
|
||||||
|
``starts_at`` datetimes; the number of nodes is not assumed — all returned
|
||||||
|
nodes are parsed regardless of count.
|
||||||
|
- ``fetch_current_price`` uses a separate, simpler query that asks for the
|
||||||
|
*current* price point only; used by the connection-test endpoint (T09) to
|
||||||
|
produce a fast three-state result (success / auth-error / network-error).
|
||||||
|
- Authentication failures (HTTP 401/403) are raised as ``TibberAuthError`` so
|
||||||
|
that callers (the test endpoint) can distinguish them from generic network or
|
||||||
|
API errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
||||||
|
_DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
# GraphQL query to fetch a range of 15-minute price nodes.
|
||||||
|
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
||||||
|
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
|
||||||
|
# but the Tibber API typically starts from the current slot and returns at
|
||||||
|
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
||||||
|
# "today + tomorrow").
|
||||||
|
_PRICE_RANGE_QUERY = """
|
||||||
|
{
|
||||||
|
viewer {
|
||||||
|
homes {
|
||||||
|
id
|
||||||
|
currentSubscription {
|
||||||
|
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
||||||
|
nodes {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# GraphQL query to fetch the single *current* price point.
|
||||||
|
_CURRENT_PRICE_QUERY = """
|
||||||
|
{
|
||||||
|
viewer {
|
||||||
|
homes {
|
||||||
|
id
|
||||||
|
currentSubscription {
|
||||||
|
priceInfo {
|
||||||
|
current {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TibberError(Exception):
|
||||||
|
"""Base class for all Tibber client errors.
|
||||||
|
|
||||||
|
Raised for network failures, timeouts, unexpected HTTP status codes, and
|
||||||
|
malformed API responses. The exception message will **never** contain the
|
||||||
|
API token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TibberAuthError(TibberError):
|
||||||
|
"""Raised when the Tibber API rejects the provided token (HTTP 401/403).
|
||||||
|
|
||||||
|
Callers that implement a three-state connection test should catch this
|
||||||
|
exception separately from ``TibberError`` to distinguish auth problems
|
||||||
|
from network / API problems.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PricePoint:
|
||||||
|
"""One 15-minute price slot returned by the Tibber API.
|
||||||
|
|
||||||
|
All monetary values are in ``currency`` and include VAT (user-facing).
|
||||||
|
``starts_at`` is always a timezone-aware UTC datetime regardless of the
|
||||||
|
timezone offset that Tibber returns in ``startsAt``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
starts_at: datetime # UTC-aware
|
||||||
|
total: float # full all-in price (energy + tax)
|
||||||
|
energy: float # spot energy component
|
||||||
|
tax: float # tax component
|
||||||
|
currency: str # ISO 4217 (typically "EUR")
|
||||||
|
level: str | None # e.g. "CHEAP", "NORMAL", "EXPENSIVE"; None if absent
|
||||||
|
resolution: str # label for the slot resolution (e.g. "QUARTER_HOURLY")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_starts_at(raw: str) -> datetime:
|
||||||
|
"""Parse an ISO 8601 timestamp with timezone offset and convert to UTC.
|
||||||
|
|
||||||
|
Tibber returns timestamps like ``2026-06-23T00:00:00.000+02:00``.
|
||||||
|
``datetime.fromisoformat`` handles this format in Python 3.11+; the result
|
||||||
|
is then converted to UTC via ``.astimezone(UTC)``.
|
||||||
|
"""
|
||||||
|
return datetime.fromisoformat(raw).astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_node(node: dict[str, Any], resolution: str) -> PricePoint:
|
||||||
|
"""Parse a single price node dict into a ``PricePoint``."""
|
||||||
|
return PricePoint(
|
||||||
|
starts_at=_parse_starts_at(node["startsAt"]),
|
||||||
|
total=float(node["total"]),
|
||||||
|
energy=float(node["energy"]),
|
||||||
|
tax=float(node["tax"]),
|
||||||
|
currency=str(node["currency"]),
|
||||||
|
level=node.get("level") or None,
|
||||||
|
resolution=resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_home(homes: list[dict[str, Any]], home_id: str | None) -> dict[str, Any]:
|
||||||
|
"""Return the home dict matching *home_id*, or the first home if None."""
|
||||||
|
if not homes:
|
||||||
|
raise TibberError("Tibber API returned no homes")
|
||||||
|
if home_id is not None:
|
||||||
|
for h in homes:
|
||||||
|
if h.get("id") == home_id:
|
||||||
|
return h
|
||||||
|
raise TibberError("Tibber home id not found in API response")
|
||||||
|
return homes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _post_graphql(token: str, query: str, timeout: float) -> dict[str, Any]:
|
||||||
|
"""POST the GraphQL *query* and return the parsed ``data`` dict.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TibberAuthError
|
||||||
|
On HTTP 401 or 403.
|
||||||
|
TibberError
|
||||||
|
On timeouts, network errors, non-2xx responses, or unexpected body shape.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
_TIBBER_API_URL,
|
||||||
|
json={"query": query},
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise TibberError("Tibber API request timed out") from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise TibberError("Tibber API request failed") from exc
|
||||||
|
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
raise TibberAuthError("Tibber API authentication failed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise TibberError(f"Tibber API returned unexpected status {response.status_code}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise TibberError("Tibber API returned non-JSON response") from exc
|
||||||
|
|
||||||
|
if "errors" in body:
|
||||||
|
# GraphQL errors are not HTTP errors; surface them as TibberError.
|
||||||
|
# Do not include token in the message.
|
||||||
|
raise TibberError("Tibber GraphQL returned errors")
|
||||||
|
|
||||||
|
data = body.get("data")
|
||||||
|
if data is None:
|
||||||
|
raise TibberError("Tibber API response missing 'data' field")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_price_range(
|
||||||
|
token: str,
|
||||||
|
home_id: str | None = None,
|
||||||
|
*,
|
||||||
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
) -> list[PricePoint]:
|
||||||
|
"""Fetch a range of 15-minute price nodes from the Tibber API.
|
||||||
|
|
||||||
|
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
|
||||||
|
and parses every returned node into a ``PricePoint``. The number of nodes
|
||||||
|
is not assumed — all returned nodes are parsed regardless of count.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token:
|
||||||
|
Tibber API token. **Never** logged or included in exception messages.
|
||||||
|
home_id:
|
||||||
|
If given, the home with this Tibber home ID is selected. If ``None``,
|
||||||
|
the first home in the account is used.
|
||||||
|
timeout:
|
||||||
|
HTTP request timeout in seconds (default 15 s).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[PricePoint]
|
||||||
|
List of price points with UTC-aware ``starts_at`` values.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TibberAuthError
|
||||||
|
If the token is rejected (HTTP 401/403).
|
||||||
|
TibberError
|
||||||
|
For network failures, timeouts, or unexpected API responses.
|
||||||
|
"""
|
||||||
|
data = _post_graphql(token, _PRICE_RANGE_QUERY, timeout)
|
||||||
|
|
||||||
|
try:
|
||||||
|
homes = data["viewer"]["homes"]
|
||||||
|
except (KeyError, TypeError) as exc:
|
||||||
|
raise TibberError("Tibber API response has unexpected shape") from exc
|
||||||
|
|
||||||
|
home = _pick_home(homes, home_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
|
||||||
|
except (KeyError, TypeError) as exc:
|
||||||
|
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
|
||||||
|
|
||||||
|
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_price(
|
||||||
|
token: str,
|
||||||
|
home_id: str | None = None,
|
||||||
|
*,
|
||||||
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
) -> PricePoint:
|
||||||
|
"""Fetch the *current* price point from the Tibber API.
|
||||||
|
|
||||||
|
Uses the lighter ``priceInfo { current { ... } }`` query rather than the
|
||||||
|
full range query, making it suitable for a fast connection test.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token:
|
||||||
|
Tibber API token. **Never** logged or included in exception messages.
|
||||||
|
home_id:
|
||||||
|
If given, the home with this Tibber home ID is selected. If ``None``,
|
||||||
|
the first home in the account is used.
|
||||||
|
timeout:
|
||||||
|
HTTP request timeout in seconds (default 15 s).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
PricePoint
|
||||||
|
The current price point with a UTC-aware ``starts_at``.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TibberAuthError
|
||||||
|
If the token is rejected (HTTP 401/403).
|
||||||
|
TibberError
|
||||||
|
For network failures, timeouts, missing current price, or unexpected
|
||||||
|
API responses.
|
||||||
|
"""
|
||||||
|
data = _post_graphql(token, _CURRENT_PRICE_QUERY, timeout)
|
||||||
|
|
||||||
|
try:
|
||||||
|
homes = data["viewer"]["homes"]
|
||||||
|
except (KeyError, TypeError) as exc:
|
||||||
|
raise TibberError("Tibber API response has unexpected shape") from exc
|
||||||
|
|
||||||
|
home = _pick_home(homes, home_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = home["currentSubscription"]["priceInfo"]["current"]
|
||||||
|
except (KeyError, TypeError) as exc:
|
||||||
|
raise TibberError("Tibber API response missing current price") from exc
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
raise TibberError("Tibber API returned null for current price")
|
||||||
|
|
||||||
|
return _parse_node(current, "QUARTER_HOURLY")
|
||||||
+92
@@ -13,6 +13,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app import models # noqa: F401
|
from app import models # noqa: F401
|
||||||
from app.api.routes.api.config import router as api_config_router
|
from app.api.routes.api.config import router as api_config_router
|
||||||
from app.api.routes.api.data import router as api_data_router
|
from app.api.routes.api.data import router as api_data_router
|
||||||
|
from app.api.routes.api.energy import router as api_energy_router
|
||||||
|
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
|
||||||
from app.api.routes.api.expose import router as api_expose_router
|
from app.api.routes.api.expose import router as api_expose_router
|
||||||
from app.api.routes.api.modbus import router as api_modbus_router
|
from app.api.routes.api.modbus import router as api_modbus_router
|
||||||
from app.api.routes.api.session import router as api_session_router
|
from app.api.routes.api.session import router as api_session_router
|
||||||
@@ -27,9 +29,12 @@ from app.config import get_settings
|
|||||||
from app.integrations.mqtt import mqtt_manager
|
from app.integrations.mqtt import mqtt_manager
|
||||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||||
from app.services.config_page import build_runtime_settings, seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
from app.services.config_page import build_runtime_settings, seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
||||||
|
from app.services.dsmr_ingest import apply_dsmr_subscription
|
||||||
from app.services.public_ip import check_public_ipv4_and_notify
|
from app.services.public_ip import check_public_ipv4_and_notify
|
||||||
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
|
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
|
||||||
from app.services.ha_discovery import publish_discovery, publish_states
|
from app.services.ha_discovery import publish_discovery, publish_states
|
||||||
|
from app.services.tibber_prices import refresh_prices
|
||||||
|
from app.services.energy_cost import compute_closed_periods
|
||||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -62,6 +67,64 @@ def _run_scheduled_modbus_poll() -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduled_tibber_refresh() -> None:
|
||||||
|
"""Scheduled job: fetch Tibber 15-minute prices and upsert into tibber_price.
|
||||||
|
|
||||||
|
Runs every hour so that:
|
||||||
|
- Today's prices are available from startup.
|
||||||
|
- Tomorrow's prices (published by Tibber around 13:00 CET / 11:00 UTC) are
|
||||||
|
picked up within an hour of publication without requiring a server restart.
|
||||||
|
|
||||||
|
The job is a no-op when:
|
||||||
|
- No active energy contract with kind="tibber" exists.
|
||||||
|
- The Tibber API token is empty in the runtime settings.
|
||||||
|
|
||||||
|
Any client exceptions (auth failures, network errors) are caught and logged
|
||||||
|
so that a single failed fetch does not crash the scheduler or affect the
|
||||||
|
other background jobs.
|
||||||
|
"""
|
||||||
|
session_local = get_session_local()
|
||||||
|
session: Session = session_local()
|
||||||
|
try:
|
||||||
|
runtime_settings = build_runtime_settings(session, get_settings())
|
||||||
|
refresh_prices(session, runtime_settings)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_run_scheduled_tibber_refresh: unexpected error")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduled_energy_cost() -> None:
|
||||||
|
"""Scheduled job: compute billing records for all uncalculated closed 15-minute periods.
|
||||||
|
|
||||||
|
Runs every minute so that a new period is picked up within 1 minute of
|
||||||
|
closing. The job is a no-op when:
|
||||||
|
- No active energy contract with a version covering the period exists.
|
||||||
|
- DSMR data has not yet arrived for the period boundaries.
|
||||||
|
- The period's billing record already exists and is not degraded.
|
||||||
|
|
||||||
|
Any unexpected exceptions are caught and logged so that a single failure
|
||||||
|
does not crash the scheduler or affect the other background jobs.
|
||||||
|
"""
|
||||||
|
session_local = get_session_local()
|
||||||
|
session: Session = session_local()
|
||||||
|
try:
|
||||||
|
compute_closed_periods(session)
|
||||||
|
# After billing periods are computed, push fresh energy-cost state values
|
||||||
|
# to MQTT/HA. publish_states is internally guarded by _should_publish
|
||||||
|
# (MQTT disabled / not connected → no-op), so this never raises due to
|
||||||
|
# unconfigured MQTT and does not block the billing job.
|
||||||
|
try:
|
||||||
|
from app.services.ha_discovery import publish_states
|
||||||
|
publish_states(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_run_scheduled_energy_cost: publish_states failed (non-fatal)")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_run_scheduled_energy_cost: unexpected error")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_scheduled_ha_state_publish() -> None:
|
def _run_scheduled_ha_state_publish() -> None:
|
||||||
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
|
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
|
||||||
|
|
||||||
@@ -147,6 +210,28 @@ async def lifespan(_: FastAPI):
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
# Tibber price refresh: fetch today + tomorrow every hour.
|
||||||
|
# The job is a no-op when no active tibber contract or token is configured,
|
||||||
|
# so it is safe to register unconditionally.
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_scheduled_tibber_refresh,
|
||||||
|
trigger=IntervalTrigger(hours=1),
|
||||||
|
id="tibber-refresh",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
# Energy cost billing: compute uncalculated closed 15-minute periods every minute.
|
||||||
|
# The job is a no-op when no active contract or DSMR data is present, so it is
|
||||||
|
# safe to register unconditionally.
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_scheduled_energy_cost,
|
||||||
|
trigger=IntervalTrigger(minutes=1),
|
||||||
|
id="energy-cost",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||||
@@ -161,6 +246,11 @@ async def lifespan(_: FastAPI):
|
|||||||
_startup_session.close()
|
_startup_session.close()
|
||||||
mqtt_manager.connect(_startup_runtime_settings)
|
mqtt_manager.connect(_startup_runtime_settings)
|
||||||
|
|
||||||
|
# DSMR ingest: subscribe to the configured MQTT topic when enabled. The same
|
||||||
|
# applier is called from PUT /api/config, so toggling DSMR via the UI takes
|
||||||
|
# effect without an app restart.
|
||||||
|
apply_dsmr_subscription(_startup_runtime_settings)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# MQTT: clean shutdown before the process exits.
|
# MQTT: clean shutdown before the process exits.
|
||||||
@@ -188,6 +278,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(status.router)
|
app.include_router(status.router)
|
||||||
app.include_router(api_config_router)
|
app.include_router(api_config_router)
|
||||||
app.include_router(api_data_router)
|
app.include_router(api_data_router)
|
||||||
|
app.include_router(api_energy_router)
|
||||||
|
app.include_router(api_energy_contracts_router)
|
||||||
app.include_router(api_expose_router)
|
app.include_router(api_expose_router)
|
||||||
app.include_router(api_modbus_router)
|
app.include_router(api_modbus_router)
|
||||||
app.include_router(api_session_router)
|
app.include_router(api_session_router)
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
|
||||||
|
|
||||||
|
Five tables:
|
||||||
|
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
|
||||||
|
- energy_contract: contract head (manual or tibber, one active at a time).
|
||||||
|
- energy_contract_version: versioned pricing values; append-only for auditability.
|
||||||
|
- tibber_price: cached Tibber 15-minute spot prices (immutable).
|
||||||
|
- energy_cost_period: computed 15-minute billing periods (immutable snapshot).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DsmrReading(Base):
|
||||||
|
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
||||||
|
|
||||||
|
Identity & idempotency are **independent of the DSMR Reader's telegram id**
|
||||||
|
(that field overflows and must be manually reset to zero — a known DSMR
|
||||||
|
quirk — so relying on it for uniqueness risks silently dropping new data).
|
||||||
|
The table's own autoincrement ``id`` PK is the stable internal identity, and
|
||||||
|
``recorded_at`` (the telegram timestamp) is the UNIQUE de-duplication key: a
|
||||||
|
single P1 meter emits exactly one telegram per timestamp.
|
||||||
|
|
||||||
|
``recorded_at`` is a real column (not inside the payload) so time-range
|
||||||
|
queries are efficient. The entire telegram frame is stored verbatim in
|
||||||
|
``payload``; no field allow-list is applied so future commodities (gas,
|
||||||
|
heating, three-phase) are accommodated without a schema change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "dsmr_reading"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# UTC timestamp of the sample — real column, UNIQUE (telegram-id-independent
|
||||||
|
# idempotency key). The unique index also serves time-range queries.
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Telegram's own id (DSMR Reader assigns it). Stored only as a reference /
|
||||||
|
# debugging aid — NOT used for uniqueness or idempotency (it overflows and
|
||||||
|
# gets reset to zero). Nullable because some DSMR sources may not emit one.
|
||||||
|
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Full telegram frame as a JSON object; values are typically JSON strings
|
||||||
|
# (e.g. "20915.154") — callers must cast to Decimal before arithmetic.
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class EnergyContract(Base):
|
||||||
|
"""Contract head: a named energy contract with a chosen pricing strategy.
|
||||||
|
|
||||||
|
``kind`` determines which price strategy is used (``manual`` for fixed
|
||||||
|
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
|
||||||
|
Only one contract may be ``active`` at a time; the service layer enforces
|
||||||
|
mutual exclusion. Specific pricing values live in ``EnergyContractVersion``
|
||||||
|
so that price changes can be tracked without modifying historical records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "energy_contract"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Human-readable label; freely editable by the user.
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Strategy selector: "manual" or "tibber". Application-layer validation
|
||||||
|
# enforces the allowed set; no DB CHECK constraint is added to keep the
|
||||||
|
# migration simple and the strategy registry extensible.
|
||||||
|
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
# Whether this is the currently active contract (at most one should be True).
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# ISO 4217 currency code for all monetary values in this contract.
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="EUR")
|
||||||
|
|
||||||
|
# Audit timestamps.
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# Relationship to versions (back-reference; not loaded eagerly).
|
||||||
|
versions: Mapped[list["EnergyContractVersion"]] = relationship(
|
||||||
|
back_populates="contract", cascade="save-update, merge"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnergyContractVersion(Base):
|
||||||
|
"""One time-bounded version of an energy contract's pricing values.
|
||||||
|
|
||||||
|
Pricing changes are modelled as new versions (append-only); existing versions
|
||||||
|
are never modified so that historical ``EnergyCostPeriod`` records remain
|
||||||
|
fully auditable. ``effective_to`` is ``NULL`` for the currently open version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "energy_contract_version"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# FK to the parent contract. RESTRICT prevents deletion of a contract that
|
||||||
|
# still has versioned pricing rows attached to it.
|
||||||
|
contract_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("energy_contract.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start of this version's validity window (inclusive, UTC).
|
||||||
|
effective_from: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# End of this version's validity window (exclusive, UTC). NULL means open-ended
|
||||||
|
# (i.e. this is the most recent / current version).
|
||||||
|
effective_to: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pricing values as a JSON object conforming to the profile structure for
|
||||||
|
# ``contract.kind`` (validated by the application layer against the YAML profile).
|
||||||
|
values: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
# Creation timestamp.
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# Relationship back to the parent contract.
|
||||||
|
contract: Mapped["EnergyContract"] = relationship(back_populates="versions")
|
||||||
|
|
||||||
|
# Relationship to cost periods that reference this version.
|
||||||
|
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
|
||||||
|
back_populates="contract_version", cascade="save-update, merge"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TibberPrice(Base):
|
||||||
|
"""Cached Tibber 15-minute spot price point (immutable once fetched).
|
||||||
|
|
||||||
|
``starts_at`` is unique so that upserts are idempotent. Past prices are
|
||||||
|
never overwritten; the fetch job only adds rows for future time slots.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tibber_price"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# UTC start of the 15-minute slot; unique so upsert is idempotent.
|
||||||
|
starts_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolution label as returned by the Tibber API (e.g. "QUARTER_HOURLY").
|
||||||
|
resolution: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
# Price components in the contract currency (all include VAT, user-facing).
|
||||||
|
energy: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
tax: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
total: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
# Tibber price level (e.g. "NORMAL", "CHEAP", "EXPENSIVE"); may be absent.
|
||||||
|
level: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|
||||||
|
# ISO 4217 currency code as returned by the API.
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
|
||||||
|
# UTC timestamp of when this row was fetched/inserted.
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class EnergyCostPeriod(Base):
|
||||||
|
"""Computed billing record for one 15-minute metering period (immutable snapshot).
|
||||||
|
|
||||||
|
Each row captures the per-register kWh deltas, the resulting import cost and
|
||||||
|
export revenue, and a full snapshot of the pricing values used so that the
|
||||||
|
calculation is fully auditable and reproducible without re-querying the
|
||||||
|
contract version. Rows are written once and never modified; explicit
|
||||||
|
recomputation via the API is the only way to overwrite a period.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "energy_cost_period"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# UTC start of the 15-minute period; unique so upsert is idempotent.
|
||||||
|
period_start: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-register kWh deltas for the period (end minus start of cumulative registers).
|
||||||
|
# _1 = dal/low-tariff, _2 = normal/high-tariff (NL convention).
|
||||||
|
d1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered low
|
||||||
|
d2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered high
|
||||||
|
r1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned low
|
||||||
|
r2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned high
|
||||||
|
|
||||||
|
# Computed monetary amounts for the period (in ``currency``).
|
||||||
|
import_cost: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
export_revenue: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
net_cost: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
# ISO 4217 currency code matching the contract.
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
|
||||||
|
# Full snapshot of the pricing inputs used during computation. This makes
|
||||||
|
# each row self-contained and auditable even if the contract is later changed.
|
||||||
|
pricing: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
# FK to the exact contract version whose values were used. RESTRICT prevents
|
||||||
|
# deletion of a version that has cost records attached. Nullable to support
|
||||||
|
# periods computed in ``degraded`` mode (missing price data).
|
||||||
|
contract_version_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# True when the period was computed with incomplete data (missing readings or
|
||||||
|
# missing price); serves as a flag for later recomputation.
|
||||||
|
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# UTC timestamp of when this row was computed/inserted.
|
||||||
|
computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# Relationship back to the contract version.
|
||||||
|
contract_version: Mapped["EnergyContractVersion | None"] = relationship(
|
||||||
|
back_populates="cost_periods"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
||||||
|
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
||||||
|
# no composite index is needed for single-meter deployments.)
|
||||||
|
|
||||||
|
# Index on period_start is covered by the unique constraint (SQLite creates an
|
||||||
|
# implicit index for UNIQUE columns), so no additional index is required.
|
||||||
|
|
||||||
|
# Index on starts_at for TibberPrice is covered by the unique constraint similarly.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Pydantic schemas for the EnergyContract CRUD + versioning API (M6-T04).
|
||||||
|
|
||||||
|
Schema hierarchy
|
||||||
|
----------------
|
||||||
|
ContractVersionResponse — single version row (id, dates, values, created_at)
|
||||||
|
ContractResponse — contract head (id, name, kind, active, currency, timestamps)
|
||||||
|
ContractDetailResponse — contract head + embedded versions list (for GET{id})
|
||||||
|
ContractListResponse — paginated list of ContractResponse items
|
||||||
|
ContractCreate — POST /api/energy/contracts body
|
||||||
|
ContractPatch — PATCH /api/energy/contracts/{id} body (all fields optional)
|
||||||
|
VersionCreate — POST /api/energy/contracts/{id}/versions body
|
||||||
|
ProfilesResponse — GET /api/energy/profiles response
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Version schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ContractVersionResponse(BaseModel):
|
||||||
|
"""Response schema for a single EnergyContractVersion row."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
effective_from: datetime
|
||||||
|
effective_to: datetime | None
|
||||||
|
values: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contract schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ContractResponse(BaseModel):
|
||||||
|
"""Response schema for a single EnergyContract (without embedded versions).
|
||||||
|
|
||||||
|
Used for list responses where embedding all versions would be expensive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
active: bool
|
||||||
|
currency: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDetailResponse(BaseModel):
|
||||||
|
"""Response schema for a single EnergyContract with full version history.
|
||||||
|
|
||||||
|
Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH
|
||||||
|
operations where the caller needs to see all version data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
active: bool
|
||||||
|
currency: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
versions: list[ContractVersionResponse]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ContractListResponse(BaseModel):
|
||||||
|
"""Response schema for GET /api/energy/contracts."""
|
||||||
|
|
||||||
|
items: list[ContractResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ContractCreate(BaseModel):
|
||||||
|
"""Request body for POST /api/energy/contracts.
|
||||||
|
|
||||||
|
``effective_from`` defaults to the current UTC time if not provided,
|
||||||
|
giving the first version an open-ended start from "now".
|
||||||
|
``kind`` is validated at the application layer against the profile registry;
|
||||||
|
clients should send ``"manual"`` or ``"tibber"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
kind: str = Field(..., min_length=1, max_length=32)
|
||||||
|
currency: str = Field(default="EUR", min_length=1, max_length=8)
|
||||||
|
values: dict[str, Any]
|
||||||
|
effective_from: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"UTC datetime from which the first pricing version is effective. "
|
||||||
|
"Defaults to the current UTC time when omitted."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractPatch(BaseModel):
|
||||||
|
"""Request body for PATCH /api/energy/contracts/{id}.
|
||||||
|
|
||||||
|
All fields are optional. Sending ``active=true`` activates this contract
|
||||||
|
(deactivating all others); ``active=false`` deactivates it without affecting
|
||||||
|
other contracts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VersionCreate(BaseModel):
|
||||||
|
"""Request body for POST /api/energy/contracts/{id}/versions."""
|
||||||
|
|
||||||
|
effective_from: datetime
|
||||||
|
values: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Profile response schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProfilesResponse(BaseModel):
|
||||||
|
"""Response schema for GET /api/energy/profiles.
|
||||||
|
|
||||||
|
``profiles`` is a list of raw profile dicts as produced by
|
||||||
|
``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum
|
||||||
|
``kind`` and ``label``; the full nested structure allows the front-end to
|
||||||
|
render a type-appropriate form for each pricing profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
profiles: list[dict[str, Any]]
|
||||||
@@ -152,3 +152,20 @@ class ModbusTestReadResponse(BaseModel):
|
|||||||
ok: bool
|
ok: bool
|
||||||
payload: dict[str, Any] | None = None
|
payload: dict[str, Any] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cascade-delete schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDeleteResponse(BaseModel):
|
||||||
|
"""Response for DELETE /api/modbus/devices/{uuid}?cascade=true.
|
||||||
|
|
||||||
|
Returned only when cascade deletion succeeds (HTTP 200). Non-cascade
|
||||||
|
successful deletes continue to return HTTP 204 (no body).
|
||||||
|
"""
|
||||||
|
|
||||||
|
deleted: bool
|
||||||
|
readings_deleted: int
|
||||||
|
toggles_deleted: int
|
||||||
|
|||||||
@@ -122,7 +122,42 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
"ha_discovery_prefix",
|
"ha_discovery_prefix",
|
||||||
"HA Discovery Prefix",
|
"HA Discovery Prefix",
|
||||||
),
|
),
|
||||||
|
ConfigField(
|
||||||
|
"Home Assistant Discovery",
|
||||||
|
"HA_STATE_TOPIC_PREFIX",
|
||||||
|
"ha_state_topic_prefix",
|
||||||
|
"HA State Topic Prefix",
|
||||||
|
),
|
||||||
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
|
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
|
||||||
|
ConfigField(
|
||||||
|
"DSMR",
|
||||||
|
"DSMR_INGEST_ENABLED",
|
||||||
|
"dsmr_ingest_enabled",
|
||||||
|
"DSMR Ingest Enabled",
|
||||||
|
input_type="checkbox",
|
||||||
|
),
|
||||||
|
ConfigField("DSMR", "DSMR_MQTT_TOPIC", "dsmr_mqtt_topic", "DSMR MQTT Topic"),
|
||||||
|
ConfigField(
|
||||||
|
"DSMR",
|
||||||
|
"DSMR_SAMPLE_INTERVAL_S",
|
||||||
|
"dsmr_sample_interval_s",
|
||||||
|
"DSMR Sample Interval (s)",
|
||||||
|
input_type="number",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"DSMR",
|
||||||
|
"DSMR_TARIFF_TOPIC",
|
||||||
|
"dsmr_tariff_topic",
|
||||||
|
"DSMR Tariff Topic",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"Tibber",
|
||||||
|
"TIBBER_API_TOKEN",
|
||||||
|
"tibber_api_token",
|
||||||
|
"Tibber API Token",
|
||||||
|
secret=True,
|
||||||
|
),
|
||||||
|
ConfigField("Tibber", "TIBBER_HOME_ID", "tibber_home_id", "Tibber Home ID"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -322,4 +357,11 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
|||||||
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
||||||
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
||||||
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
||||||
|
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
|
||||||
|
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
|
||||||
|
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
|
||||||
|
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
|
||||||
|
"dsmr_tariff_topic": settings.dsmr_tariff_topic,
|
||||||
|
"tibber_api_token": settings.tibber_api_token,
|
||||||
|
"tibber_home_id": settings.tibber_home_id,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Service layer for EnergyContract CRUD, versioning, and activation.
|
||||||
|
|
||||||
|
All functions accept an explicit SQLAlchemy Session; callers are responsible
|
||||||
|
for committing or rolling back the transaction.
|
||||||
|
|
||||||
|
Design decisions
|
||||||
|
----------------
|
||||||
|
- ``create_contract``: validates values against the pricing profile *before*
|
||||||
|
writing any rows; raises ``ProfileValidationError`` on non-compliance.
|
||||||
|
- ``add_version``: append-only; closes the previous open version by
|
||||||
|
setting its ``effective_to`` to the new version's ``effective_from``; raises
|
||||||
|
``ContractVersionError`` if the new date is strictly earlier than the previous
|
||||||
|
version's ``effective_from``.
|
||||||
|
- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active``
|
||||||
|
to False, then sets the given contract's ``active`` to True.
|
||||||
|
- ``active_contract_version_at``: returns the single version of the currently
|
||||||
|
active contract that covers *ts* (``effective_from ≤ ts < effective_to``,
|
||||||
|
or open-ended when ``effective_to`` is None).
|
||||||
|
|
||||||
|
SQLite timezone note
|
||||||
|
--------------------
|
||||||
|
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
|
||||||
|
read-back they come out as **timezone-naive** datetimes. Wherever this code
|
||||||
|
compares timestamps from the DB against timezone-aware values (e.g. from
|
||||||
|
Pydantic or ``datetime.now(UTC)``), it calls ``_as_utc()`` to make both sides
|
||||||
|
comparable without tripping on "offset-naive vs offset-aware" TypeErrors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.integrations.pricing.profiles import validate_values
|
||||||
|
from app.models.energy import EnergyContract, EnergyContractVersion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(dt: datetime) -> datetime:
|
||||||
|
"""Return *dt* as a timezone-aware UTC datetime.
|
||||||
|
|
||||||
|
SQLite's DateTime(timezone=True) column type stores datetimes as naive UTC
|
||||||
|
strings and gives them back as naive datetimes on read. This helper
|
||||||
|
re-attaches the UTC timezone info when it is missing, making cross-origin
|
||||||
|
comparisons safe.
|
||||||
|
"""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=UTC)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom exception
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ContractVersionError(ValueError):
|
||||||
|
"""Raised when a new contract version has an invalid effective date.
|
||||||
|
|
||||||
|
Specifically: the new version's ``effective_from`` must be greater than or
|
||||||
|
equal to the previous open version's ``effective_from``. Allowing equal
|
||||||
|
timestamps would cause ambiguous overlap; the service therefore also rejects
|
||||||
|
strictly-equal values (same second) to avoid silent data loss.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract | None:
|
||||||
|
"""Return the contract with the given id, or None if not found."""
|
||||||
|
return session.execute(
|
||||||
|
select(EnergyContract).where(EnergyContract.id == contract_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_contracts(session: Session) -> list[EnergyContract]:
|
||||||
|
"""Return all contracts ordered by id (ascending)."""
|
||||||
|
return list(
|
||||||
|
session.execute(select(EnergyContract).order_by(EnergyContract.id)).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_version(session: Session, contract: EnergyContract) -> EnergyContractVersion | None:
|
||||||
|
"""Return the current open version (effective_to IS NULL) for *contract*, or None."""
|
||||||
|
return session.execute(
|
||||||
|
select(EnergyContractVersion)
|
||||||
|
.where(
|
||||||
|
EnergyContractVersion.contract_id == contract.id,
|
||||||
|
EnergyContractVersion.effective_to.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(EnergyContractVersion.effective_from.desc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core service functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_contract(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
kind: str,
|
||||||
|
currency: str = "EUR",
|
||||||
|
values: dict[str, Any],
|
||||||
|
effective_from: datetime,
|
||||||
|
) -> EnergyContract:
|
||||||
|
"""Create a new energy contract with its first pricing version.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session. Caller must commit after this returns.
|
||||||
|
name:
|
||||||
|
Human-readable label for the contract.
|
||||||
|
kind:
|
||||||
|
Pricing strategy identifier (``"manual"`` or ``"tibber"``).
|
||||||
|
currency:
|
||||||
|
ISO 4217 currency code (default ``"EUR"``).
|
||||||
|
values:
|
||||||
|
Pricing values dict conforming to the named profile's structure.
|
||||||
|
Validated via ``validate_values(kind, values)`` before any writes.
|
||||||
|
effective_from:
|
||||||
|
UTC datetime at which the first pricing version takes effect.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
EnergyContract
|
||||||
|
The newly created contract (not yet committed).
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ProfileNotFoundError
|
||||||
|
If no YAML profile exists for *kind*.
|
||||||
|
ProfileValidationError
|
||||||
|
If *values* does not conform to the profile structure.
|
||||||
|
"""
|
||||||
|
# Validate (and fill defaults) before any DB write.
|
||||||
|
filled_values = validate_values(kind, values)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
contract = EnergyContract(
|
||||||
|
name=name,
|
||||||
|
kind=kind,
|
||||||
|
currency=currency,
|
||||||
|
active=False, # New contracts are inactive; caller must explicitly activate.
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush() # Assign contract.id so we can reference it in the version FK.
|
||||||
|
|
||||||
|
version = EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=effective_from,
|
||||||
|
effective_to=None,
|
||||||
|
values=filled_values,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
|
||||||
|
logger.info("Created energy contract %r (kind=%s, id=%d)", name, kind, contract.id)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def add_version(
|
||||||
|
session: Session,
|
||||||
|
contract: EnergyContract,
|
||||||
|
*,
|
||||||
|
effective_from: datetime,
|
||||||
|
values: dict[str, Any],
|
||||||
|
) -> EnergyContractVersion:
|
||||||
|
"""Add a new pricing version to an existing contract (append-only).
|
||||||
|
|
||||||
|
The previous open version's ``effective_to`` is automatically set to the
|
||||||
|
new version's ``effective_from`` (version closure), ensuring there is never
|
||||||
|
a gap or overlap between consecutive versions.
|
||||||
|
|
||||||
|
The new ``effective_from`` **must be strictly greater than** the previous
|
||||||
|
open version's ``effective_from``. Equal timestamps are rejected because
|
||||||
|
they would produce two versions starting at the same instant, making it
|
||||||
|
impossible to determine which is current. If this constraint is not met,
|
||||||
|
``ContractVersionError`` is raised and **no rows are written**.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session. Caller must commit after this returns.
|
||||||
|
contract:
|
||||||
|
The parent ``EnergyContract`` to append a version to.
|
||||||
|
effective_from:
|
||||||
|
UTC datetime at which this version's pricing takes effect.
|
||||||
|
values:
|
||||||
|
New pricing values dict conforming to the contract's profile.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
EnergyContractVersion
|
||||||
|
The newly created version (not yet committed).
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ContractVersionError
|
||||||
|
If *effective_from* is not strictly after the previous open version's
|
||||||
|
``effective_from``.
|
||||||
|
ProfileNotFoundError
|
||||||
|
If no YAML profile exists for the contract's kind.
|
||||||
|
ProfileValidationError
|
||||||
|
If *values* does not conform to the profile structure.
|
||||||
|
"""
|
||||||
|
# Validate (and fill defaults) before any DB write.
|
||||||
|
filled_values = validate_values(contract.kind, values)
|
||||||
|
|
||||||
|
prev = _open_version(session, contract)
|
||||||
|
if prev is not None:
|
||||||
|
# Enforce strictly-after constraint to prevent ambiguous overlap.
|
||||||
|
# Use _as_utc() on both sides: the incoming value may be tz-aware
|
||||||
|
# while the DB-read value is tz-naive (SQLite limitation).
|
||||||
|
if _as_utc(effective_from) <= _as_utc(prev.effective_from):
|
||||||
|
raise ContractVersionError(
|
||||||
|
f"New version effective_from ({effective_from.isoformat()}) must be strictly "
|
||||||
|
f"after the previous open version's effective_from "
|
||||||
|
f"({prev.effective_from.isoformat()})."
|
||||||
|
)
|
||||||
|
# Close the previous open version.
|
||||||
|
prev.effective_to = effective_from
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
new_version = EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=effective_from,
|
||||||
|
effective_to=None,
|
||||||
|
values=filled_values,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(new_version)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Added version to contract id=%d (kind=%s, effective_from=%s)",
|
||||||
|
contract.id,
|
||||||
|
contract.kind,
|
||||||
|
effective_from.isoformat(),
|
||||||
|
)
|
||||||
|
return new_version
|
||||||
|
|
||||||
|
|
||||||
|
def activate_contract(session: Session, contract: EnergyContract) -> None:
|
||||||
|
"""Activate a contract with mutual exclusion.
|
||||||
|
|
||||||
|
Sets every other contract's ``active`` flag to False, then sets the given
|
||||||
|
contract's ``active`` to True. This guarantees at most one active contract
|
||||||
|
at any time.
|
||||||
|
|
||||||
|
Caller must commit after this returns.
|
||||||
|
"""
|
||||||
|
# Deactivate all contracts (including the target; we re-activate below).
|
||||||
|
for other in session.execute(select(EnergyContract)).scalars().all():
|
||||||
|
other.active = False
|
||||||
|
contract.active = True
|
||||||
|
contract.updated_at = datetime.now(UTC)
|
||||||
|
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_contract(session: Session, contract: EnergyContract) -> None:
|
||||||
|
"""Deactivate a contract without touching other contracts.
|
||||||
|
|
||||||
|
Caller must commit after this returns.
|
||||||
|
"""
|
||||||
|
contract.active = False
|
||||||
|
contract.updated_at = datetime.now(UTC)
|
||||||
|
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
||||||
|
|
||||||
|
|
||||||
|
def active_contract_version_at(
|
||||||
|
session: Session, ts: datetime
|
||||||
|
) -> EnergyContractVersion | None:
|
||||||
|
"""Return the active contract's version that covers *ts*.
|
||||||
|
|
||||||
|
A version covers *ts* when:
|
||||||
|
``effective_from ≤ ts`` AND (``effective_to IS NULL`` OR ``ts < effective_to``)
|
||||||
|
|
||||||
|
Returns None when:
|
||||||
|
- There is no active contract.
|
||||||
|
- The active contract has no version covering *ts*.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session (read-only usage).
|
||||||
|
ts:
|
||||||
|
UTC datetime to look up.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
EnergyContractVersion | None
|
||||||
|
"""
|
||||||
|
active = session.execute(
|
||||||
|
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if active is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build a query for all versions of the active contract covering ts.
|
||||||
|
stmt = (
|
||||||
|
select(EnergyContractVersion)
|
||||||
|
.where(
|
||||||
|
EnergyContractVersion.contract_id == active.id,
|
||||||
|
EnergyContractVersion.effective_from <= ts,
|
||||||
|
)
|
||||||
|
.order_by(EnergyContractVersion.effective_from.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
version = session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if version is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Exclude versions whose effective window has already closed before ts.
|
||||||
|
if version.effective_to is not None and _as_utc(ts) >= _as_utc(version.effective_to):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return version
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""DSMR telegram ingest service.
|
||||||
|
|
||||||
|
Subscribes to the DSMR Reader MQTT topic (``dsmr/json``) and persists
|
||||||
|
down-sampled DSMR telegram frames to the ``dsmr_reading`` table.
|
||||||
|
|
||||||
|
Design decisions
|
||||||
|
----------------
|
||||||
|
- **Whole-frame storage**: the entire parsed telegram dict is stored as a JSON
|
||||||
|
blob in ``DsmrReading.payload``; no field allow-list is applied. This lets
|
||||||
|
future commodities (gas, heating, three-phase) be accommodated without a
|
||||||
|
table-schema change.
|
||||||
|
- **10-second down-sampling** (configurable via ``dsmr_sample_interval_s``):
|
||||||
|
only telegrams whose ``timestamp`` second falls on an exact multiple of the
|
||||||
|
interval are persisted. This reduces write volume from ~60 rows/min to ~6
|
||||||
|
rows/min while guaranteeing that every 15-minute boundary (second=00) is
|
||||||
|
captured.
|
||||||
|
- **Idempotency**: the telegram's own ``id`` field is stored as ``source_id``
|
||||||
|
with a UNIQUE constraint. A second delivery of the same telegram (e.g. after
|
||||||
|
a broker reconnect) is silently skipped.
|
||||||
|
- **Network-thread safety**: ``handle_message`` is called from paho's background
|
||||||
|
loop thread. It opens and closes its own short-lived SQLAlchemy session and
|
||||||
|
swallows all exceptions so that a buggy payload or transient DB error never
|
||||||
|
crashes the paho loop or drops the MQTT connection.
|
||||||
|
- **Numeric values kept as strings**: the DSMR Reader emits all numeric readings
|
||||||
|
as JSON strings (e.g. ``"20915.154"``). They are stored verbatim; conversion
|
||||||
|
to ``Decimal`` is deferred to the billing engine (T07) where precision matters.
|
||||||
|
- **Null phases**: some telegrams omit certain phase readings (``null`` in JSON);
|
||||||
|
these are stored as-is without special handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import get_session_local
|
||||||
|
from app.models.energy import DsmrReading
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Tracks the DSMR topic currently subscribed via the MQTT manager, so a config
|
||||||
|
# change can unsubscribe the old topic before subscribing the new one.
|
||||||
|
_current_dsmr_topic: str | None = None
|
||||||
|
|
||||||
|
# Tracks the DSMR tariff topic currently subscribed via the MQTT manager.
|
||||||
|
_current_tariff_topic: str | None = None
|
||||||
|
|
||||||
|
# Current electricity tariff: 1 = dal/off-peak, 2 = normal/peak.
|
||||||
|
# Written by the paho network thread, read by the publish job — guarded by a lock.
|
||||||
|
_current_tariff: int | None = None
|
||||||
|
_tariff_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tariff() -> int | None:
|
||||||
|
"""Return the most recently received electricity tariff (1 or 2), or None."""
|
||||||
|
with _tariff_lock:
|
||||||
|
return _current_tariff
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_tariff(value: int | None) -> None:
|
||||||
|
"""Set the current electricity tariff (1 or 2), or clear it with None."""
|
||||||
|
with _tariff_lock:
|
||||||
|
global _current_tariff
|
||||||
|
_current_tariff = value
|
||||||
|
|
||||||
|
|
||||||
|
def handle_tariff_message(payload_bytes: bytes) -> None:
|
||||||
|
"""Parse one DSMR tariff MQTT payload and update the in-memory tariff state.
|
||||||
|
|
||||||
|
Called from the paho network thread; *must* swallow all exceptions so that
|
||||||
|
a bad payload never crashes the loop or drops the broker connection.
|
||||||
|
|
||||||
|
Accepts payload as bytes or str (paho can deliver either). Ignores
|
||||||
|
whitespace. Only accepts integer values 1 or 2; anything else is discarded
|
||||||
|
and the previous known tariff is preserved.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
payload_bytes:
|
||||||
|
Raw bytes from the MQTT message (may also be a str in some paho versions).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Decode bytes → str if needed; strip surrounding whitespace.
|
||||||
|
if isinstance(payload_bytes, (bytes, bytearray)):
|
||||||
|
raw = payload_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
else:
|
||||||
|
raw = str(payload_bytes).strip()
|
||||||
|
|
||||||
|
value = int(raw)
|
||||||
|
if value not in (1, 2):
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest.handle_tariff_message: unexpected tariff value %d (expected 1 or 2, ignored).",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
set_current_tariff(value)
|
||||||
|
logger.debug("dsmr_ingest.handle_tariff_message: tariff updated to %d.", value)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Malformed payload (e.g. non-numeric); swallow silently to protect network thread.
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest.handle_tariff_message: could not parse payload %r (ignored).",
|
||||||
|
payload_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_dsmr_subscription(settings: "Settings") -> None:
|
||||||
|
"""(Re)apply the DSMR MQTT subscriptions to match *settings* — restart-free.
|
||||||
|
|
||||||
|
Call this at startup and after every config save. It makes the live MQTT
|
||||||
|
subscriptions reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic``
|
||||||
|
/ ``dsmr_sample_interval_s`` / ``dsmr_tariff_topic`` settings without an app
|
||||||
|
restart:
|
||||||
|
|
||||||
|
- **Disabled** → unsubscribe any active DSMR and tariff subscriptions.
|
||||||
|
- **Enabled** → (re)subscribe to ``dsmr_mqtt_topic`` with a handler bound to
|
||||||
|
a *fresh* settings snapshot, so a changed sample interval also takes effect.
|
||||||
|
Also subscribe to ``dsmr_tariff_topic`` when non-empty.
|
||||||
|
- **Topic changed** → unsubscribe the old topic before subscribing the new one.
|
||||||
|
|
||||||
|
Idempotent and safe to call when MQTT is not connected (the subscription is
|
||||||
|
queued in the manager and established on the next connect).
|
||||||
|
"""
|
||||||
|
# Imported here (not at module top) to avoid a circular import at app start.
|
||||||
|
from app.integrations.mqtt import mqtt_manager
|
||||||
|
|
||||||
|
global _current_dsmr_topic, _current_tariff_topic
|
||||||
|
|
||||||
|
if not settings.dsmr_ingest_enabled:
|
||||||
|
if _current_dsmr_topic is not None:
|
||||||
|
mqtt_manager.unsubscribe(_current_dsmr_topic)
|
||||||
|
logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic)
|
||||||
|
_current_dsmr_topic = None
|
||||||
|
if _current_tariff_topic is not None:
|
||||||
|
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||||
|
logger.info(
|
||||||
|
"DSMR ingest disabled — unsubscribed from tariff topic=%s.",
|
||||||
|
_current_tariff_topic,
|
||||||
|
)
|
||||||
|
_current_tariff_topic = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Main DSMR telegram topic ---
|
||||||
|
topic = settings.dsmr_mqtt_topic
|
||||||
|
if _current_dsmr_topic is not None and _current_dsmr_topic != topic:
|
||||||
|
mqtt_manager.unsubscribe(_current_dsmr_topic)
|
||||||
|
|
||||||
|
# Re-subscribe (overwrites any existing handler for this topic) with a fresh
|
||||||
|
# settings snapshot so dsmr_sample_interval_s changes take effect too.
|
||||||
|
snapshot = settings
|
||||||
|
mqtt_manager.subscribe(topic, lambda payload: handle_message(payload, snapshot))
|
||||||
|
_current_dsmr_topic = topic
|
||||||
|
logger.info("DSMR ingest enabled — subscribed to topic=%s.", topic)
|
||||||
|
|
||||||
|
# --- DSMR tariff topic (dual-tariff slot indicator) ---
|
||||||
|
tariff_topic = settings.dsmr_tariff_topic if settings.dsmr_tariff_topic else ""
|
||||||
|
if tariff_topic:
|
||||||
|
if _current_tariff_topic is not None and _current_tariff_topic != tariff_topic:
|
||||||
|
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||||
|
mqtt_manager.subscribe(tariff_topic, lambda payload: handle_tariff_message(payload))
|
||||||
|
_current_tariff_topic = tariff_topic
|
||||||
|
logger.info("DSMR tariff topic — subscribed to topic=%s.", tariff_topic)
|
||||||
|
else:
|
||||||
|
# tariff_topic is empty → unsubscribe any existing tariff subscription.
|
||||||
|
if _current_tariff_topic is not None:
|
||||||
|
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||||
|
logger.info(
|
||||||
|
"DSMR tariff topic cleared — unsubscribed from topic=%s.",
|
||||||
|
_current_tariff_topic,
|
||||||
|
)
|
||||||
|
_current_tariff_topic = None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_message(payload_bytes: bytes, settings: "Settings") -> None:
|
||||||
|
"""Parse one DSMR MQTT payload and persist it if it passes the sample filter.
|
||||||
|
|
||||||
|
Called from the paho network thread; *must* swallow all exceptions so that
|
||||||
|
a bad payload or transient error does not crash the loop or drop the broker
|
||||||
|
connection.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
payload_bytes:
|
||||||
|
Raw bytes from the MQTT message.
|
||||||
|
settings:
|
||||||
|
Runtime settings snapshot (captured at subscription time). Used for
|
||||||
|
``dsmr_sample_interval_s``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_handle_message_inner(payload_bytes, settings)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("dsmr_ingest.handle_message: unexpected error (swallowed).")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_message_inner(payload_bytes: bytes, settings: "Settings") -> None:
|
||||||
|
"""Inner implementation — may raise; caller wraps in try/except."""
|
||||||
|
# --- 1. Parse JSON ---
|
||||||
|
try:
|
||||||
|
data: dict = json.loads(payload_bytes)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
logger.debug("dsmr_ingest: invalid JSON payload (skipped).")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.debug("dsmr_ingest: payload is not a JSON object (skipped).")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- 2. Parse timestamp ---
|
||||||
|
raw_ts = data.get("timestamp")
|
||||||
|
if raw_ts is None:
|
||||||
|
logger.debug("dsmr_ingest: missing 'timestamp' field (skipped).")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python 3.11+ accepts the trailing 'Z' directly; for 3.10 compat we
|
||||||
|
# replace 'Z' with '+00:00' before parsing.
|
||||||
|
ts_str = raw_ts if not isinstance(raw_ts, str) else raw_ts.replace("Z", "+00:00")
|
||||||
|
ts_utc: datetime = datetime.fromisoformat(ts_str)
|
||||||
|
# Ensure it is timezone-aware UTC.
|
||||||
|
if ts_utc.tzinfo is None:
|
||||||
|
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest: cannot parse 'timestamp' value %r (skipped).", raw_ts
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- 3. Down-sample: only persist if second falls on interval boundary ---
|
||||||
|
interval = settings.dsmr_sample_interval_s
|
||||||
|
if interval > 0 and (ts_utc.second % interval) != 0:
|
||||||
|
# This telegram is between sample points; discard silently.
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- 4. Extract source_id (telegram's own id) — stored only as a reference,
|
||||||
|
# NOT used for uniqueness/idempotency (it overflows and gets reset). ---
|
||||||
|
source_id: int | None = data.get("id")
|
||||||
|
if source_id is not None and not isinstance(source_id, int):
|
||||||
|
# Unexpected type — treat as missing rather than raising.
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest: 'id' field has unexpected type %s (ignoring).",
|
||||||
|
type(source_id).__name__,
|
||||||
|
)
|
||||||
|
source_id = None
|
||||||
|
|
||||||
|
# --- 5. Persist to database ---
|
||||||
|
# Idempotency is keyed on recorded_at (the telegram timestamp), which is
|
||||||
|
# telegram-id-independent: a single P1 meter emits one telegram per second,
|
||||||
|
# and down-sampling keeps at most one per interval-aligned second. The
|
||||||
|
# UNIQUE(recorded_at) constraint is the backstop for the IntegrityError race.
|
||||||
|
session_local = get_session_local()
|
||||||
|
session = session_local()
|
||||||
|
try:
|
||||||
|
existing = session.scalar(
|
||||||
|
select(DsmrReading).where(DsmrReading.recorded_at == ts_utc)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest: recorded_at=%s already in DB, skipping.",
|
||||||
|
ts_utc.isoformat(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
reading = DsmrReading(
|
||||||
|
recorded_at=ts_utc,
|
||||||
|
source_id=source_id,
|
||||||
|
payload=data, # full frame, verbatim
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest: persisted reading recorded_at=%s source_id=%s.",
|
||||||
|
ts_utc.isoformat(),
|
||||||
|
source_id,
|
||||||
|
)
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
# Race / duplicate: another insert beat us to the same recorded_at.
|
||||||
|
session.rollback()
|
||||||
|
logger.debug(
|
||||||
|
"dsmr_ingest: IntegrityError for recorded_at=%s (duplicate, skipped).",
|
||||||
|
ts_utc.isoformat(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
logger.exception("dsmr_ingest: DB error (swallowed).")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
"""Billing engine for DSMR 15-minute energy metering periods.
|
||||||
|
|
||||||
|
This module implements the two-layer billing model described in §3.4 of the
|
||||||
|
M6 design document:
|
||||||
|
|
||||||
|
**Layer 1 — per-period metering cost (immutable, price-snapshot)**
|
||||||
|
``compute_period(session, t0)`` computes the import cost, export revenue, and
|
||||||
|
net cost for the 15-minute period ``[t0, t0+15min)``. The result is written
|
||||||
|
to ``energy_cost_period`` with a full pricing snapshot so each row is
|
||||||
|
self-contained and auditable. Existing *successful* rows are never overwritten
|
||||||
|
by the normal tick path; only an explicit ``recompute_range`` call passes
|
||||||
|
``overwrite=True``.
|
||||||
|
|
||||||
|
**Layer 2 — summary (computed at read time, not stored)**
|
||||||
|
``summarize(session, start, end)`` aggregates all non-degraded
|
||||||
|
``energy_cost_period`` rows in ``[start, end)``, then adds the daily
|
||||||
|
standing charges (network_fee + management_fee, apportioned at EUR/month
|
||||||
|
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
|
||||||
|
apportioned at EUR/year ÷ 365 per day).
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **Decimal arithmetic throughout**: all monetary computations use
|
||||||
|
``decimal.Decimal`` to avoid float binary rounding errors. Only when
|
||||||
|
writing to ``EnergyCostPeriod`` columns (Float) are values converted to
|
||||||
|
float. ``summarize`` converts back to Decimal for summation.
|
||||||
|
- **UTC quarter-hour grid**: period boundaries are aligned to UTC 00/15/30/45
|
||||||
|
minutes (``floor_to_quarter``). NL local time (CET/CEST) is always a whole
|
||||||
|
number of hours from UTC, so the quarter-hour grid is the same in both
|
||||||
|
timezone representations.
|
||||||
|
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
|
||||||
|
for cumulative kWh registers. ``register_at`` converts them to Decimal.
|
||||||
|
- **Degraded vs skip semantics**:
|
||||||
|
- *Missing readings* (``register_at`` returns None for start or end
|
||||||
|
boundary): write a ``degraded=True`` row with costs at 0 so the period is
|
||||||
|
tracked and can be retried by ``compute_closed_periods``.
|
||||||
|
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
|
||||||
|
not write a row); the period will be retried once prices arrive.
|
||||||
|
- *Missing active contract version*: skip (no contract to compute against).
|
||||||
|
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all
|
||||||
|
historical DSMR data on every tick, the function looks back at most 7 days
|
||||||
|
from the current time. This covers typical short outages (no data / no
|
||||||
|
contract) while staying bounded. Periods older than 7 days must be
|
||||||
|
recovered via an explicit ``recompute_range`` call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.integrations.pricing.strategies import (
|
||||||
|
PeriodDeltas,
|
||||||
|
TibberPriceNotFoundError,
|
||||||
|
get_strategy,
|
||||||
|
)
|
||||||
|
from app.models.energy import DsmrReading, EnergyCostPeriod
|
||||||
|
from app.services.contracts import active_contract_version_at
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PERIOD_MINUTES = 15
|
||||||
|
_LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
|
||||||
|
|
||||||
|
# Maximum age a DSMR reading may have relative to the boundary being queried.
|
||||||
|
# Under normal operation DSMR readings arrive every ~10 seconds, so a reading
|
||||||
|
# more than one period (15 minutes) old at the boundary indicates either a data
|
||||||
|
# gap or — critically — a *future* boundary being resolved against the last
|
||||||
|
# historical reading. In both cases the reading is considered stale and
|
||||||
|
# ``register_at`` returns None, letting the period be marked degraded instead of
|
||||||
|
# producing a spurious zero-delta "successful" row.
|
||||||
|
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
|
||||||
|
|
||||||
|
# DSMR payload register keys (cumulative kWh, JSON string values).
|
||||||
|
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
||||||
|
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
||||||
|
_KEY_R1 = "electricity_returned_1" # returned low-tariff
|
||||||
|
_KEY_R2 = "electricity_returned_2" # returned high-tariff
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def floor_to_quarter(dt: datetime) -> datetime:
|
||||||
|
"""Return *dt* floored to the nearest UTC quarter-hour boundary.
|
||||||
|
|
||||||
|
The result always has seconds=0 and microseconds=0, and minutes in
|
||||||
|
{0, 15, 30, 45}. Timezone info is preserved if present.
|
||||||
|
"""
|
||||||
|
floored_minute = (dt.minute // _PERIOD_MINUTES) * _PERIOD_MINUTES
|
||||||
|
return dt.replace(minute=floored_minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: Any) -> Decimal:
|
||||||
|
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
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 _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
|
||||||
|
"""Return the EnergyCostPeriod row for period_start=t0, or None."""
|
||||||
|
return session.execute(
|
||||||
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == t0)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# register_at — boundary reading lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | None:
|
||||||
|
"""Return the four cumulative kWh register values at *boundary*.
|
||||||
|
|
||||||
|
Queries the most recent ``DsmrReading`` with ``recorded_at ≤ boundary``
|
||||||
|
and extracts the four energy registers from ``payload``:
|
||||||
|
|
||||||
|
d1 — electricity_delivered_1 (delivered low-tariff / dal)
|
||||||
|
d2 — electricity_delivered_2 (delivered high-tariff / normal)
|
||||||
|
r1 — electricity_returned_1 (returned low-tariff)
|
||||||
|
r2 — electricity_returned_2 (returned high-tariff)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
|
||||||
|
when:
|
||||||
|
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary``.
|
||||||
|
- Any of the four register keys is absent from the payload.
|
||||||
|
- Any of the four register values is ``None`` (null in JSON).
|
||||||
|
"""
|
||||||
|
row: DsmrReading | None = (
|
||||||
|
session.execute(
|
||||||
|
select(DsmrReading)
|
||||||
|
.where(DsmrReading.recorded_at <= boundary)
|
||||||
|
.order_by(DsmrReading.recorded_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Freshness guard: reject readings that are too old relative to *boundary*.
|
||||||
|
# ``recorded_at`` is stored as a naive UTC datetime in SQLite; attach UTC
|
||||||
|
# tzinfo before comparing with *boundary* (which is always tz-aware UTC) to
|
||||||
|
# avoid an "offset-naive vs offset-aware" TypeError.
|
||||||
|
if _as_utc(row.recorded_at) < _as_utc(boundary) - _READING_MAX_STALENESS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = row.payload or {}
|
||||||
|
try:
|
||||||
|
d1_raw = payload[_KEY_D1]
|
||||||
|
d2_raw = payload[_KEY_D2]
|
||||||
|
r1_raw = payload[_KEY_R1]
|
||||||
|
r2_raw = payload[_KEY_R2]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if any(v is None for v in (d1_raw, d2_raw, r1_raw, r2_raw)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"d1": _to_decimal(d1_raw),
|
||||||
|
"d2": _to_decimal(d2_raw),
|
||||||
|
"r1": _to_decimal(r1_raw),
|
||||||
|
"r2": _to_decimal(r2_raw),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compute_period — single 15-minute period
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -> bool:
|
||||||
|
"""Compute and upsert the billing record for the period ``[t0, t0+15min)``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session. Caller is responsible for committing.
|
||||||
|
t0:
|
||||||
|
UTC start of the 15-minute period. **Must** lie on a quarter-hour
|
||||||
|
grid boundary (minutes ∈ {0, 15, 30, 45}, seconds=0, microseconds=0).
|
||||||
|
overwrite:
|
||||||
|
If ``True``, overwrite an existing *successful* row (i.e. re-compute
|
||||||
|
even when a non-degraded record already exists). The normal tick path
|
||||||
|
always passes ``False``; only ``recompute_range`` passes ``True``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bool
|
||||||
|
``True`` if a record was written (inserted or updated), ``False`` if
|
||||||
|
the period was skipped (missing contract or missing Tibber price).
|
||||||
|
|
||||||
|
Side-effects
|
||||||
|
------------
|
||||||
|
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
|
||||||
|
- If readings are missing at either boundary: inserts/updates a degraded
|
||||||
|
row (costs=0, degraded=True).
|
||||||
|
- If the active contract version is missing: **skips** (returns False, no write).
|
||||||
|
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
|
||||||
|
(returns False, no write).
|
||||||
|
"""
|
||||||
|
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Immutability guard: skip if a successful record already exists and we
|
||||||
|
# are not in overwrite mode.
|
||||||
|
existing = _existing_period(session, t0)
|
||||||
|
if existing is not None and not existing.degraded and not overwrite:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Active contract version at t0 (checked before readings) ---
|
||||||
|
# If there is no active contract covering t0, skip the period entirely.
|
||||||
|
# We do not write a degraded row — there is no meaningful state to recover
|
||||||
|
# without a contract (we would not know which strategy to apply once data
|
||||||
|
# arrives). The period can be recovered via an explicit recompute_range once
|
||||||
|
# a contract is configured and activated.
|
||||||
|
version = active_contract_version_at(session, t0)
|
||||||
|
if version is None:
|
||||||
|
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Boundary readings ---
|
||||||
|
start_regs = register_at(session, t0)
|
||||||
|
end_regs = register_at(session, t1)
|
||||||
|
|
||||||
|
if start_regs is None or end_regs is None:
|
||||||
|
# Missing readings → write/update a degraded placeholder so the period
|
||||||
|
# is visible and can be retried by compute_closed_periods once data arrives.
|
||||||
|
_upsert_degraded(session, t0, now, existing)
|
||||||
|
return True # a record was written (degraded)
|
||||||
|
|
||||||
|
# --- Compute deltas (end − start) ---
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=end_regs["d1"] - start_regs["d1"],
|
||||||
|
d2=end_regs["d2"] - start_regs["d2"],
|
||||||
|
r1=end_regs["r1"] - start_regs["r1"],
|
||||||
|
r2=end_regs["r2"] - start_regs["r2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Price strategy ---
|
||||||
|
strategy = get_strategy(version.contract.kind)
|
||||||
|
try:
|
||||||
|
result = strategy(deltas, t0, version.values, session)
|
||||||
|
except TibberPriceNotFoundError:
|
||||||
|
# Missing Tibber price → skip the period; it will be retried once the
|
||||||
|
# price arrives (e.g. after the next Tibber refresh job runs).
|
||||||
|
logger.debug(
|
||||||
|
"compute_period(%s): no Tibber price found — skipping.", t0.isoformat()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Upsert the billing record ---
|
||||||
|
import_cost: Decimal = result["import_cost"]
|
||||||
|
export_revenue: Decimal = result["export_revenue"]
|
||||||
|
net_cost: Decimal = result["net_cost"]
|
||||||
|
pricing: dict = result["pricing"]
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
# Update in-place (overwrite=True or previous record was degraded).
|
||||||
|
existing.d1_kwh = float(deltas.d1)
|
||||||
|
existing.d2_kwh = float(deltas.d2)
|
||||||
|
existing.r1_kwh = float(deltas.r1)
|
||||||
|
existing.r2_kwh = float(deltas.r2)
|
||||||
|
existing.import_cost = float(import_cost)
|
||||||
|
existing.export_revenue = float(export_revenue)
|
||||||
|
existing.net_cost = float(net_cost)
|
||||||
|
existing.currency = version.contract.currency
|
||||||
|
existing.pricing = pricing
|
||||||
|
existing.contract_version_id = version.id
|
||||||
|
existing.degraded = False
|
||||||
|
existing.computed_at = now
|
||||||
|
else:
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=t0,
|
||||||
|
d1_kwh=float(deltas.d1),
|
||||||
|
d2_kwh=float(deltas.d2),
|
||||||
|
r1_kwh=float(deltas.r1),
|
||||||
|
r2_kwh=float(deltas.r2),
|
||||||
|
import_cost=float(import_cost),
|
||||||
|
export_revenue=float(export_revenue),
|
||||||
|
net_cost=float(net_cost),
|
||||||
|
currency=version.contract.currency,
|
||||||
|
pricing=pricing,
|
||||||
|
contract_version_id=version.id,
|
||||||
|
degraded=False,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_degraded(
|
||||||
|
session: Session,
|
||||||
|
t0: datetime,
|
||||||
|
now: datetime,
|
||||||
|
existing: EnergyCostPeriod | None,
|
||||||
|
) -> None:
|
||||||
|
"""Insert or update a degraded placeholder for period *t0*.
|
||||||
|
|
||||||
|
When *existing* is not None (row was previously written — either degraded
|
||||||
|
or successful), the row is explicitly reset to the standard degraded state.
|
||||||
|
This is required for the ``recompute_range`` (overwrite=True) path: if the
|
||||||
|
row was previously a *successful* computation and the boundary readings have
|
||||||
|
since disappeared, the stale non-zero costs must be cleared so the row
|
||||||
|
accurately reflects the current "missing readings" state rather than
|
||||||
|
masquerading as a valid result.
|
||||||
|
"""
|
||||||
|
if existing is not None:
|
||||||
|
# Explicitly reset to degraded state — identical field values to the
|
||||||
|
# new-row path below. This covers the recompute-over-successful-row
|
||||||
|
# case where old non-zero costs must not survive the downgrade.
|
||||||
|
existing.d1_kwh = 0.0
|
||||||
|
existing.d2_kwh = 0.0
|
||||||
|
existing.r1_kwh = 0.0
|
||||||
|
existing.r2_kwh = 0.0
|
||||||
|
existing.import_cost = 0.0
|
||||||
|
existing.export_revenue = 0.0
|
||||||
|
existing.net_cost = 0.0
|
||||||
|
existing.pricing = {}
|
||||||
|
existing.contract_version_id = None
|
||||||
|
existing.degraded = True
|
||||||
|
existing.computed_at = now
|
||||||
|
else:
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=t0,
|
||||||
|
d1_kwh=0.0,
|
||||||
|
d2_kwh=0.0,
|
||||||
|
r1_kwh=0.0,
|
||||||
|
r2_kwh=0.0,
|
||||||
|
import_cost=0.0,
|
||||||
|
export_revenue=0.0,
|
||||||
|
net_cost=0.0,
|
||||||
|
currency="EUR", # placeholder; real currency known after contract lookup
|
||||||
|
pricing={},
|
||||||
|
contract_version_id=None,
|
||||||
|
degraded=True,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compute_closed_periods — periodic tick
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def compute_closed_periods(session: Session) -> int:
|
||||||
|
"""Find and compute all uncalculated closed 15-minute periods.
|
||||||
|
|
||||||
|
A period ``[t0, t1)`` is *closed* when ``t1 ≤ now``. This function:
|
||||||
|
|
||||||
|
1. Determines the lookback window: from ``now − LOOKBACK_DAYS`` to ``now``,
|
||||||
|
floored to the nearest quarter-hour. This avoids an unbounded full
|
||||||
|
historical scan on every tick while still covering the typical recovery
|
||||||
|
window (short outages, missing contract, etc.). Periods older than
|
||||||
|
``LOOKBACK_DAYS`` must be recovered via an explicit ``recompute_range``.
|
||||||
|
2. Iterates over all quarter-hour boundaries in that window where
|
||||||
|
``t1 ≤ now`` (i.e. the period has already closed).
|
||||||
|
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
|
||||||
|
- Skips periods that already have a *successful* (non-degraded) record.
|
||||||
|
- Retries periods that have a *degraded* record.
|
||||||
|
- Skips periods for which no active contract version exists or the
|
||||||
|
Tibber price is unavailable (without writing a degraded row).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
Number of periods for which a record was written (inserted or updated).
|
||||||
|
Does not count skipped periods.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
# Current period boundary (the one whose t1 has not yet passed).
|
||||||
|
current_t0 = floor_to_quarter(now)
|
||||||
|
# Earliest boundary to consider.
|
||||||
|
earliest_t0 = floor_to_quarter(now - timedelta(days=_LOOKBACK_DAYS))
|
||||||
|
|
||||||
|
written = 0
|
||||||
|
t0 = earliest_t0
|
||||||
|
while t0 < current_t0:
|
||||||
|
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||||
|
if t1 <= now:
|
||||||
|
try:
|
||||||
|
did_write = compute_period(session, t0, overwrite=False)
|
||||||
|
if did_write:
|
||||||
|
written += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"compute_closed_periods: unexpected error for t0=%s — continuing.",
|
||||||
|
t0.isoformat(),
|
||||||
|
)
|
||||||
|
t0 += timedelta(minutes=_PERIOD_MINUTES)
|
||||||
|
|
||||||
|
if written:
|
||||||
|
session.commit()
|
||||||
|
logger.info("compute_closed_periods: wrote %d period(s).", written)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# recompute_range — explicit full recompute
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_range(session: Session, start: datetime, end: datetime) -> int:
|
||||||
|
"""Recompute (overwrite) all 15-minute periods in ``[start, end)``.
|
||||||
|
|
||||||
|
This is the *explicit opt-in* path for recovering from:
|
||||||
|
- Periods where readings or prices arrived late.
|
||||||
|
- Price corrections (new contract version retroactively applied).
|
||||||
|
- Any other reason to override the immutability guard.
|
||||||
|
|
||||||
|
The function iterates over every UTC quarter-hour boundary in
|
||||||
|
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
|
||||||
|
Existing rows (including successful ones) are overwritten.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session. The function commits after all periods
|
||||||
|
have been processed.
|
||||||
|
start:
|
||||||
|
Inclusive start datetime (floored to the nearest quarter-hour internally).
|
||||||
|
end:
|
||||||
|
Exclusive end datetime.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
Number of periods for which a record was written (inserted or updated).
|
||||||
|
Periods skipped due to missing contract or missing Tibber price are
|
||||||
|
*not* counted.
|
||||||
|
"""
|
||||||
|
t0 = floor_to_quarter(_as_utc(start))
|
||||||
|
end_utc = _as_utc(end)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
written = 0
|
||||||
|
while t0 < end_utc:
|
||||||
|
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||||
|
# Only recompute periods that have already closed (t1 ≤ now). Even
|
||||||
|
# when the caller passes a future *end*, we must not write speculative
|
||||||
|
# "future" rows: register_at would resolve to the latest historical
|
||||||
|
# reading for both boundaries, producing a zero-delta fake-success row
|
||||||
|
# that blocks the real computation once the period actually closes.
|
||||||
|
if t1 <= now:
|
||||||
|
try:
|
||||||
|
did_write = compute_period(session, t0, overwrite=True)
|
||||||
|
if did_write:
|
||||||
|
written += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"recompute_range: unexpected error for t0=%s — continuing.",
|
||||||
|
t0.isoformat(),
|
||||||
|
)
|
||||||
|
t0 += timedelta(minutes=_PERIOD_MINUTES)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"recompute_range(%s, %s): wrote %d period(s).",
|
||||||
|
start.isoformat(),
|
||||||
|
end.isoformat(),
|
||||||
|
written,
|
||||||
|
)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# summarize — layer-2 aggregation (read-time, not stored)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any]:
|
||||||
|
"""Aggregate billing for the interval ``[start, end)``.
|
||||||
|
|
||||||
|
Computes the total payable as:
|
||||||
|
|
||||||
|
total_payable = Σ(net_cost) -- metered electricity
|
||||||
|
+ fixed_costs -- (network_fee + management_fee) EUR/month ÷ 30 × days
|
||||||
|
- credits -- heffingskorting EUR/year ÷ 365 × days
|
||||||
|
|
||||||
|
The fixed costs and credits are derived from the *currently active contract
|
||||||
|
version* at *end* (i.e. the version in effect at the end of the requested
|
||||||
|
interval). When no active contract version exists, fixed costs and credits
|
||||||
|
are both 0; only the metered sum is returned.
|
||||||
|
|
||||||
|
All arithmetic uses Decimal; the returned dict contains Python floats for
|
||||||
|
JSON-serialisation convenience.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active read-only SQLAlchemy session.
|
||||||
|
start:
|
||||||
|
Inclusive start of the summary interval.
|
||||||
|
end:
|
||||||
|
Exclusive end of the summary interval.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
|
||||||
|
currency str ISO 4217 currency (from contract, or "EUR" fallback)
|
||||||
|
metered_import float Σ import_cost from non-degraded periods
|
||||||
|
metered_export float Σ export_revenue from non-degraded periods
|
||||||
|
metered_net float Σ net_cost from non-degraded periods
|
||||||
|
fixed_costs float standing charges apportioned over the interval
|
||||||
|
credits float energy-tax credit apportioned over the interval
|
||||||
|
total_payable float metered_net + fixed_costs − credits
|
||||||
|
period_count int number of non-degraded periods in range
|
||||||
|
degraded_count int number of degraded periods in range
|
||||||
|
days float interval length in days (total_seconds / 86400)
|
||||||
|
"""
|
||||||
|
start_utc = _as_utc(start)
|
||||||
|
end_utc = _as_utc(end)
|
||||||
|
|
||||||
|
# --- Fetch all EnergyCostPeriod rows in [start, end) ---
|
||||||
|
rows = session.execute(
|
||||||
|
select(EnergyCostPeriod).where(
|
||||||
|
EnergyCostPeriod.period_start >= start_utc,
|
||||||
|
EnergyCostPeriod.period_start < end_utc,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
good_rows = [r for r in rows if not r.degraded]
|
||||||
|
degraded_rows = [r for r in rows if r.degraded]
|
||||||
|
|
||||||
|
# Σ monetary amounts (Decimal arithmetic).
|
||||||
|
sum_import = sum((_to_decimal(r.import_cost) for r in good_rows), Decimal("0"))
|
||||||
|
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
|
||||||
|
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
|
||||||
|
|
||||||
|
# --- Interval length in days ---
|
||||||
|
total_seconds = (end_utc - start_utc).total_seconds()
|
||||||
|
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||||
|
|
||||||
|
# --- Active contract version at *end* for standing charges ---
|
||||||
|
version = active_contract_version_at(session, end_utc)
|
||||||
|
|
||||||
|
fixed_dec = Decimal("0")
|
||||||
|
credits_dec = Decimal("0")
|
||||||
|
currency = "EUR"
|
||||||
|
|
||||||
|
if version is not None:
|
||||||
|
currency = version.contract.currency
|
||||||
|
vals: dict = version.values or {}
|
||||||
|
standing: dict = vals.get("standing", {})
|
||||||
|
creds: dict = vals.get("credits", {})
|
||||||
|
|
||||||
|
network_fee = _to_decimal(standing.get("network_fee", 0))
|
||||||
|
management_fee = _to_decimal(standing.get("management_fee", 0))
|
||||||
|
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
|
||||||
|
|
||||||
|
# Standing charges: EUR/month → EUR/day (÷ 30) × days.
|
||||||
|
fixed_dec = (network_fee + management_fee) / Decimal("30") * days
|
||||||
|
|
||||||
|
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × days.
|
||||||
|
credits_dec = heffingskorting / Decimal("365") * days
|
||||||
|
|
||||||
|
total_payable = sum_net + fixed_dec - credits_dec
|
||||||
|
|
||||||
|
return {
|
||||||
|
"currency": currency,
|
||||||
|
"metered_import": float(sum_import),
|
||||||
|
"metered_export": float(sum_export),
|
||||||
|
"metered_net": float(sum_net),
|
||||||
|
"fixed_costs": float(fixed_dec),
|
||||||
|
"credits": float(credits_dec),
|
||||||
|
"total_payable": float(total_payable),
|
||||||
|
"period_count": len(good_rows),
|
||||||
|
"degraded_count": len(degraded_rows),
|
||||||
|
"days": float(days),
|
||||||
|
}
|
||||||
+114
-23
@@ -15,12 +15,17 @@ Design
|
|||||||
doing any work. Callers never need to guard these conditions.
|
doing any work. Callers never need to guard these conditions.
|
||||||
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
|
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
|
||||||
from mutable fields like ``friendly_name`` or auto-increment DB ids.
|
from mutable fields like ``friendly_name`` or auto-increment DB ids.
|
||||||
- **Discovery topic format**: ``<prefix>/<component>/<node_id>/<object_id>/config``
|
- **Discovery topic format**: ``<discovery_prefix>/<component>/<node_id>/<object_id>/config``
|
||||||
where ``node_id`` is the device uuid (slugified to be safe) and
|
where ``node_id`` is the device uuid (slugified to be safe) and
|
||||||
``object_id`` is a uuid-prefixed stable string.
|
``object_id`` is a uuid-prefixed stable string. Uses ``ha_discovery_prefix``
|
||||||
- **State topic format**: ``<prefix>/<component>/<node_id>/<object_id>/state``
|
(default ``"homeassistant"``), which must stay under the HA discovery namespace.
|
||||||
- **Availability topic**: ``<prefix>/modbus/<node_id>/availability`` (shared
|
- **State topic format**: ``<state_prefix>/<component>/<node_id>/<object_id>/state``
|
||||||
across all entities of the same device; publishes "online"/"offline").
|
Uses ``ha_state_topic_prefix`` (default ``"home_automation"``), separate from
|
||||||
|
the discovery namespace so HA discovery and state/availability topics live under
|
||||||
|
different prefixes.
|
||||||
|
- **Availability topic**: ``<state_prefix>/modbus/<node_id>/availability`` (shared
|
||||||
|
across all entities of the same device; publishes "online"/"offline"). Also uses
|
||||||
|
the state prefix, not the discovery prefix.
|
||||||
- **best-effort**: functions catch all exceptions internally so that callers
|
- **best-effort**: functions catch all exceptions internally so that callers
|
||||||
(e.g. the Modbus poll loop) never crash due to MQTT publish errors.
|
(e.g. the Modbus poll loop) never crash due to MQTT publish errors.
|
||||||
"""
|
"""
|
||||||
@@ -107,7 +112,8 @@ def _unique_id(entity: ExposableEntity) -> str:
|
|||||||
|
|
||||||
def build_discovery_payload(
|
def build_discovery_payload(
|
||||||
entity: ExposableEntity,
|
entity: ExposableEntity,
|
||||||
prefix: str,
|
discovery_prefix: str,
|
||||||
|
state_prefix: str | None = None,
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
"""Build the HA MQTT Discovery config topic and payload dict for *entity*.
|
"""Build the HA MQTT Discovery config topic and payload dict for *entity*.
|
||||||
|
|
||||||
@@ -115,8 +121,14 @@ def build_discovery_payload(
|
|||||||
----------
|
----------
|
||||||
entity:
|
entity:
|
||||||
An ``ExposableEntity`` (from ``build_catalog``).
|
An ``ExposableEntity`` (from ``build_catalog``).
|
||||||
prefix:
|
discovery_prefix:
|
||||||
The discovery prefix (e.g. ``"homeassistant"``).
|
The HA Discovery config topic prefix (e.g. ``"homeassistant"``).
|
||||||
|
This determines where HA looks for the discovery config topic.
|
||||||
|
state_prefix:
|
||||||
|
The prefix used for state and availability topics (e.g.
|
||||||
|
``"home_automation"``). When omitted or ``None``, falls back to
|
||||||
|
``discovery_prefix`` for backward compatibility (e.g. in tests that
|
||||||
|
pass only one prefix).
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -124,23 +136,33 @@ def build_discovery_payload(
|
|||||||
``(topic, config_dict)`` — the config topic and the HA Discovery
|
``(topic, config_dict)`` — the config topic and the HA Discovery
|
||||||
payload (not yet JSON-serialised).
|
payload (not yet JSON-serialised).
|
||||||
"""
|
"""
|
||||||
|
if state_prefix is None:
|
||||||
|
state_prefix = discovery_prefix
|
||||||
|
|
||||||
device_uuid = entity.device.identifiers[1]
|
device_uuid = entity.device.identifiers[1]
|
||||||
avail_topic = _availability_topic(device_uuid, prefix)
|
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||||
state_t = _state_topic(entity, prefix)
|
state_t = _state_topic(entity, state_prefix)
|
||||||
topic = _discovery_topic(entity, prefix)
|
topic = _discovery_topic(entity, discovery_prefix)
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
config: dict[str, Any] = {
|
||||||
"unique_id": _unique_id(entity),
|
"unique_id": _unique_id(entity),
|
||||||
"name": entity.name,
|
"name": entity.name,
|
||||||
"state_topic": state_t,
|
"state_topic": state_t,
|
||||||
"availability": [{"topic": avail_topic}],
|
|
||||||
"availability_mode": "all",
|
|
||||||
"device": {
|
"device": {
|
||||||
"identifiers": list(entity.device.identifiers),
|
"identifiers": list(entity.device.identifiers),
|
||||||
"name": entity.device.name,
|
"name": entity.device.name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Only declare an availability topic for devices that actually publish an
|
||||||
|
# online/offline heartbeat (e.g. Modbus, via its "online" binary_sensor).
|
||||||
|
# Devices without a heartbeat (e.g. energy-cost) omit availability so HA
|
||||||
|
# treats their entities as always-available — otherwise HA would mark them
|
||||||
|
# ``unavailable`` forever even though their state is being published.
|
||||||
|
if entity.device.provides_availability:
|
||||||
|
config["availability"] = [{"topic": avail_topic}]
|
||||||
|
config["availability_mode"] = "all"
|
||||||
|
|
||||||
# Component-specific fields
|
# Component-specific fields
|
||||||
if entity.component == "binary_sensor":
|
if entity.component == "binary_sensor":
|
||||||
# "online" binary sensor: payload ON/OFF
|
# "online" binary sensor: payload ON/OFF
|
||||||
@@ -181,7 +203,8 @@ def publish_discovery(session: Session) -> None:
|
|||||||
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
|
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
|
||||||
return
|
return
|
||||||
|
|
||||||
prefix = settings.ha_discovery_prefix
|
discovery_prefix = settings.ha_discovery_prefix
|
||||||
|
state_prefix = settings.ha_state_topic_prefix
|
||||||
|
|
||||||
try:
|
try:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
@@ -192,7 +215,7 @@ def publish_discovery(session: Session) -> None:
|
|||||||
for entry in catalog:
|
for entry in catalog:
|
||||||
entity = entry.entity
|
entity = entry.entity
|
||||||
try:
|
try:
|
||||||
topic, config = build_discovery_payload(entity, prefix)
|
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
||||||
if entry.enabled:
|
if entry.enabled:
|
||||||
payload = json.dumps(config)
|
payload = json.dumps(config)
|
||||||
mqtt_manager.publish(topic, payload, retain=True)
|
mqtt_manager.publish(topic, payload, retain=True)
|
||||||
@@ -234,7 +257,7 @@ def publish_states(session: Session) -> None:
|
|||||||
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
|
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
|
||||||
return
|
return
|
||||||
|
|
||||||
prefix = settings.ha_discovery_prefix
|
state_prefix = settings.ha_state_topic_prefix
|
||||||
|
|
||||||
try:
|
try:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
@@ -248,7 +271,7 @@ def publish_states(session: Session) -> None:
|
|||||||
continue
|
continue
|
||||||
entity = entry.entity
|
entity = entry.entity
|
||||||
try:
|
try:
|
||||||
_publish_entity_state(entity, prefix, session)
|
_publish_entity_state(entity, state_prefix, session)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"publish_states: error publishing state for %r; continuing", entity.key
|
"publish_states: error publishing state for %r; continuing", entity.key
|
||||||
@@ -322,13 +345,13 @@ def publish_device_state(session: Session, device: Any) -> None:
|
|||||||
if not _should_publish(settings):
|
if not _should_publish(settings):
|
||||||
return
|
return
|
||||||
|
|
||||||
prefix = settings.ha_discovery_prefix
|
state_prefix = settings.ha_state_topic_prefix
|
||||||
device_uuid = device.uuid
|
device_uuid = device.uuid
|
||||||
online = bool(device.last_poll_ok)
|
online = bool(device.last_poll_ok)
|
||||||
|
|
||||||
# Publish availability topic first.
|
# Publish availability topic first.
|
||||||
try:
|
try:
|
||||||
avail_topic = _availability_topic(device_uuid, prefix)
|
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||||
avail_payload = "online" if online else "offline"
|
avail_payload = "online" if online else "offline"
|
||||||
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -360,7 +383,7 @@ def publish_device_state(session: Session, device: Any) -> None:
|
|||||||
if entity.component == "binary_sensor" and "online" in entity.key:
|
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||||
# Publish the binary_sensor state too.
|
# Publish the binary_sensor state too.
|
||||||
try:
|
try:
|
||||||
state_t = _state_topic(entity, prefix)
|
state_t = _state_topic(entity, state_prefix)
|
||||||
mqtt_manager.publish(state_t, "ON", retain=False)
|
mqtt_manager.publish(state_t, "ON", retain=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -376,7 +399,7 @@ def publish_device_state(session: Session, device: Any) -> None:
|
|||||||
value = entity.value_getter(session)
|
value = entity.value_getter(session)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
state_t = _state_topic(entity, prefix)
|
state_t = _state_topic(entity, state_prefix)
|
||||||
mqtt_manager.publish(state_t, str(value), retain=False)
|
mqtt_manager.publish(state_t, str(value), retain=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -384,6 +407,74 @@ def publish_device_state(session: Session, device: Any) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_device_discovery(session: Session, device_uuid: str) -> None:
|
||||||
|
"""Best-effort: send empty retained payloads to all HA Discovery config topics
|
||||||
|
for the given device, effectively removing the device's entities from HA.
|
||||||
|
|
||||||
|
This must be called **before** the device rows are deleted from the DB, so
|
||||||
|
that ``build_catalog`` can still enumerate the device's entities.
|
||||||
|
|
||||||
|
Behaviour
|
||||||
|
---------
|
||||||
|
- No-op if MQTT / HA Discovery is not enabled or the MQTT client is not
|
||||||
|
connected.
|
||||||
|
- All exceptions are caught internally; this function never raises.
|
||||||
|
The caller proceeds with the DB deletion regardless of MQTT outcome.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session (device must still exist in DB at call time).
|
||||||
|
device_uuid:
|
||||||
|
UUID string of the device being deleted.
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
settings = build_runtime_settings(session, get_settings())
|
||||||
|
if not _should_publish(settings):
|
||||||
|
logger.debug(
|
||||||
|
"clear_device_discovery: skipped (MQTT/Discovery not enabled or not connected)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
discovery_prefix = settings.ha_discovery_prefix
|
||||||
|
state_prefix = settings.ha_state_topic_prefix
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"clear_device_discovery: failed to build catalog for device uuid=%s; skipping HA cleanup",
|
||||||
|
device_uuid,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cleared = 0
|
||||||
|
for entry in catalog:
|
||||||
|
entity = entry.entity
|
||||||
|
# Only clear entities belonging to this device.
|
||||||
|
if entity.device.identifiers[1] != device_uuid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
topic, _config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
||||||
|
mqtt_manager.publish(topic, b"", retain=True)
|
||||||
|
cleared += 1
|
||||||
|
logger.debug(
|
||||||
|
"clear_device_discovery: cleared config topic for entity %r → %s",
|
||||||
|
entity.key,
|
||||||
|
topic,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"clear_device_discovery: error clearing entity %r; continuing", entity.key
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"clear_device_discovery: cleared %d HA discovery topic(s) for device uuid=%s",
|
||||||
|
cleared,
|
||||||
|
device_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def publish_device_offline(session: Session, device_uuid: str) -> None:
|
def publish_device_offline(session: Session, device_uuid: str) -> None:
|
||||||
"""Publish an "offline" availability payload for *device_uuid*.
|
"""Publish an "offline" availability payload for *device_uuid*.
|
||||||
|
|
||||||
@@ -399,8 +490,8 @@ def publish_device_offline(session: Session, device_uuid: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prefix = settings.ha_discovery_prefix
|
state_prefix = settings.ha_state_topic_prefix
|
||||||
avail_topic = _availability_topic(device_uuid, prefix)
|
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||||
mqtt_manager.publish(avail_topic, "offline", retain=False)
|
mqtt_manager.publish(avail_topic, "offline", retain=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Service layer for fetching and persisting Tibber 15-minute electricity prices.
|
||||||
|
|
||||||
|
``refresh_prices`` is the main entry point. It is designed to be called from a
|
||||||
|
scheduled background job (see ``app/main.py``) and from tests.
|
||||||
|
|
||||||
|
Design decisions
|
||||||
|
----------------
|
||||||
|
- **Guard clause**: if no active contract with ``kind="tibber"`` exists, or if
|
||||||
|
the Tibber API token is empty in the runtime settings, the function is a
|
||||||
|
complete no-op (returns 0) and does not raise. This means the scheduler can
|
||||||
|
call ``refresh_prices`` unconditionally; the service itself decides whether to
|
||||||
|
do anything based on current configuration.
|
||||||
|
- **Upsert idempotency**: rows are matched by ``starts_at`` (the unique
|
||||||
|
constraint on ``tibber_price``). If a row already exists, its price fields
|
||||||
|
and ``fetched_at`` are updated in-place; if it does not exist, a new row is
|
||||||
|
inserted. Running ``refresh_prices`` twice in a row must not double-insert.
|
||||||
|
- **No destructive operations**: this service never deletes ``tibber_price``
|
||||||
|
rows. Only INSERT or UPDATE.
|
||||||
|
- **Exception propagation**: exceptions from the Tibber client are *not*
|
||||||
|
swallowed here. The scheduled job wrapper in ``main.py`` is responsible for
|
||||||
|
catching and logging errors so that a single fetch failure does not crash the
|
||||||
|
scheduler.
|
||||||
|
- **SQLite timezone note**: ``DateTime(timezone=True)`` columns come back as
|
||||||
|
timezone-naive UTC datetimes on read. We store UTC-aware datetimes on write
|
||||||
|
(``starts_at`` from ``PricePoint`` is already UTC-aware; ``fetched_at`` is
|
||||||
|
``datetime.now(UTC)``). Reads elsewhere that compare against these values
|
||||||
|
must normalise accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.integrations.tibber.client import fetch_price_range
|
||||||
|
from app.models.energy import EnergyContract, TibberPrice
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_tibber_contract_exists(session: Session) -> bool:
|
||||||
|
"""Return True if there is an active contract with kind='tibber'."""
|
||||||
|
row = session.execute(
|
||||||
|
select(EnergyContract).where(
|
||||||
|
EnergyContract.active.is_(True),
|
||||||
|
EnergyContract.kind == "tibber",
|
||||||
|
).limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_prices(session: Session, settings: object) -> int:
|
||||||
|
"""Fetch today-and-tomorrow Tibber prices and upsert them into ``tibber_price``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
An active SQLAlchemy session. The caller is responsible for closing it;
|
||||||
|
this function commits each upserted row individually to keep transactions
|
||||||
|
short.
|
||||||
|
settings:
|
||||||
|
A runtime settings object that exposes ``tibber_api_token`` and
|
||||||
|
``tibber_home_id`` attributes (typically a ``Settings`` or merged
|
||||||
|
runtime-settings instance from ``build_runtime_settings``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
Number of rows upserted (inserted or updated). Returns 0 for a no-op.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- No-op (returns 0) when:
|
||||||
|
* No active contract exists with ``kind="tibber"``, OR
|
||||||
|
* ``settings.tibber_api_token`` is empty or whitespace.
|
||||||
|
- Exceptions from the Tibber client are *not* caught here; they propagate to
|
||||||
|
the caller (the scheduled job wrapper swallows them and logs).
|
||||||
|
"""
|
||||||
|
token: str = getattr(settings, "tibber_api_token", "") or ""
|
||||||
|
if not token.strip():
|
||||||
|
logger.debug("refresh_prices: tibber_api_token is empty — no-op")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not _active_tibber_contract_exists(session):
|
||||||
|
logger.debug("refresh_prices: no active tibber contract — no-op")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
home_id: str = getattr(settings, "tibber_home_id", "") or ""
|
||||||
|
home_id_or_none: str | None = home_id.strip() or None
|
||||||
|
|
||||||
|
logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none)
|
||||||
|
|
||||||
|
# May raise TibberError or TibberAuthError — let them propagate.
|
||||||
|
price_points = fetch_price_range(token, home_id_or_none)
|
||||||
|
|
||||||
|
if not price_points:
|
||||||
|
logger.info("refresh_prices: API returned zero price points — nothing to upsert")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
fetched_at = datetime.now(UTC)
|
||||||
|
upserted = 0
|
||||||
|
|
||||||
|
for point in price_points:
|
||||||
|
existing = session.execute(
|
||||||
|
select(TibberPrice).where(TibberPrice.starts_at == point.starts_at)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
# Update in-place — price fields may change (e.g. Tibber corrects a
|
||||||
|
# forecast), but starts_at and resolution stay the same.
|
||||||
|
existing.total = point.total
|
||||||
|
existing.energy = point.energy
|
||||||
|
existing.tax = point.tax
|
||||||
|
existing.level = point.level
|
||||||
|
existing.currency = point.currency
|
||||||
|
existing.fetched_at = fetched_at
|
||||||
|
else:
|
||||||
|
session.add(
|
||||||
|
TibberPrice(
|
||||||
|
starts_at=point.starts_at,
|
||||||
|
resolution=point.resolution,
|
||||||
|
total=point.total,
|
||||||
|
energy=point.energy,
|
||||||
|
tax=point.tax,
|
||||||
|
level=point.level,
|
||||||
|
currency=point.currency,
|
||||||
|
fetched_at=fetched_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
upserted += 1
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info("refresh_prices: upserted %d price points", upserted)
|
||||||
|
return upserted
|
||||||
@@ -19,36 +19,42 @@
|
|||||||
|
|
||||||
- `main.py`
|
- `main.py`
|
||||||
- FastAPI app factory
|
- FastAPI app factory
|
||||||
- lifespan(APScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布)
|
- lifespan(APScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布;M6 新增 `tibber-refresh` 抓价 job + `energy-cost` 1 分钟计费 tick job;M6 启用时注册 DSMR MQTT 订阅)
|
||||||
- 基础路由注册
|
- 基础路由注册
|
||||||
- `config.py`
|
- `config.py`
|
||||||
- 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项)
|
- 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项;M6 新增 `dsmr_ingest_enabled`、`dsmr_mqtt_topic`、`dsmr_sample_interval_s`、`tibber_api_token`(secret)、`tibber_home_id`)
|
||||||
- `db.py`
|
- `db.py`
|
||||||
- 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engine(SQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
|
- 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engine(SQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
|
||||||
- `dependencies.py`
|
- `dependencies.py`
|
||||||
- 通用依赖注入
|
- 通用依赖注入
|
||||||
- `api/`
|
- `api/`
|
||||||
- HTTP routes
|
- HTTP routes
|
||||||
- `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD、Modbus 设备 CRUD + readings + metrics + test(`/api/modbus/*`)、Expose 勾选 + 重发 discovery(`/api/expose`)、MQTT 测试连接(`/api/config/mqtt/test`)
|
- `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD、Modbus 设备 CRUD + readings + metrics + test(`/api/modbus/*`)、Expose 勾选 + 重发 discovery(`/api/expose`)、MQTT 测试连接(`/api/config/mqtt/test`)、M6 新增合同 CRUD + 版本(`/api/energy/contracts*`)、pricing profile 列表(`/api/energy/profiles`)、价格/费用/汇总/DSMR 最新/重算/Tibber 测试(`/api/energy/prices`、`/api/energy/costs`、`/api/energy/costs/summary`、`/api/energy/dsmr/latest`、`/api/energy/costs/recompute`、`/api/energy/tibber/test`)
|
||||||
- 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等
|
- 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等
|
||||||
- `models/`
|
- `models/`
|
||||||
- SQLAlchemy models
|
- SQLAlchemy models
|
||||||
- 所有模型(auth / config / public_ip / location / poo / modbus / expose)共用同一个 `Base`,均落在单一 `app.db` 中
|
- 所有模型(auth / config / public_ip / location / poo / modbus / expose / energy)共用同一个 `Base`,均落在单一 `app.db` 中
|
||||||
- M5 新增:`ModbusDevice`(设备部署层)、`ModbusReading`(通用遥测,JSON payload)、`ExposedEntityToggle`(HA 实体暴露开关)
|
- M5 新增:`ModbusDevice`(设备部署层)、`ModbusReading`(通用遥测,JSON payload)、`ExposedEntityToggle`(HA 实体暴露开关)
|
||||||
|
- M6 新增:`DsmrReading`(整帧 DSMR telegram,10s 降采样)、`EnergyContract`(合同头,含 active 标记)、`EnergyContractVersion`(版本/时段,values JSON,只增不改)、`TibberPrice`(15 分钟价缓存,不可变)、`EnergyCostPeriod`(每 15 分钟计量电费,快照价,不可变)
|
||||||
- `schemas/`
|
- `schemas/`
|
||||||
- Pydantic schemas(M5 新增 `modbus.py`、`expose.py`)
|
- Pydantic schemas(M5 新增 `modbus.py`、`expose.py`;M6 新增 `energy_contract.py`、`energy.py`)
|
||||||
- `services/`
|
- `services/`
|
||||||
- 业务服务层
|
- 业务服务层
|
||||||
- 当前已迁入 config page 的 DB 持久化逻辑
|
- 当前已迁入 config page 的 DB 持久化逻辑
|
||||||
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
|
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
|
||||||
- 当前已迁入 SMTP 发信与测试发信逻辑
|
- 当前已迁入 SMTP 发信与测试发信逻辑
|
||||||
- M5 新增:`modbus_poll.py`(采集 service,逐设备 poll + 落库 + 推 MQTT state)、`ha_discovery.py`(构建 HA Discovery payload、发布 retained config、发布 state)
|
- M5 新增:`modbus_poll.py`(采集 service,逐设备 poll + 落库 + 推 MQTT state)、`ha_discovery.py`(构建 HA Discovery payload、发布 retained config、发布 state)
|
||||||
|
- M6 新增:`tibber_prices.py`(httpx GraphQL 抓 15 分钟价,upsert `tibber_price`,幂等;仅 active=tibber 且 token 存在时运行)、`dsmr_ingest.py`(MQTT handler,整帧 JSON blob + 10s 降采样落库,`source_id` 幂等)、`energy_cost.py`(计费引擎:每 15 分钟寄存器差 × strategy 出价 → `energy_cost_period` 不可变快照;汇总 Σnet + 固定费 − heffingskorting;重算显式 opt-in)
|
||||||
- `integrations/`
|
- `integrations/`
|
||||||
- 外部系统适配层
|
- 外部系统适配层
|
||||||
- Home Assistant outbound adapter(REST 通道,原有)
|
- Home Assistant outbound adapter(REST 通道,原有)
|
||||||
- M5 新增:`modbus/`(pymodbus 薄封装:`driver.py` 块读 + float32 解码;`profiles.py` YAML profile 加载/校验/解码;`profiles/sdm120.yaml` SDM120 协议声明)
|
- M5 新增:`modbus/`(pymodbus 薄封装:`driver.py` 块读 + float32 解码;`profiles.py` YAML profile 加载/校验/解码;`profiles/sdm120.yaml` SDM120 协议声明)
|
||||||
- M5 新增:`mqtt.py`(paho-mqtt 长连接 `MqttManager`:lifespan 起/停、配置变更重连、`publish(topic, payload, retain)`)
|
- M5 新增:`mqtt.py`(paho-mqtt 长连接 `MqttManager`:lifespan 起/停、配置变更重连、`publish(topic, payload, retain)`)
|
||||||
- M5 新增:`expose.py`(通用 expose 框架:`ExposableEntity`、provider 注册表、`build_catalog`;Modbus provider 从 YAML profile 派生 sensor/binary_sensor 实体目录)
|
- M5 新增:`expose.py`(通用 expose 框架:`ExposableEntity`、provider 注册表、`build_catalog`;Modbus provider 从 YAML profile 派生 sensor/binary_sensor 实体目录)
|
||||||
|
- M6 新增:`pricing/`(通用电价层:`profiles.py` pydantic 加载/校验 YAML profile + `validate_values`;`strategies.py` manual/tibber 出价策略注册表;`profiles/manual.yaml` 固定/双费率结构;`profiles/tibber.yaml` 动态电价结构)
|
||||||
|
- M6 新增:`tibber/`(`client.py`:httpx GraphQL 客户端,`priceInfoRange(QUARTER_HOURLY)` 查询,按 `starts_at` 解析,不假设固定节点数)
|
||||||
|
- M6 扩展:`mqtt.py` 新增订阅端(`subscribe(topic, handler)`,`on_connect` 里 subscribe,`on_message` 按 topic 分发,handler 异常吞掉不崩连接)
|
||||||
|
- M6 扩展:`expose.py` 新增 `_energy_cost_provider`(`buy_price_now`、`sell_price_now`、`import_cost_total`/`export_revenue_total` 均 `total_increasing`,反哺 HA Energy)
|
||||||
- `static/`
|
- `static/`
|
||||||
- 极简静态资源
|
- 极简静态资源
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# 设计文档与多模型协作约定
|
# 设计文档与多模型协作约定
|
||||||
|
|
||||||
本目录把 `docs/roadmap.md` 里的三个里程碑展开成**可被 coding agent 流水线执行**的详细设计文档。
|
本目录把 `docs/roadmap.md` 里的各个里程碑展开成**可被 coding agent 流水线执行**的详细设计文档。
|
||||||
|
|
||||||
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
|
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
|
||||||
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
|
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
|
||||||
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
|
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
|
||||||
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做**
|
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做**
|
||||||
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
||||||
|
- [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺
|
||||||
|
|
||||||
本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。
|
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ Phase D(API + 前端)
|
|||||||
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库);前端闸门见 §8。**不新增依赖**。纯新增、不删/移文件;改 `app/main.py` lifespan 时不得破坏既有 job/连接。
|
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库);前端闸门见 §8。**不新增依赖**。纯新增、不删/移文件;改 `app/main.py` lifespan 时不得破坏既有 job/连接。
|
||||||
|
|
||||||
### M6-T01 — 5 张表与模型 `[schema]`
|
### M6-T01 — 5 张表与模型 `[schema]`
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 单库 app 链新增 5 张表(§3.5)。只建 schema + 模型,不写采集/计算/接口。
|
- **Context**: 单库 app 链新增 5 张表(§3.5)。只建 schema + 模型,不写采集/计算/接口。
|
||||||
- **Files**: `create app/models/energy.py`(`DsmrReading`/`EnergyContract`/`EnergyContractVersion`/`TibberPrice`/`EnergyCostPeriod`);`create alembic_app/versions/20260623_11_energy_tables.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`(`APP_BASELINE_REVISION`→新 head);`create tests/test_energy_models.py`
|
- **Files**: `create app/models/energy.py`(`DsmrReading`/`EnergyContract`/`EnergyContractVersion`/`TibberPrice`/`EnergyCostPeriod`);`create alembic_app/versions/20260623_11_energy_tables.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`(`APP_BASELINE_REVISION`→新 head);`create tests/test_energy_models.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -304,7 +304,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列;`env.py` 已 import 新模型;迁移 `down_revision` 指当前真实 head;无破坏性操作。
|
- **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列;`env.py` 已 import 新模型;迁移 `down_revision` 指当前真实 head;无破坏性操作。
|
||||||
|
|
||||||
### M6-T02 — flat 配置(DSMR + Tibber 凭据)
|
### M6-T02 — flat 配置(DSMR + Tibber 凭据)
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: DSMR 订阅设置 + Tibber 凭据接入扁平配置(前端自动渲染)。**价格数值不在这里**(在合同里)。
|
- **Context**: DSMR 订阅设置 + Tibber 凭据接入扁平配置(前端自动渲染)。**价格数值不在这里**(在合同里)。
|
||||||
- **Files**: `modify app/config.py`、`app/services/config_page.py`(CONFIG_FIELDS + `_settings_payload`);`modify .env.example`、`tests/test_api_config.py`
|
- **Files**: `modify app/config.py`、`app/services/config_page.py`(CONFIG_FIELDS + `_settings_payload`);`modify .env.example`、`tests/test_api_config.py`
|
||||||
- **Steps**: `Settings` 加 `dsmr_ingest_enabled: bool=False`、`dsmr_mqtt_topic: str="dsmr/json"`、`dsmr_sample_interval_s: int=10`、`tibber_api_token: str=""`、`tibber_home_id: str=""`;CONFIG_FIELDS 归「DSMR」「Tibber」section,`tibber_api_token` 为 secret,bool=checkbox、数值=number;`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。
|
- **Steps**: `Settings` 加 `dsmr_ingest_enabled: bool=False`、`dsmr_mqtt_topic: str="dsmr/json"`、`dsmr_sample_interval_s: int=10`、`tibber_api_token: str=""`、`tibber_home_id: str=""`;CONFIG_FIELDS 归「DSMR」「Tibber」section,`tibber_api_token` 为 secret,bool=checkbox、数值=number;`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。
|
||||||
@@ -315,7 +315,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;`_settings_payload` 无遗漏;默认 off。
|
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;`_settings_payload` 无遗漏;默认 off。
|
||||||
|
|
||||||
### M6-T03 — pricing profile 框架 + strategy 注册表
|
### M6-T03 — pricing profile 框架 + strategy 注册表
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 仓库内 `manual.yaml`/`tibber.yaml` 定结构(pydantic 校验),加 price strategy 注册表(manual/tibber 出价)。纯模块,单测。
|
- **Context**: 仓库内 `manual.yaml`/`tibber.yaml` 定结构(pydantic 校验),加 price strategy 注册表(manual/tibber 出价)。纯模块,单测。
|
||||||
- **Files**: `create app/integrations/pricing/__init__.py`、`pricing/profiles.py`(pydantic 模型 + `load_profile`/`list_profiles`/`validate_values`)、`pricing/profiles/manual.yaml`、`pricing/profiles/tibber.yaml`、`pricing/strategies.py`(`register_strategy`/`get_strategy`、`manual`/`tibber` 实现);`create tests/test_pricing_profiles.py`、`tests/test_pricing_strategies.py`
|
- **Files**: `create app/integrations/pricing/__init__.py`、`pricing/profiles.py`(pydantic 模型 + `load_profile`/`list_profiles`/`validate_values`)、`pricing/profiles/manual.yaml`、`pricing/profiles/tibber.yaml`、`pricing/strategies.py`(`register_strategy`/`get_strategy`、`manual`/`tibber` 实现);`create tests/test_pricing_profiles.py`、`tests/test_pricing_strategies.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -333,7 +333,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
|
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
|
||||||
|
|
||||||
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API)
|
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API)
|
||||||
- **Status**: `todo` · **Depends**: M6-T01, M6-T03
|
- **Status**: `done` · **Depends**: M6-T01, M6-T03
|
||||||
- **Context**: 合同增删改、版本(改价加新版本、生效日期)、激活(一次一个)、按 profile 校验数值。
|
- **Context**: 合同增删改、版本(改价加新版本、生效日期)、激活(一次一个)、按 profile 校验数值。
|
||||||
- **Files**: `create app/api/routes/api/energy_contracts.py`、`app/schemas/energy_contract.py`、`app/services/contracts.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy_contracts.py`
|
- **Files**: `create app/api/routes/api/energy_contracts.py`、`app/schemas/energy_contract.py`、`app/services/contracts.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy_contracts.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -347,7 +347,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。
|
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。
|
||||||
|
|
||||||
### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
|
### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
|
||||||
- **Status**: `todo` · **Depends**: M6-T01, M6-T02, M6-T03
|
- **Status**: `done` · **Depends**: M6-T01, M6-T02, M6-T03
|
||||||
- **Context**: httpx 拉 15min 价 upsert `tibber_price`;启动+每日抓(仅 active=tibber);连接测试。
|
- **Context**: httpx 拉 15min 价 upsert `tibber_price`;启动+每日抓(仅 active=tibber);连接测试。
|
||||||
- **Files**: `create app/integrations/tibber/__init__.py`、`tibber/client.py`、`app/services/tibber_prices.py`;`modify app/main.py`(抓价 job);`create tests/test_tibber_client.py`、`tests/test_tibber_prices.py`
|
- **Files**: `create app/integrations/tibber/__init__.py`、`tibber/client.py`、`app/services/tibber_prices.py`;`modify app/main.py`(抓价 job);`create tests/test_tibber_client.py`、`tests/test_tibber_prices.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -362,7 +362,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: token 不进日志;httpx 超时;upsert 幂等;时间存 UTC;无对 `tibber_price` 的破坏性批删。
|
- **Reviewer checklist**: token 不进日志;httpx 超时;upsert 幂等;时间存 UTC;无对 `tibber_price` 的破坏性批删。
|
||||||
|
|
||||||
### M6-T06 — MqttManager 扩订阅 + DSMR ingest
|
### M6-T06 — MqttManager 扩订阅 + DSMR ingest
|
||||||
- **Status**: `todo` · **Depends**: M6-T01, M6-T02
|
- **Status**: `done` · **Depends**: M6-T01, M6-T02
|
||||||
- **Context**: 给只发不收的 `MqttManager` 扩订阅;DSMR ingest 整帧 blob + 10s 降采样落库。
|
- **Context**: 给只发不收的 `MqttManager` 扩订阅;DSMR ingest 整帧 blob + 10s 降采样落库。
|
||||||
- **Files**: `modify app/integrations/mqtt.py`(订阅注册 + on_message 分发);`create app/services/dsmr_ingest.py`;`modify app/main.py`(启用时注册订阅);`create tests/test_mqtt_subscribe.py`、`tests/test_dsmr_ingest.py`
|
- **Files**: `modify app/integrations/mqtt.py`(订阅注册 + on_message 分发);`create app/services/dsmr_ingest.py`;`modify app/main.py`(启用时注册订阅);`create tests/test_mqtt_subscribe.py`、`tests/test_dsmr_ingest.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -378,7 +378,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: on_message 网络线程内只做短事务、吞异常不崩连接;整帧存(含 gas/各相);null 容错;`source_id` 幂等;10s 降采样正确。
|
- **Reviewer checklist**: on_message 网络线程内只做短事务、吞异常不崩连接;整帧存(含 gas/各相);null 容错;`source_id` 幂等;10s 降采样正确。
|
||||||
|
|
||||||
### M6-T07 — 计费引擎 + 周期 job + 汇总 + 重算
|
### M6-T07 — 计费引擎 + 周期 job + 汇总 + 重算
|
||||||
- **Status**: `todo` · **Depends**: M6-T01, M6-T03, M6-T04, M6-T05, M6-T06
|
- **Status**: `done` · **Depends**: M6-T01, M6-T03, M6-T04, M6-T05, M6-T06
|
||||||
- **Context**: 每 15min 用寄存器差 × active 合同 strategy 算计量电费(快照、不可变);汇总加固定费 − 抵扣;周期 job + 重算。
|
- **Context**: 每 15min 用寄存器差 × active 合同 strategy 算计量电费(快照、不可变);汇总加固定费 − 抵扣;周期 job + 重算。
|
||||||
- **Files**: `create app/services/energy_cost.py`;`modify app/main.py`(周期 job);`create tests/test_energy_cost.py`
|
- **Files**: `create app/services/energy_cost.py`;`modify app/main.py`(周期 job);`create tests/test_energy_cost.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -393,7 +393,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0<effective_to`;job 不崩。
|
- **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0<effective_to`;job 不崩。
|
||||||
|
|
||||||
### M6-T08 — energy_cost expose provider + state 发布
|
### M6-T08 — energy_cost expose provider + state 发布
|
||||||
- **Status**: `todo` · **Depends**: M6-T07
|
- **Status**: `done` · **Depends**: M6-T07
|
||||||
- **Context**: 复用 M5 expose/Discovery,发当前买/卖价 + 累计成本/收入。
|
- **Context**: 复用 M5 expose/Discovery,发当前买/卖价 + 累计成本/收入。
|
||||||
- **Files**: `modify app/integrations/expose.py`(`_energy_cost_provider` + 注册);`modify app/services/energy_cost.py` 或 `app/main.py`(算完顺带 `publish_states`);`create tests/test_energy_expose.py`
|
- **Files**: `modify app/integrations/expose.py`(`_energy_cost_provider` + 注册);`modify app/services/energy_cost.py` 或 `app/main.py`(算完顺带 `publish_states`);`create tests/test_energy_expose.py`
|
||||||
- **Steps**: provider 产出 §3.7 实体(`buy_price_now`/`sell_price_now`/`import_cost_total`/`export_revenue_total`),累计项 `total_increasing`+`monetary`+currency;`value_getter` 从 `energy_cost_period` 取;计费 job 后 `publish_states`。
|
- **Steps**: provider 产出 §3.7 实体(`buy_price_now`/`sell_price_now`/`import_cost_total`/`export_revenue_total`),累计项 `total_increasing`+`monetary`+currency;`value_getter` 从 `energy_cost_period` 取;计费 job 后 `publish_states`。
|
||||||
@@ -405,7 +405,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。
|
- **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。
|
||||||
|
|
||||||
### M6-T09 — Energy 数据 API
|
### M6-T09 — Energy 数据 API
|
||||||
- **Status**: `todo` · **Depends**: M6-T05, M6-T07
|
- **Status**: `done` · **Depends**: M6-T05, M6-T07
|
||||||
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。
|
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。
|
||||||
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`;`modify app/main.py`(注册);`create tests/test_api_energy.py`
|
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`;`modify app/main.py`(注册);`create tests/test_api_energy.py`
|
||||||
- **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。
|
- **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。
|
||||||
@@ -416,7 +416,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
|
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
|
||||||
|
|
||||||
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
|
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
|
||||||
- **Status**: `todo` · **Depends**: M6-T04, M6-T09
|
- **Status**: `done` · **Depends**: M6-T04, M6-T09
|
||||||
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/ContractManager.tsx`、`ContractForm.tsx`、`TibberPrices.tsx`、`CostView.tsx`、扩 `hooks.ts`;`modify` Config 页 Tibber 测试入口;`create` 对应 `*.test.tsx`
|
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/ContractManager.tsx`、`ContractForm.tsx`、`TibberPrices.tsx`、`CostView.tsx`、扩 `hooks.ts`;`modify` Config 页 Tibber 测试入口;`create` 对应 `*.test.tsx`
|
||||||
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
|
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
|
||||||
- **Out of scope**: 不做服务端降采样;不改后端。
|
- **Out of scope**: 不做服务端降采样;不改后端。
|
||||||
@@ -427,7 +427,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: 全走类型化 client;图表组件隔离复用;表单按结构渲染、不 hardcode 字段;窗口/上限;空/错/加载/缺 key 容错。
|
- **Reviewer checklist**: 全走类型化 client;图表组件隔离复用;表单按结构渲染、不 hardcode 字段;窗口/上限;空/错/加载/缺 key 容错。
|
||||||
|
|
||||||
### M6-T11 — 文档 + OpenAPI + roadmap 收尾
|
### M6-T11 — 文档 + OpenAPI + roadmap 收尾
|
||||||
- **Status**: `todo` · **Depends**: 全部
|
- **Status**: `done` · **Depends**: 全部
|
||||||
- **Files**: `modify README.md`、`docs/roadmap.md`(M6 行 + 毕业)、`docs/architecture-overview.md`、`docs/design/README.md`(列入 m6);`run python scripts/export_openapi.py` 并提交 `openapi/`
|
- **Files**: `modify README.md`、`docs/roadmap.md`(M6 行 + 毕业)、`docs/architecture-overview.md`、`docs/design/README.md`(列入 m6);`run python scripts/export_openapi.py` 并提交 `openapi/`
|
||||||
- **Acceptance criteria**:
|
- **Acceptance criteria**:
|
||||||
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异;校验闸门全绿。
|
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异;校验闸门全绿。
|
||||||
|
|||||||
+39
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
||||||
|
|
||||||
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
||||||
|
|
||||||
## 当前基线(v1.0.3)
|
## 当前基线(v1.0.3)
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
||||||
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
||||||
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
|
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
|
||||||
|
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
|
||||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||||
|
|
||||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
||||||
@@ -176,6 +177,43 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## M6 — 通用电价层 + DSMR 实时数据 + 实时买卖电费计算(✅ 已完成)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
在 M5 IoT 基建之上,把"电价合同"与"DSMR 实时电表数据"接起来,按 **15 分钟**周期算出**实际买电支出 / 卖电收入**,自己落库留底(不可变、可审计),并通过 MQTT + HA Discovery 反哺 Home Assistant 的 Energy 仪表盘。
|
||||||
|
|
||||||
|
### 关键能力
|
||||||
|
|
||||||
|
- **通用电价层(两层模型)**:仓库内 YAML profile 定结构(`manual` 固定/双费率、`tibber` 动态电价);`EnergyContract`(+ 版本)存 UI 可改的数值;price strategy 按 `kind` 出价。一次激活一个合同。改价 = 加新版本,旧版本保留(审计链)。
|
||||||
|
- **DSMR 实时数据接入**:扩 `MqttManager` 订阅 `dsmr/json`(每秒一帧),整帧 JSON blob 按 10 秒降采样落库(电、气、各相全存;字段 allowlist 不做,blob 天然容纳未来字段)。
|
||||||
|
- **实时买卖电费计算(两层)**:每 15 分钟用电表累计寄存器差值(`delivered_1/2`、`returned_1/2`)× 当时合同的买/卖价算计量电费(不可变、快照价)。买价:tibber = API `total`(含税全包),manual = `energy_buy_档 + energy_tax`;卖价:tibber = `total − energy_tax − sell_adjust`,manual = `sell_档`(无能源税)。双费率:`_1`=dal/低、`_2`=normal/高(NL 惯例)。日/月/年汇总再加固定费(按月→天)减 heffingskorting(按年→天)。不做净计量。卖价残差(sell_adjust)及能源税等当前数值在真实账单确认后钉死。
|
||||||
|
- **反哺 Home Assistant**:`_energy_cost_provider` 发 `buy_price_now`、`sell_price_now`、`import_cost_total`(`total_increasing`)、`export_revenue_total`(`total_increasing`)—— 直接挂 HA Energy 仪表盘。
|
||||||
|
- **Tibber 动态电价**:httpx GraphQL 客户端抓 `QUARTER_HOURLY` 15 分钟价(已 demo 验证 `total=energy+tax`);启动 + 每小时抓取今明两天价格、幂等 upsert `tibber_price`(hourly trigger,确保每日刷新且可补重试);仅当 active 合同 kind=tibber 且 token 存在时运行。
|
||||||
|
- **前端**:Energy 视图新增三个 Tab——Contracts(合同管理,表单按 profile 结构渲染)、Prices(15 分钟价格曲线)、Costs(费用走势/明细/汇总卡片);Config 页 Tibber 测试三态入口。
|
||||||
|
|
||||||
|
### 命名分层(延续 M5)
|
||||||
|
|
||||||
|
存储/采集/计算层中性命名(`dsmr_*` / `tibber_price` / `energy_cost_period` / `energy_contract`);面向用户并入既有 **Energy** 视图。
|
||||||
|
|
||||||
|
### 新增 5 张表(单库 app 链,migration `20260623_11_energy_tables`)
|
||||||
|
|
||||||
|
| 表 | 关键列 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `dsmr_reading` | `recorded_at`(idx)、`source_id`(unique)、`payload`(JSON) | 整帧 DSMR telegram blob,10 秒降采样 |
|
||||||
|
| `energy_contract` | `name`、`kind`、`active`、`currency`、时间戳 | 合同头;一次一个 active |
|
||||||
|
| `energy_contract_version` | `contract_id`(FK)、`effective_from`、`effective_to`(null)、`values`(JSON)、`created_at` | 版本/时段;改价加新版本、旧版本保留 |
|
||||||
|
| `tibber_price` | `starts_at`(unique)、`resolution`、`energy/tax/total`、`level`、`currency`、`fetched_at` | 15 分钟价缓存,不可变 |
|
||||||
|
| `energy_cost_period` | `period_start`(unique)、`d1/d2/r1/r2_kwh`、`import_cost/export_revenue/net_cost`、`currency`、`pricing`(JSON 快照)、`contract_version_id`(FK)、`degraded`、`computed_at` | 每 15 分钟计量电费,不可变(快照价+版本) |
|
||||||
|
|
||||||
|
### 不新增依赖
|
||||||
|
|
||||||
|
httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,不新增任何 Python 包。
|
||||||
|
|
||||||
|
> 详细设计与任务卡:[`docs/design/m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## M3 — 开放与移动端(远期试水)
|
## M3 — 开放与移动端(远期试水)
|
||||||
|
|
||||||
### 目标
|
### 目标
|
||||||
|
|||||||
Vendored
+1181
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ContractForm component.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders profile fields dynamically from profile structure (not hardcoded)
|
||||||
|
* — verified by passing defaultKind so fields render automatically.
|
||||||
|
* 2. Submit in create mode calls POST /api/energy/contracts.
|
||||||
|
* 3. Submit in add-version mode calls POST /api/energy/contracts/{id}/versions.
|
||||||
|
* 4. Cancel closes modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PROFILES_RESPONSE = {
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'Fixed / Variable Rate (NL, dual-tariff)',
|
||||||
|
energy: {
|
||||||
|
dual_tariff: true,
|
||||||
|
buy: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
|
||||||
|
sell: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
|
||||||
|
energy_tax: { unit: 'EUR/kWh' },
|
||||||
|
ode: { unit: 'EUR/kWh', default: 0 },
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
network_fee: { unit: 'EUR/month' },
|
||||||
|
management_fee: { unit: 'EUR/month' },
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
heffingskorting: { unit: 'EUR/year' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREATED_CONTRACT = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Contract',
|
||||||
|
kind: 'manual',
|
||||||
|
active: false,
|
||||||
|
currency: 'EUR',
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
versions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { ContractForm } from './ContractForm'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ContractForm', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders profile fields dynamically from profile structure (not hardcoded)', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
|
||||||
|
// Pass defaultKind so the profile fields render automatically in create mode
|
||||||
|
renderWithProviders(
|
||||||
|
<ContractForm defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for profiles to load and fields to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
|
||||||
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
|
// Verify sections are rendered from profile structure (not hardcoded)
|
||||||
|
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('contract-section-standing')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('contract-section-credits')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Verify specific fields exist from the profile structure
|
||||||
|
// "energy.buy.normal" → label "Buy Normal"
|
||||||
|
expect(screen.getByTestId('contract-field-energy.buy.normal')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('contract-field-energy.buy.dal')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('contract-field-standing.network_fee')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/energy/contracts in create mode', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
|
||||||
|
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
|
||||||
|
// Use defaultKind to ensure fields load without needing to interact with Select
|
||||||
|
renderWithProviders(
|
||||||
|
<ContractForm defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for form to render
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for profile fields to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-name')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fill in contract name
|
||||||
|
await user.type(screen.getByTestId('contract-name'), 'Test Contract')
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await user.click(screen.getByTestId('contract-form-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/contracts',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
name: 'Test Contract',
|
||||||
|
kind: 'manual',
|
||||||
|
currency: 'EUR',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, { timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
|
||||||
|
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContractForm contractId={1} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for form to be ready (add-version mode)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// In add-version mode, no name or kind fields shown
|
||||||
|
expect(screen.queryByTestId('contract-name')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('contract-kind')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Fill in effective_from (required in add-version mode)
|
||||||
|
const effectiveDateInput = screen.getByTestId('contract-effective-from')
|
||||||
|
await user.type(effectiveDateInput, '2026-07-01')
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await user.click(screen.getByTestId('contract-form-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/contracts/{contract_id}/versions',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { contract_id: 1 } },
|
||||||
|
body: expect.objectContaining({
|
||||||
|
values: expect.any(Object),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, { timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel button is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContractForm onClose={onClose} onSaved={onSaved} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-form-cancel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('contract-form-cancel'))
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* ContractForm — create a new energy contract OR add a version to an existing one.
|
||||||
|
*
|
||||||
|
* Form fields are dynamically rendered from the profile structure returned by
|
||||||
|
* GET /api/energy/profiles — never hardcoded. The profile structure provides
|
||||||
|
* section names (energy, standing, credits), leaf keys, and their units.
|
||||||
|
*
|
||||||
|
* Create mode: show Name, Kind (Select from profiles), Currency, optional
|
||||||
|
* effective_from, then dynamically rendered profile value fields.
|
||||||
|
* Add-version mode (contractId provided): show only effective_from (required)
|
||||||
|
* + profile value fields for the contract's kind.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Text,
|
||||||
|
Divider,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ContractFormProps {
|
||||||
|
/** When provided: add-version mode; contract must exist. */
|
||||||
|
contractId?: number
|
||||||
|
/** Existing contract kind (for add-version mode or edit). */
|
||||||
|
defaultKind?: string
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for dynamic profile field rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leaf in the profile structure is any object with a `unit` key.
|
||||||
|
* Returns true if the value is a leaf (renderable field).
|
||||||
|
*/
|
||||||
|
function isLeaf(val: unknown): val is { unit: string; default?: unknown } {
|
||||||
|
return typeof val === 'object' && val !== null && 'unit' in val
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all leaf fields from a profile section, returning
|
||||||
|
* [dotPath, unit, defaultValue] tuples. E.g.:
|
||||||
|
* "energy.buy.normal" → "EUR/kWh"
|
||||||
|
* "energy.energy_tax" → "EUR/kWh"
|
||||||
|
*/
|
||||||
|
interface LeafField {
|
||||||
|
/** Dot-separated path within the section, e.g. "buy.normal" */
|
||||||
|
fieldPath: string
|
||||||
|
unit: string
|
||||||
|
defaultValue?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField[] {
|
||||||
|
const fields: LeafField[] = []
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
// Skip non-object values (e.g. dual_tariff: true)
|
||||||
|
if (typeof val !== 'object' || val === null) continue
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key
|
||||||
|
if (isLeaf(val)) {
|
||||||
|
fields.push({
|
||||||
|
fieldPath: path,
|
||||||
|
unit: val.unit,
|
||||||
|
defaultValue: typeof val.default === 'number' ? val.default : undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fields.push(...extractLeafFields(val as Record<string, unknown>, path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a nested values object from flat field values.
|
||||||
|
* E.g. { "buy.normal": 0.133, "buy.dal": 0.127 } → { buy: { normal: 0.133, dal: 0.127 } }
|
||||||
|
*/
|
||||||
|
function buildNestedValues(
|
||||||
|
sectionFields: Record<string, LeafField[]>,
|
||||||
|
fieldValues: Record<string, number | string>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
for (const [section, fields] of Object.entries(sectionFields)) {
|
||||||
|
const sectionObj: Record<string, unknown> = {}
|
||||||
|
for (const field of fields) {
|
||||||
|
const raw = fieldValues[`${section}.${field.fieldPath}`]
|
||||||
|
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
|
||||||
|
// Set nested path
|
||||||
|
const parts = field.fieldPath.split('.')
|
||||||
|
let current = sectionObj
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (!(parts[i] in current)) current[parts[i]] = {}
|
||||||
|
current = current[parts[i]] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]] = isNaN(numVal) ? 0 : numVal
|
||||||
|
}
|
||||||
|
result[section] = sectionObj
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label formatter: converts "buy.normal" → "Buy Normal", "energy_tax" → "Energy Tax"
|
||||||
|
*/
|
||||||
|
function formatLabel(path: string): string {
|
||||||
|
return path
|
||||||
|
.replace(/\./g, ' ')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) {
|
||||||
|
const isAddVersion = contractId != null
|
||||||
|
|
||||||
|
// Profiles query
|
||||||
|
const profilesQuery = useEnergyProfiles()
|
||||||
|
|
||||||
|
// Create mode fields
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [selectedKind, setSelectedKind] = useState<string | null>(defaultKind ?? null)
|
||||||
|
const [currency, setCurrency] = useState('EUR')
|
||||||
|
// ISO date string "YYYY-MM-DD" for effective_from; empty = default to now
|
||||||
|
const [effectiveFromStr, setEffectiveFromStr] = useState('')
|
||||||
|
|
||||||
|
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number
|
||||||
|
const [fieldValues, setFieldValues] = useState<Record<string, number | string>>({})
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const createMutation = useCreateContract()
|
||||||
|
const addVersionMutation = useAddContractVersion()
|
||||||
|
|
||||||
|
const isBusy = createMutation.isPending || addVersionMutation.isPending
|
||||||
|
|
||||||
|
// Resolve the effective kind (add-version mode uses defaultKind)
|
||||||
|
const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind
|
||||||
|
|
||||||
|
// Build profile options from API response
|
||||||
|
const profiles = profilesQuery.data?.profiles ?? []
|
||||||
|
const profileOptions = profiles.map((p: Record<string, unknown>) => ({
|
||||||
|
value: p.kind as string,
|
||||||
|
label: (p.label as string | undefined) ?? (p.kind as string),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Find the selected profile structure
|
||||||
|
const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind)
|
||||||
|
|
||||||
|
// Extract section → leaf fields mapping from the selected profile
|
||||||
|
const sectionFields: Record<string, LeafField[]> = {}
|
||||||
|
if (selectedProfile) {
|
||||||
|
const profileObj = selectedProfile as Record<string, unknown>
|
||||||
|
for (const [key, val] of Object.entries(profileObj)) {
|
||||||
|
// Skip metadata fields
|
||||||
|
if (key === 'kind' || key === 'label') continue
|
||||||
|
if (typeof val === 'object' && val !== null && !isLeaf(val)) {
|
||||||
|
sectionFields[key] = extractLeafFields(val as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFieldChange(key: string, val: number | string) {
|
||||||
|
setFieldValues((prev) => ({ ...prev, [key]: val }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize default values when profile changes
|
||||||
|
function initDefaults(kind: string | null) {
|
||||||
|
if (!kind) return
|
||||||
|
const profile = profiles.find((p: Record<string, unknown>) => p.kind === kind)
|
||||||
|
if (!profile) return
|
||||||
|
const profileObj = profile as Record<string, unknown>
|
||||||
|
const defaults: Record<string, number | string> = {}
|
||||||
|
for (const [sectionKey, sectionVal] of Object.entries(profileObj)) {
|
||||||
|
if (sectionKey === 'kind' || sectionKey === 'label') continue
|
||||||
|
if (typeof sectionVal === 'object' && sectionVal !== null && !isLeaf(sectionVal)) {
|
||||||
|
const leaves = extractLeafFields(sectionVal as Record<string, unknown>)
|
||||||
|
for (const leaf of leaves) {
|
||||||
|
defaults[`${sectionKey}.${leaf.fieldPath}`] = leaf.defaultValue ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFieldValues(defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!isAddVersion && !name.trim()) {
|
||||||
|
setError('Contract name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!effectiveKind) {
|
||||||
|
setError('Contract kind is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = buildNestedValues(sectionFields, fieldValues)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert local date string to ISO datetime if provided
|
||||||
|
const effectiveFromISO = effectiveFromStr
|
||||||
|
? new Date(effectiveFromStr).toISOString()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (isAddVersion) {
|
||||||
|
const body = {
|
||||||
|
effective_from: effectiveFromISO ?? new Date().toISOString(),
|
||||||
|
values,
|
||||||
|
}
|
||||||
|
await addVersionMutation.mutateAsync({ id: contractId, body })
|
||||||
|
} else {
|
||||||
|
const body = {
|
||||||
|
name: name.trim(),
|
||||||
|
kind: effectiveKind,
|
||||||
|
currency,
|
||||||
|
values,
|
||||||
|
...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}),
|
||||||
|
}
|
||||||
|
await createMutation.mutateAsync(body)
|
||||||
|
}
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
setError('Failed to save. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const title = isAddVersion ? 'Add Contract Version' : 'New Energy Contract'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
size="md"
|
||||||
|
data-testid="contract-form-modal"
|
||||||
|
>
|
||||||
|
{profilesQuery.isLoading && (
|
||||||
|
<Center py="md">
|
||||||
|
<Loader size="sm" data-testid="contract-form-loading" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profilesQuery.isError && (
|
||||||
|
<Alert color="red" mb="sm" data-testid="contract-form-profiles-error">
|
||||||
|
Failed to load pricing profiles. Cannot continue.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!profilesQuery.isLoading && (
|
||||||
|
<form onSubmit={handleSubmit} data-testid="contract-form">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{/* Create mode fields */}
|
||||||
|
{!isAddVersion && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Contract name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.currentTarget.value)}
|
||||||
|
data-testid="contract-name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Pricing kind"
|
||||||
|
required
|
||||||
|
data={profileOptions}
|
||||||
|
value={selectedKind}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSelectedKind(val)
|
||||||
|
initDefaults(val)
|
||||||
|
}}
|
||||||
|
data-testid="contract-kind"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Currency"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.currentTarget.value)}
|
||||||
|
data-testid="contract-currency"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Effective from — required for add-version, optional for create */}
|
||||||
|
<TextInput
|
||||||
|
label={isAddVersion ? 'Effective from (required)' : 'Effective from (optional, defaults to now)'}
|
||||||
|
description="Date in YYYY-MM-DD format"
|
||||||
|
type="date"
|
||||||
|
required={isAddVersion}
|
||||||
|
value={effectiveFromStr}
|
||||||
|
onChange={(e) => setEffectiveFromStr(e.currentTarget.value)}
|
||||||
|
data-testid="contract-effective-from"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dynamic profile fields */}
|
||||||
|
{effectiveKind && Object.keys(sectionFields).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider mt="xs" />
|
||||||
|
{Object.entries(sectionFields).map(([section, fields]) => (
|
||||||
|
<Stack key={section} gap="xs" data-testid={`contract-section-${section}`}>
|
||||||
|
<Title order={6} tt="capitalize" c="dimmed">
|
||||||
|
{section.replace(/_/g, ' ')}
|
||||||
|
</Title>
|
||||||
|
{fields.map((field) => {
|
||||||
|
const key = `${section}.${field.fieldPath}`
|
||||||
|
const raw = fieldValues[key]
|
||||||
|
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
key={key}
|
||||||
|
label={formatLabel(field.fieldPath)}
|
||||||
|
description={field.unit}
|
||||||
|
value={isNaN(numVal) ? 0 : numVal}
|
||||||
|
onChange={(val) => handleFieldChange(key, val)}
|
||||||
|
decimalScale={6}
|
||||||
|
step={0.001}
|
||||||
|
data-testid={`contract-field-${key}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!effectiveKind && !isAddVersion && (
|
||||||
|
<Text size="sm" c="dimmed" data-testid="contract-form-kind-hint">
|
||||||
|
Select a pricing kind to see the rate fields.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" data-testid="contract-form-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="contract-form-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isBusy}
|
||||||
|
data-testid="contract-form-submit"
|
||||||
|
>
|
||||||
|
{isAddVersion ? 'Add Version' : 'Create Contract'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ContractManager component.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Loading state rendering.
|
||||||
|
* 2. Contract list rendering.
|
||||||
|
* 3. Empty state rendering.
|
||||||
|
* 4. Activate button calls PATCH with {active: true}.
|
||||||
|
* 5. "New Contract" button opens ContractForm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACTIVE_CONTRACT = {
|
||||||
|
id: 1,
|
||||||
|
name: 'My Active Contract',
|
||||||
|
kind: 'manual',
|
||||||
|
active: true,
|
||||||
|
currency: 'EUR',
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const INACTIVE_CONTRACT = {
|
||||||
|
id: 2,
|
||||||
|
name: 'Inactive Contract',
|
||||||
|
kind: 'tibber',
|
||||||
|
active: false,
|
||||||
|
currency: 'EUR',
|
||||||
|
created_at: '2026-06-02T00:00:00Z',
|
||||||
|
updated_at: '2026-06-02T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILES_RESPONSE = {
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'Fixed / Variable Rate (NL, dual-tariff)',
|
||||||
|
energy: {
|
||||||
|
dual_tariff: true,
|
||||||
|
buy: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
|
||||||
|
sell: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
|
||||||
|
energy_tax: { unit: 'EUR/kWh' },
|
||||||
|
ode: { unit: 'EUR/kWh', default: 0 },
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
network_fee: { unit: 'EUR/month' },
|
||||||
|
management_fee: { unit: 'EUR/month' },
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
heffingskorting: { unit: 'EUR/year' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { ContractManager } from './ContractManager'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ContractManager', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders loading state initially', () => {
|
||||||
|
// Never resolve so we stay in loading state
|
||||||
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
|
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('contracts-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders contract list when data loads', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/contracts') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [ACTIVE_CONTRACT, INACTIVE_CONTRACT], total: 2 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contracts-table')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('My Active Contract')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Inactive Contract')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('contract-active-badge-1')).toHaveTextContent('active')
|
||||||
|
expect(screen.getByTestId('contract-active-badge-2')).toHaveTextContent('inactive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty state when no contracts exist', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contracts-empty')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls PATCH with active=true when Activate button clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/contracts') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [INACTIVE_CONTRACT], total: 1 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
mockPatch.mockResolvedValue({
|
||||||
|
data: { ...INACTIVE_CONTRACT, active: true, versions: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`contract-activate-${INACTIVE_CONTRACT.id}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId(`contract-activate-${INACTIVE_CONTRACT.id}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/contracts/{contract_id}',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { contract_id: INACTIVE_CONTRACT.id } },
|
||||||
|
body: { active: true },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens ContractForm when "New Contract" button is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/contracts') {
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-new-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('contract-new-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* ContractManager — energy contract list UI.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Table of contracts: name, kind, active badge, currency, created date, actions.
|
||||||
|
* - Actions per row: Activate (PATCH active=true), Add Version, View History.
|
||||||
|
* - "New Contract" button at top.
|
||||||
|
* - Version history modal showing all versions with effective dates and values.
|
||||||
|
* - Loading / error / empty states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Badge,
|
||||||
|
ScrollArea,
|
||||||
|
Modal,
|
||||||
|
Accordion,
|
||||||
|
Code,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import {
|
||||||
|
useContracts,
|
||||||
|
useUpdateContract,
|
||||||
|
type ContractResponse,
|
||||||
|
type ContractDetailResponse,
|
||||||
|
} from './hooks'
|
||||||
|
import { ContractForm } from './ContractForm'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { formatLocalDate } from '../utils/datetime'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Version history modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface VersionHistoryModalProps {
|
||||||
|
contractId: number
|
||||||
|
contractName: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionHistoryModal({ contractId, contractName, onClose }: VersionHistoryModalProps) {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [detail, setDetail] = useState<ContractDetailResponse | null>(null)
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Fetch detail on first render
|
||||||
|
useState(() => {
|
||||||
|
apiClient
|
||||||
|
.GET('/api/energy/contracts/{contract_id}', {
|
||||||
|
params: { path: { contract_id: contractId } },
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setDetail(res.data ?? null)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFetchError('Failed to load contract details.')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Version history — ${contractName}`}
|
||||||
|
size="lg"
|
||||||
|
data-testid="version-history-modal"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<Center py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
{fetchError && (
|
||||||
|
<Alert color="red" data-testid="version-history-error">
|
||||||
|
{fetchError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!loading && !fetchError && detail && (
|
||||||
|
<>
|
||||||
|
{detail.versions.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" size="sm" data-testid="version-history-empty">
|
||||||
|
No versions found.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Accordion variant="separated" data-testid="version-history-accordion">
|
||||||
|
{detail.versions.map((v) => (
|
||||||
|
<Accordion.Item key={v.id} value={String(v.id)} data-testid={`version-item-${v.id}`}>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
From: {formatLocalDate(v.effective_from)}
|
||||||
|
</Text>
|
||||||
|
{v.effective_to ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
To: {formatLocalDate(v.effective_to)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Badge color="green" size="xs">
|
||||||
|
current
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Code block style={{ fontSize: 11 }}>
|
||||||
|
{JSON.stringify(v.values, null, 2)}
|
||||||
|
</Code>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contract table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ContractTableProps {
|
||||||
|
contracts: ContractResponse[]
|
||||||
|
onActivate: (id: number) => void
|
||||||
|
onAddVersion: (contract: ContractResponse) => void
|
||||||
|
onViewHistory: (contract: ContractResponse) => void
|
||||||
|
activatingId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContractTable({
|
||||||
|
contracts,
|
||||||
|
onActivate,
|
||||||
|
onAddVersion,
|
||||||
|
onViewHistory,
|
||||||
|
activatingId,
|
||||||
|
}: ContractTableProps) {
|
||||||
|
if (contracts.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" size="sm" data-testid="contracts-empty">
|
||||||
|
No contracts configured yet. Click "New Contract" to add one.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid="contracts-table">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Kind</Table.Th>
|
||||||
|
<Table.Th>Active</Table.Th>
|
||||||
|
<Table.Th>Currency</Table.Th>
|
||||||
|
<Table.Th>Created</Table.Th>
|
||||||
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{contracts.map((contract) => (
|
||||||
|
<Table.Tr key={contract.id} data-testid={`contract-row-${contract.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{contract.name}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{contract.kind}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
color={contract.active ? 'green' : 'gray'}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
data-testid={`contract-active-badge-${contract.id}`}
|
||||||
|
>
|
||||||
|
{contract.active ? 'active' : 'inactive'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{contract.currency}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatLocalDate(contract.created_at)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
{!contract.active && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="green"
|
||||||
|
loading={activatingId === contract.id}
|
||||||
|
onClick={() => onActivate(contract.id)}
|
||||||
|
data-testid={`contract-activate-${contract.id}`}
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onAddVersion(contract)}
|
||||||
|
data-testid={`contract-add-version-${contract.id}`}
|
||||||
|
>
|
||||||
|
Add Version
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => onViewHistory(contract)}
|
||||||
|
data-testid={`contract-history-${contract.id}`}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ContractManager — top-level
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ContractManager() {
|
||||||
|
const contractsQuery = useContracts()
|
||||||
|
const updateMutation = useUpdateContract()
|
||||||
|
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [addVersionContract, setAddVersionContract] = useState<ContractResponse | null>(null)
|
||||||
|
const [historyContract, setHistoryContract] = useState<ContractResponse | null>(null)
|
||||||
|
const [activatingId, setActivatingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
async function handleActivate(id: number) {
|
||||||
|
setActivatingId(id)
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({ id, body: { active: true } })
|
||||||
|
} finally {
|
||||||
|
setActivatingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render states
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (contractsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Center py="xl" data-testid="contracts-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contractsQuery.isError || !contractsQuery.data) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid="contracts-load-error">
|
||||||
|
Failed to load contracts. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contracts = contractsQuery.data.items
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" data-testid="contract-manager">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fw={500}>Energy Contracts</Text>
|
||||||
|
<Button onClick={() => setShowCreateForm(true)} data-testid="contract-new-button">
|
||||||
|
New Contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ContractTable
|
||||||
|
contracts={contracts}
|
||||||
|
onActivate={handleActivate}
|
||||||
|
onAddVersion={(c) => setAddVersionContract(c)}
|
||||||
|
onViewHistory={(c) => setHistoryContract(c)}
|
||||||
|
activatingId={activatingId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create new contract */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<ContractForm
|
||||||
|
onClose={() => setShowCreateForm(false)}
|
||||||
|
onSaved={() => setShowCreateForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add version to existing contract */}
|
||||||
|
{addVersionContract && (
|
||||||
|
<ContractForm
|
||||||
|
contractId={addVersionContract.id}
|
||||||
|
defaultKind={addVersionContract.kind}
|
||||||
|
onClose={() => setAddVersionContract(null)}
|
||||||
|
onSaved={() => setAddVersionContract(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version history modal */}
|
||||||
|
{historyContract && (
|
||||||
|
<VersionHistoryModal
|
||||||
|
contractId={historyContract.id}
|
||||||
|
contractName={historyContract.name}
|
||||||
|
onClose={() => setHistoryContract(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Tests for CostView component.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Loading state.
|
||||||
|
* 2. Empty state.
|
||||||
|
* 3. Renders costs table with data.
|
||||||
|
* 4. Shows summary values.
|
||||||
|
* 5. Recompute button triggers confirmation modal and then recompute call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const COST_PERIOD = {
|
||||||
|
period_start: '2026-06-22T10:00:00Z',
|
||||||
|
d1_kwh: 0.1,
|
||||||
|
d2_kwh: 0.2,
|
||||||
|
r1_kwh: 0.0,
|
||||||
|
r2_kwh: 0.05,
|
||||||
|
import_cost: 0.044,
|
||||||
|
export_revenue: 0.005,
|
||||||
|
net_cost: 0.039,
|
||||||
|
currency: 'EUR',
|
||||||
|
degraded: false,
|
||||||
|
contract_version_id: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY = {
|
||||||
|
currency: 'EUR',
|
||||||
|
metered_import: 10.5,
|
||||||
|
metered_export: 2.3,
|
||||||
|
metered_net: 8.2,
|
||||||
|
fixed_costs: 5.0,
|
||||||
|
credits: 50.0,
|
||||||
|
total_payable: 12.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { CostView } from './CostView'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('CostView', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders loading state initially', () => {
|
||||||
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
|
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('costs-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty state when no cost periods available', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/costs') {
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/costs/summary') {
|
||||||
|
return Promise.resolve({ data: SUMMARY })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('costs-empty')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders costs table when data is available', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/costs') {
|
||||||
|
return Promise.resolve({ data: { items: [COST_PERIOD], total: 1 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/costs/summary') {
|
||||||
|
return Promise.resolve({ data: SUMMARY })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('costs-table')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('cost-row-0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows summary values correctly', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/costs') {
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/costs/summary') {
|
||||||
|
return Promise.resolve({ data: SUMMARY })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500')
|
||||||
|
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300')
|
||||||
|
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows recompute confirmation modal on button click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/costs') {
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/costs/summary') {
|
||||||
|
return Promise.resolve({ data: SUMMARY })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('cost-recompute-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('recompute-confirm-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls recompute mutation when confirmed', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/costs') {
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/costs/summary') {
|
||||||
|
return Promise.resolve({ data: SUMMARY })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
|
||||||
|
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('cost-recompute-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('recompute-confirm')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('recompute-confirm'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/costs/recompute',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* CostView — cost trends + detail + summary.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Date range selector: Today / This month / Custom (SegmentedControl + DateInput).
|
||||||
|
* - Recharts AreaChart showing import_cost, export_revenue, net_cost per period.
|
||||||
|
* - Table showing per-15min periods with time, kWh values, costs, degraded badge.
|
||||||
|
* - Summary cards: metered import/export, fixed costs, credits, total payable.
|
||||||
|
* - "Recompute" button with confirmation.
|
||||||
|
* - Loading/error/empty states, degraded period highlighting.
|
||||||
|
*
|
||||||
|
* Recharts imports are isolated to this file only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
Table,
|
||||||
|
Group,
|
||||||
|
Badge,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Modal,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
|
||||||
|
import { formatLocalTime } from '../utils/datetime'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cost limit — prevent accidental full-table pulls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const COSTS_MAX_LIMIT = 500
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date range helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getTodayRange(): { start: string; end: string } {
|
||||||
|
// Use local date boundary so "today" matches what the user sees in the display.
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
|
return { start: start.toISOString(), end: end.toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThisMonthRange(): { start: string; end: string } {
|
||||||
|
// Use local date boundary so "this month" matches what the user sees in the display.
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||||
|
return { start: start.toISOString(), end: end.toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Summary cards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
testId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, testId }: SummaryCardProps) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="sm" data-testid={testId}>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CostView — main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type RangePreset = 'today' | 'month' | 'custom'
|
||||||
|
|
||||||
|
export function CostView() {
|
||||||
|
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
|
||||||
|
// Date strings in YYYY-MM-DD format for custom range
|
||||||
|
const [customStartStr, setCustomStartStr] = useState('')
|
||||||
|
const [customEndStr, setCustomEndStr] = useState('')
|
||||||
|
const [showRecomputeConfirm, setShowRecomputeConfirm] = useState(false)
|
||||||
|
|
||||||
|
// Compute effective date range
|
||||||
|
const { start, end } = (() => {
|
||||||
|
if (rangePreset === 'today') return getTodayRange()
|
||||||
|
if (rangePreset === 'month') return getThisMonthRange()
|
||||||
|
return {
|
||||||
|
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
|
||||||
|
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const costsQuery = useEnergyCosts(start, end, COSTS_MAX_LIMIT)
|
||||||
|
const summaryQuery = useEnergyCostSummary(start, end)
|
||||||
|
const recomputeMutation = useRecomputeCosts()
|
||||||
|
|
||||||
|
async function handleRecompute() {
|
||||||
|
setShowRecomputeConfirm(false)
|
||||||
|
await recomputeMutation.mutateAsync({ start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const currency = costsQuery.data?.items[0]?.currency ?? summaryQuery.data?.currency ?? 'EUR'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" data-testid="cost-view">
|
||||||
|
{/* Date range selector */}
|
||||||
|
<Group align="flex-start" gap="md" wrap="wrap">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Date range
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={rangePreset}
|
||||||
|
onChange={(v) => setRangePreset(v as RangePreset)}
|
||||||
|
data={[
|
||||||
|
{ label: 'Today', value: 'today' },
|
||||||
|
{ label: 'This month', value: 'month' },
|
||||||
|
{ label: 'Custom', value: 'custom' },
|
||||||
|
]}
|
||||||
|
data-testid="cost-range-control"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{rangePreset === 'custom' && (
|
||||||
|
<Group gap="sm" align="flex-end">
|
||||||
|
<TextInput
|
||||||
|
label="From"
|
||||||
|
type="date"
|
||||||
|
value={customStartStr}
|
||||||
|
onChange={(e) => setCustomStartStr(e.currentTarget.value)}
|
||||||
|
data-testid="cost-custom-start"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="To"
|
||||||
|
type="date"
|
||||||
|
value={customEndStr}
|
||||||
|
onChange={(e) => setCustomEndStr(e.currentTarget.value)}
|
||||||
|
data-testid="cost-custom-end"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="orange"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRecomputeConfirm(true)}
|
||||||
|
loading={recomputeMutation.isPending}
|
||||||
|
data-testid="cost-recompute-button"
|
||||||
|
>
|
||||||
|
Recompute
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
{summaryQuery.isLoading && (
|
||||||
|
<Center>
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summaryQuery.isError && (
|
||||||
|
<Alert color="red" data-testid="summary-error">
|
||||||
|
Failed to load cost summary.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summaryQuery.data && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={6} c="dimmed">
|
||||||
|
Summary
|
||||||
|
</Title>
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
|
||||||
|
<SummaryCard
|
||||||
|
label="Import (kWh)"
|
||||||
|
value={summaryQuery.data.metered_import.toFixed(3)}
|
||||||
|
testId="summary-import"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Export (kWh)"
|
||||||
|
value={summaryQuery.data.metered_export.toFixed(3)}
|
||||||
|
testId="summary-export"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label={`Fixed costs (${currency})`}
|
||||||
|
value={summaryQuery.data.fixed_costs.toFixed(2)}
|
||||||
|
testId="summary-fixed"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label={`Credits (${currency})`}
|
||||||
|
value={summaryQuery.data.credits.toFixed(2)}
|
||||||
|
testId="summary-credits"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label={`Total payable (${currency})`}
|
||||||
|
value={summaryQuery.data.total_payable.toFixed(2)}
|
||||||
|
testId="summary-total"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cost chart */}
|
||||||
|
{costsQuery.isLoading && (
|
||||||
|
<Center py="xl" data-testid="costs-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{costsQuery.isError && (
|
||||||
|
<Alert color="red" data-testid="costs-error">
|
||||||
|
Failed to load cost periods. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{costsQuery.data && costsQuery.data.items.length === 0 && (
|
||||||
|
<Alert color="gray" data-testid="costs-empty">
|
||||||
|
No cost data available for the selected period.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{costsQuery.data && costsQuery.data.items.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Area chart */}
|
||||||
|
<Stack gap="xs" data-testid="cost-chart">
|
||||||
|
<Title order={6} c="dimmed">
|
||||||
|
Cost trends ({currency})
|
||||||
|
</Title>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<AreaChart
|
||||||
|
data={costsQuery.data.items.map((item) => ({
|
||||||
|
time: formatLocalTime(item.period_start),
|
||||||
|
import_cost: item.import_cost,
|
||||||
|
export_revenue: item.export_revenue,
|
||||||
|
net_cost: item.net_cost,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 4, right: 16, left: 0, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v: number) => v.toFixed(2)} />
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(val: any) =>
|
||||||
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="import_cost"
|
||||||
|
stroke="#2196f3"
|
||||||
|
fill="#bbdefb"
|
||||||
|
name="Import cost"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="export_revenue"
|
||||||
|
stroke="#4caf50"
|
||||||
|
fill="#c8e6c9"
|
||||||
|
name="Export revenue"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="net_cost"
|
||||||
|
stroke="#ff9800"
|
||||||
|
fill="#ffe0b2"
|
||||||
|
name="Net cost"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Detail table */}
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={6} c="dimmed">
|
||||||
|
Period detail
|
||||||
|
</Title>
|
||||||
|
<ScrollArea>
|
||||||
|
<Table
|
||||||
|
striped
|
||||||
|
highlightOnHover
|
||||||
|
withTableBorder
|
||||||
|
withColumnBorders
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
data-testid="costs-table"
|
||||||
|
>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Time</Table.Th>
|
||||||
|
<Table.Th>Import kWh</Table.Th>
|
||||||
|
<Table.Th>Export kWh</Table.Th>
|
||||||
|
<Table.Th>Import cost</Table.Th>
|
||||||
|
<Table.Th>Export rev.</Table.Th>
|
||||||
|
<Table.Th>Net cost</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{costsQuery.data.items.map((item, idx) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={idx}
|
||||||
|
style={item.degraded ? { opacity: 0.6 } : undefined}
|
||||||
|
data-testid={`cost-row-${idx}`}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">
|
||||||
|
{formatLocalTime(item.period_start)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td>
|
||||||
|
<Table.Td>{(item.r1_kwh + item.r2_kwh).toFixed(3)}</Table.Td>
|
||||||
|
<Table.Td>{item.import_cost.toFixed(4)}</Table.Td>
|
||||||
|
<Table.Td>{item.export_revenue.toFixed(4)}</Table.Td>
|
||||||
|
<Table.Td>{item.net_cost.toFixed(4)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{item.degraded && (
|
||||||
|
<Badge color="orange" size="xs" data-testid={`cost-degraded-${idx}`}>
|
||||||
|
degraded
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recompute confirmation modal */}
|
||||||
|
{showRecomputeConfirm && (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={() => setShowRecomputeConfirm(false)}
|
||||||
|
title="Recompute costs?"
|
||||||
|
size="sm"
|
||||||
|
data-testid="recompute-confirm-modal"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
This will recompute all cost periods for the selected date range. Continue?
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setShowRecomputeConfirm(false)}
|
||||||
|
data-testid="recompute-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
onClick={handleRecompute}
|
||||||
|
data-testid="recompute-confirm"
|
||||||
|
>
|
||||||
|
Recompute
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DsmrPanel — latest parsed DSMR telegram view.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Loading state.
|
||||||
|
* 2. Empty state (found=false) with a helpful hint.
|
||||||
|
* 3. Renders the payload as a key/value table; null values shown as "—".
|
||||||
|
* 4. Error state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: vi.fn(),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { DsmrPanel } from './DsmrPanel'
|
||||||
|
|
||||||
|
describe('DsmrPanel', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders loading state initially', () => {
|
||||||
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
|
renderWithProviders(<DsmrPanel />)
|
||||||
|
expect(screen.getByTestId('dsmr-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty hint when no DSMR data has been ingested', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { found: false, recorded_at: null, payload: null } })
|
||||||
|
renderWithProviders(<DsmrPanel />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('dsmr-table')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the latest telegram as a key/value table; null shown as dash', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
found: true,
|
||||||
|
recorded_at: '2026-06-23T12:16:00Z',
|
||||||
|
payload: {
|
||||||
|
electricity_delivered_1: '20915.154',
|
||||||
|
phase_voltage_l2: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
renderWithProviders(<DsmrPanel />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('dsmr-table')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Field rows present (keys are sorted).
|
||||||
|
expect(screen.getByTestId('dsmr-row-electricity_delivered_1')).toHaveTextContent('20915.154')
|
||||||
|
// Null phase value rendered as an em dash, not omitted.
|
||||||
|
expect(screen.getByTestId('dsmr-row-phase_voltage_l2')).toHaveTextContent('—')
|
||||||
|
expect(screen.getByTestId('dsmr-recorded-at')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an error state when the request fails', async () => {
|
||||||
|
mockGet.mockRejectedValue(new Error('network down'))
|
||||||
|
renderWithProviders(<DsmrPanel />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('dsmr-error')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* DsmrPanel — shows the latest parsed DSMR telegram (the source of our metering data).
|
||||||
|
*
|
||||||
|
* This is the "ground truth" view: it fetches GET /api/energy/dsmr/latest and
|
||||||
|
* renders the full parsed frame as a key/value table, so you can confirm at a
|
||||||
|
* glance whether DSMR ingest is actually landing data (without reading the DB).
|
||||||
|
*
|
||||||
|
* - Auto-refresh toggle (default on) so new telegrams appear without a manual reload.
|
||||||
|
* - Loading / error / empty states; "empty" explains the likely cause.
|
||||||
|
* - Null phase values are shown as "—" (kept verbatim in the payload).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
ScrollArea,
|
||||||
|
Switch,
|
||||||
|
Divider,
|
||||||
|
Badge,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useDsmrLatest } from './hooks'
|
||||||
|
import { formatLocalDateTime } from '../utils/datetime'
|
||||||
|
|
||||||
|
/** Default auto-refresh interval (ms) — DSMR ingest stores ~one row per 10 s. */
|
||||||
|
const AUTO_REFRESH_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '—'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DsmrPanel() {
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
|
const latestQuery = useDsmrLatest(
|
||||||
|
autoRefresh ? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS } : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" data-testid="dsmr-panel">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<div>
|
||||||
|
<Text fw={600}>Latest DSMR reading</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
The most recent parsed telegram persisted to <code>dsmr_reading</code>.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label="Auto-refresh"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.currentTarget.checked)}
|
||||||
|
size="sm"
|
||||||
|
data-testid="dsmr-auto-refresh-switch"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<DsmrContent
|
||||||
|
isLoading={latestQuery.isLoading}
|
||||||
|
isError={latestQuery.isError}
|
||||||
|
data={latestQuery.data}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DsmrContentProps {
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
data:
|
||||||
|
| { found: boolean; recorded_at?: string | null; payload?: Record<string, unknown> | null }
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center py="xl" data-testid="dsmr-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid="dsmr-error">
|
||||||
|
Failed to load the latest DSMR reading.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.found || !data.payload) {
|
||||||
|
return (
|
||||||
|
<Alert color="gray" data-testid="dsmr-empty">
|
||||||
|
No DSMR data yet. Enable <strong>DSMR ingest</strong> in Config, make sure MQTT
|
||||||
|
is connected, and confirm the DSMR Reader is publishing to the configured topic
|
||||||
|
(default <code>dsmr/json</code>). Rows are stored about once every 10 seconds.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = data.payload
|
||||||
|
const keys = Object.keys(payload).sort()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm" data-testid="dsmr-data">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color="teal" variant="light">
|
||||||
|
{keys.length} fields
|
||||||
|
</Badge>
|
||||||
|
{data.recorded_at && (
|
||||||
|
<Text size="sm" c="dimmed" data-testid="dsmr-recorded-at">
|
||||||
|
recorded at {formatLocalDateTime(data.recorded_at)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder>
|
||||||
|
<ScrollArea.Autosize mah={480}>
|
||||||
|
<Table striped highlightOnHover withColumnBorders data-testid="dsmr-table">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Field</Table.Th>
|
||||||
|
<Table.Th>Value</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<Table.Tr key={key} data-testid={`dsmr-row-${key}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" ff="monospace">
|
||||||
|
{key}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{formatValue(payload[key])}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
import { useReadings, useMetrics } from './hooks'
|
import { useReadings, useMetrics } from './hooks'
|
||||||
import type { MetricInfo, AutoRefreshOptions } from './hooks'
|
import type { MetricInfo, AutoRefreshOptions } from './hooks'
|
||||||
import { formatMetricValue } from './format'
|
import { formatMetricValue } from './format'
|
||||||
|
import { formatLocalTime, formatLocalDateTime } from '../utils/datetime'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -107,12 +108,7 @@ function lineColor(index: number): string {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function formatTimeTick(isoString: string): string {
|
function formatTimeTick(isoString: string): string {
|
||||||
try {
|
return formatLocalTime(isoString)
|
||||||
const d = new Date(isoString)
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
} catch {
|
|
||||||
return isoString
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -300,11 +296,7 @@ export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProp
|
|||||||
] as [string, string]
|
] as [string, string]
|
||||||
}}
|
}}
|
||||||
labelFormatter={(label) => {
|
labelFormatter={(label) => {
|
||||||
try {
|
return formatLocalDateTime(String(label))
|
||||||
return new Date(String(label)).toLocaleString()
|
|
||||||
} catch {
|
|
||||||
return String(label)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Tests for TibberPrices component.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Loading state.
|
||||||
|
* 2. Empty state (no active contract / no kind).
|
||||||
|
* 3. Renders tibber chart when tibber kind data is available.
|
||||||
|
* 4. Shows tariff table for manual kind.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: vi.fn(),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { TibberPrices } from './TibberPrices'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('TibberPrices', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders loading state initially', () => {
|
||||||
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('prices-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "no active contract" when kind is missing/null', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
kind: null,
|
||||||
|
currency: 'EUR',
|
||||||
|
points: [],
|
||||||
|
tariff: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('prices-no-contract')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders error state when fetch fails', async () => {
|
||||||
|
mockGet.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('prices-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders tibber chart when tibber data is available', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
kind: 'tibber',
|
||||||
|
currency: 'EUR',
|
||||||
|
points: [
|
||||||
|
{ starts_at: '2026-06-22T10:00:00Z', buy: 0.133, sell: 0.09, level: 'NORMAL' },
|
||||||
|
{ starts_at: '2026-06-22T10:15:00Z', buy: 0.140, sell: 0.092, level: 'NORMAL' },
|
||||||
|
],
|
||||||
|
tariff: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-chart')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check badge shows kind
|
||||||
|
expect(screen.getByText('tibber')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows tariff table for manual kind', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
kind: 'manual',
|
||||||
|
currency: 'EUR',
|
||||||
|
points: [],
|
||||||
|
tariff: {
|
||||||
|
buy_dal: 0.127,
|
||||||
|
buy_normal: 0.133,
|
||||||
|
sell_dal: 0.09,
|
||||||
|
sell_normal: 0.09,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tariff-buy-normal')).toHaveTextContent('0.1330')
|
||||||
|
expect(screen.getByTestId('tariff-buy-dal')).toHaveTextContent('0.1270')
|
||||||
|
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
||||||
|
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* TibberPrices — price curve visualization.
|
||||||
|
*
|
||||||
|
* - Fetches today + tomorrow price range using useEnergyPrices.
|
||||||
|
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
|
||||||
|
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
|
||||||
|
* - Handles: no active contract, empty data, loading, error.
|
||||||
|
*
|
||||||
|
* Recharts imports are isolated to this file only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
Table,
|
||||||
|
Title,
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { useEnergyPrices } from './hooks'
|
||||||
|
import { formatLocalTime } from '../utils/datetime'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Time range helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getTodayStart(): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCHours(0, 0, 0, 0)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTomorrowEnd(): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCHours(0, 0, 0, 0)
|
||||||
|
d.setUTCDate(d.getUTCDate() + 2)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tibber chart
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TibberChartProps {
|
||||||
|
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function TibberChart({ points, currency }: TibberChartProps) {
|
||||||
|
const data = points.map((p) => ({
|
||||||
|
time: formatLocalTime(p.starts_at),
|
||||||
|
buy: p.buy,
|
||||||
|
sell: p.sell,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" data-testid="tibber-chart">
|
||||||
|
<Title order={6} c="dimmed">
|
||||||
|
Price curve ({currency})
|
||||||
|
</Title>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
tickFormatter={(v: number) => v.toFixed(3)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(val: any) =>
|
||||||
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="buy"
|
||||||
|
stroke="#2196f3"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Buy"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="sell"
|
||||||
|
stroke="#4caf50"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Sell"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Manual tariff table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ManualTariffTableProps {
|
||||||
|
tariff: {
|
||||||
|
buy_dal: number
|
||||||
|
buy_normal: number
|
||||||
|
sell_dal: number
|
||||||
|
sell_normal: number
|
||||||
|
}
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" data-testid="manual-tariff-table">
|
||||||
|
<Title order={6} c="dimmed">
|
||||||
|
Fixed tariff ({currency}/kWh)
|
||||||
|
</Title>
|
||||||
|
<Table withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Tariff</Table.Th>
|
||||||
|
<Table.Th>Buy</Table.Th>
|
||||||
|
<Table.Th>Sell</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>Normal (peak)</Table.Td>
|
||||||
|
<Table.Td data-testid="tariff-buy-normal">{tariff.buy_normal.toFixed(4)}</Table.Td>
|
||||||
|
<Table.Td data-testid="tariff-sell-normal">{tariff.sell_normal.toFixed(4)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>Dal (off-peak)</Table.Td>
|
||||||
|
<Table.Td data-testid="tariff-buy-dal">{tariff.buy_dal.toFixed(4)}</Table.Td>
|
||||||
|
<Table.Td data-testid="tariff-sell-dal">{tariff.sell_dal.toFixed(4)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TibberPrices — main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function TibberPrices() {
|
||||||
|
const start = getTodayStart()
|
||||||
|
const end = getTomorrowEnd()
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useEnergyPrices(start, end)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center py="xl" data-testid="prices-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid="prices-error">
|
||||||
|
Failed to load energy prices. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Alert color="gray" data-testid="prices-no-data">
|
||||||
|
No pricing data available.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active contract
|
||||||
|
if (!data.kind) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" data-testid="prices-no-contract">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw={500}>No active contract</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Activate an energy contract on the Contracts tab to see pricing data.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = data.currency
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" data-testid="tibber-prices">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Text fw={500}>Energy Prices</Text>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{data.kind}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{data.kind === 'tibber' && data.points && data.points.length > 0 && (
|
||||||
|
<TibberChart points={data.points} currency={currency} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.kind === 'tibber' && (!data.points || data.points.length === 0) && (
|
||||||
|
<Alert color="yellow" data-testid="tibber-no-prices">
|
||||||
|
No Tibber price points available for the selected time range. Check Tibber configuration.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.kind === 'manual' && data.tariff && (
|
||||||
|
<ManualTariffTable tariff={data.tariff} currency={currency} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.kind === 'manual' && !data.tariff && (
|
||||||
|
<Alert color="yellow" data-testid="manual-no-tariff">
|
||||||
|
Manual tariff data not available.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy hooks in hooks.ts — energy pricing API wrappers.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. useContracts — GET /api/energy/contracts
|
||||||
|
* 2. useCreateContract — POST /api/energy/contracts
|
||||||
|
* 3. useEnergyPrices — GET /api/energy/prices
|
||||||
|
* 4. useEnergyCosts — GET /api/energy/costs
|
||||||
|
* 5. useEnergyCostSummary — GET /api/energy/costs/summary
|
||||||
|
* 6. useRecomputeCosts — POST /api/energy/costs/recompute
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CONTRACT = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Contract',
|
||||||
|
kind: 'manual',
|
||||||
|
active: true,
|
||||||
|
currency: 'EUR',
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRICE_POINT = {
|
||||||
|
starts_at: '2026-06-22T10:00:00Z',
|
||||||
|
buy: 0.133,
|
||||||
|
sell: 0.09,
|
||||||
|
level: 'NORMAL',
|
||||||
|
}
|
||||||
|
|
||||||
|
const COST_PERIOD = {
|
||||||
|
period_start: '2026-06-22T10:00:00Z',
|
||||||
|
d1_kwh: 0.1,
|
||||||
|
d2_kwh: 0.2,
|
||||||
|
r1_kwh: 0.0,
|
||||||
|
r2_kwh: 0.05,
|
||||||
|
import_cost: 0.044,
|
||||||
|
export_revenue: 0.005,
|
||||||
|
net_cost: 0.039,
|
||||||
|
currency: 'EUR',
|
||||||
|
degraded: false,
|
||||||
|
contract_version_id: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY = {
|
||||||
|
currency: 'EUR',
|
||||||
|
metered_import: 10.5,
|
||||||
|
metered_export: 2.3,
|
||||||
|
metered_net: 8.2,
|
||||||
|
fixed_costs: 5.0,
|
||||||
|
credits: 50.0,
|
||||||
|
total_payable: 12.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeWrapper() {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
return { qc, Wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('useContracts', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/energy/contracts and returns contract list', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [CONTRACT], total: 1 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useContracts } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useContracts(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/contracts')
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
expect(result.current.data?.items[0].id).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCreateContract', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls POST /api/energy/contracts with the provided body', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: { ...CONTRACT, versions: [] } })
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useCreateContract } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useCreateContract(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: 'New Contract',
|
||||||
|
kind: 'manual',
|
||||||
|
currency: 'EUR',
|
||||||
|
values: { energy: { buy: { normal: 0.133 } } },
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', { body })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useEnergyPrices', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/energy/prices with start and end params', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
kind: 'tibber',
|
||||||
|
currency: 'EUR',
|
||||||
|
points: [PRICE_POINT],
|
||||||
|
tariff: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useEnergyPrices } = await import('./hooks')
|
||||||
|
const start = '2026-06-22T00:00:00Z'
|
||||||
|
const end = '2026-06-23T00:00:00Z'
|
||||||
|
const { result } = renderHook(() => useEnergyPrices(start, end), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', {
|
||||||
|
params: { query: { start, end } },
|
||||||
|
})
|
||||||
|
expect(result.current.data?.points).toHaveLength(1)
|
||||||
|
expect(result.current.data?.kind).toBe('tibber')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls GET /api/energy/prices without params when none provided', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { kind: 'manual', currency: 'EUR', points: [], tariff: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useEnergyPrices } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useEnergyPrices(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', {
|
||||||
|
params: { query: {} },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useEnergyCosts', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/energy/costs with start, end, and limit', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { items: [COST_PERIOD], total: 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useEnergyCosts } = await import('./hooks')
|
||||||
|
const start = '2026-06-22T00:00:00Z'
|
||||||
|
const end = '2026-06-23T00:00:00Z'
|
||||||
|
const limit = 100
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useEnergyCosts(start, end, limit),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/costs', {
|
||||||
|
params: { query: { start, end, limit } },
|
||||||
|
})
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useEnergyCostSummary', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/energy/costs/summary with date range', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: SUMMARY })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useEnergyCostSummary } = await import('./hooks')
|
||||||
|
const start = '2026-06-01T00:00:00Z'
|
||||||
|
const end = '2026-06-30T23:59:59Z'
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useEnergyCostSummary(start, end),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/costs/summary', {
|
||||||
|
params: { query: { start, end } },
|
||||||
|
})
|
||||||
|
expect(result.current.data?.total_payable).toBe(12.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useRecomputeCosts', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls POST /api/energy/costs/recompute and invalidates cost queries', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: { recomputed: 10 } })
|
||||||
|
// Mock GET for invalidation queries (costs and summary)
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useRecomputeCosts } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useRecomputeCosts(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
start: '2026-06-22T00:00:00Z',
|
||||||
|
end: '2026-06-23T00:00:00Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/costs/recompute', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
start: '2026-06-22T00:00:00Z',
|
||||||
|
end: '2026-06-23T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/energy/costs/recompute without params when none provided', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useRecomputeCosts } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useRecomputeCosts(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/costs/recompute', {
|
||||||
|
params: { query: {} },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -173,7 +173,7 @@ describe('useUpdateDevice', () => {
|
|||||||
describe('useDeleteDevice', () => {
|
describe('useDeleteDevice', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
it('calls DELETE /api/modbus/devices/{uuid}', async () => {
|
it('calls DELETE /api/modbus/devices/{uuid} without query param when cascade omitted', async () => {
|
||||||
mockDelete.mockResolvedValue({ data: null })
|
mockDelete.mockResolvedValue({ data: null })
|
||||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
@@ -182,13 +182,32 @@ describe('useDeleteDevice', () => {
|
|||||||
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
|
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.mutateAsync('test-uuid-1')
|
await result.current.mutateAsync({ uuid: 'test-uuid-1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
params: { path: { uuid: 'test-uuid-1' } },
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calls DELETE /api/modbus/devices/{uuid} with cascade=true in query when cascade=true', async () => {
|
||||||
|
mockDelete.mockResolvedValue({
|
||||||
|
data: { deleted: true, readings_deleted: 5, toggles_deleted: 2 },
|
||||||
|
})
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useDeleteDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({ uuid: 'test-uuid-1', cascade: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' }, query: { cascade: true } },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useTestReadDevice', () => {
|
describe('useTestReadDevice', () => {
|
||||||
|
|||||||
@@ -115,12 +115,21 @@ export function useUpdateDevice() {
|
|||||||
// Mutation: delete device
|
// Mutation: delete device
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DeleteDeviceParams {
|
||||||
|
uuid: string
|
||||||
|
/** When true, perform a cascade delete (removes readings + expose toggles). */
|
||||||
|
cascade?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteDevice() {
|
export function useDeleteDevice() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (uuid: string) =>
|
mutationFn: ({ uuid, cascade }: DeleteDeviceParams) =>
|
||||||
apiClient.DELETE('/api/modbus/devices/{uuid}', {
|
apiClient.DELETE('/api/modbus/devices/{uuid}', {
|
||||||
params: { path: { uuid } },
|
params: {
|
||||||
|
path: { uuid },
|
||||||
|
...(cascade ? { query: { cascade: true } } : {}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
})
|
})
|
||||||
@@ -191,6 +200,240 @@ export function useMetrics(uuid: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Energy / Pricing hooks — typed TanStack Query wrappers for /api/energy/*.
|
||||||
|
//
|
||||||
|
// Query-key conventions:
|
||||||
|
// ['energy-contracts'] — contract list
|
||||||
|
// ['energy-contract', id] — single contract with versions
|
||||||
|
// ['energy-profiles'] — pricing profile structures
|
||||||
|
// ['energy-prices', start, end] — price curve
|
||||||
|
// ['energy-costs', start, end, limit] — cost periods
|
||||||
|
// ['energy-costs-summary', start, end] — summary
|
||||||
|
// ['dsmr-latest'] — DSMR latest
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// Re-exported energy types for consumers
|
||||||
|
export type ContractResponse = components['schemas']['ContractResponse']
|
||||||
|
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
|
||||||
|
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
|
||||||
|
export type ContractListResponse = components['schemas']['ContractListResponse']
|
||||||
|
export type ContractCreate = components['schemas']['ContractCreate']
|
||||||
|
export type ContractPatch = components['schemas']['ContractPatch']
|
||||||
|
export type VersionCreate = components['schemas']['VersionCreate']
|
||||||
|
export type ProfilesResponse = components['schemas']['ProfilesResponse']
|
||||||
|
export type PricesResponse = components['schemas']['PricesResponse']
|
||||||
|
export type PricePointSchema = components['schemas']['PricePointSchema']
|
||||||
|
export type ManualTariffSchema = components['schemas']['ManualTariffSchema']
|
||||||
|
export type CostsResponse = components['schemas']['CostsResponse']
|
||||||
|
export type CostPeriodSchema = components['schemas']['CostPeriodSchema']
|
||||||
|
export type SummaryResponse = components['schemas']['SummaryResponse']
|
||||||
|
export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse']
|
||||||
|
export type TibberTestResponse = components['schemas']['TibberTestResponse']
|
||||||
|
export type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: list all energy contracts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useContracts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-contracts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/contracts')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: single contract with full version history
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useContractDetail(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-contract', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/contracts/{contract_id}', {
|
||||||
|
params: { path: { contract_id: id } },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: energy pricing profile structures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useEnergyProfiles() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-profiles'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/profiles')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: create energy contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useCreateContract() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ContractCreate) =>
|
||||||
|
apiClient.POST('/api/energy/contracts', { body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: update (PATCH) energy contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useUpdateContract() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: ContractPatch }) =>
|
||||||
|
apiClient.PATCH('/api/energy/contracts/{contract_id}', {
|
||||||
|
params: { path: { contract_id: id } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: add a new version to an existing contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useAddContractVersion() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: VersionCreate }) =>
|
||||||
|
apiClient.POST('/api/energy/contracts/{contract_id}/versions', {
|
||||||
|
params: { path: { contract_id: id } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-contracts'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-contract', vars.id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: energy price curve (Tibber 15-min / manual tariff)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useEnergyPrices(start?: string, end?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-prices', start, end],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/prices', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
...(start ? { start } : {}),
|
||||||
|
...(end ? { end } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: cost periods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useEnergyCosts(start?: string, end?: string, limit?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-costs', start, end, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/costs', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
...(start ? { start } : {}),
|
||||||
|
...(end ? { end } : {}),
|
||||||
|
...(limit != null ? { limit } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: cost summary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useEnergyCostSummary(start?: string, end?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-costs-summary', start, end],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/costs/summary', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
...(start ? { start } : {}),
|
||||||
|
...(end ? { end } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: DSMR latest reading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useDsmrLatest(options?: AutoRefreshOptions) {
|
||||||
|
const refetchInterval = options?.refetchIntervalMs != null
|
||||||
|
? Math.max(2_000, options.refetchIntervalMs)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dsmr-latest'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/dsmr/latest')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
refetchInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: recompute costs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useRecomputeCosts() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ start, end }: { start?: string; end?: string } = {}) => {
|
||||||
|
// The schema marks start/end as required, but the backend accepts them as
|
||||||
|
// optional query params; we spread only defined values.
|
||||||
|
const query = {
|
||||||
|
...(start ? { start } : {}),
|
||||||
|
...(end ? { end } : {}),
|
||||||
|
} as { start: string; end: string }
|
||||||
|
return apiClient.POST('/api/energy/costs/recompute', {
|
||||||
|
params: { query },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query: time-range readings for a device (window + limit — never full-table)
|
// Query: time-range readings for a device (window + limit — never full-table)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Tibber test button in ConfigPage.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Tibber test button appears when Tibber section is in config.
|
||||||
|
* 2. Success tri-state shows green alert.
|
||||||
|
* 3. Config-error tri-state shows orange alert.
|
||||||
|
* 4. Failed tri-state shows red alert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PUT: (...args: unknown[]) => mockPut(...args),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
DELETE: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CONFIG_WITH_TIBBER = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
name: 'Tibber',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
env_name: 'TIBBER_TOKEN',
|
||||||
|
label: 'Tibber API Token',
|
||||||
|
secret: true,
|
||||||
|
input_type: 'text',
|
||||||
|
configured: true,
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { ConfigPage } from './ConfigPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper to suppress TOTP and Expose Settings sub-queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function mockConfigDependencies() {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/config') {
|
||||||
|
return Promise.resolve({ data: CONFIG_WITH_TIBBER })
|
||||||
|
}
|
||||||
|
if (path === '/api/expose') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
catalog: [],
|
||||||
|
mqtt_status: { connected: false, broker: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/auth/totp/status') {
|
||||||
|
return Promise.resolve({ data: { enabled: false } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ConfigPage — Tibber test button', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders Tibber test button when Tibber section is present', async () => {
|
||||||
|
mockConfigDependencies()
|
||||||
|
|
||||||
|
renderWithProviders(<ConfigPage />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success alert on successful Tibber test', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockConfigDependencies()
|
||||||
|
|
||||||
|
mockPost.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/tibber/test') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
result: 'success',
|
||||||
|
message: 'Connected to Tibber API',
|
||||||
|
price: {
|
||||||
|
starts_at: '2026-06-22T10:00:00Z',
|
||||||
|
total: 0.1337,
|
||||||
|
energy: 0.08,
|
||||||
|
tax: 0.0537,
|
||||||
|
currency: 'EUR',
|
||||||
|
level: 'NORMAL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<ConfigPage />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('tibber-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-result-success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tibber-result-success')).toHaveTextContent(
|
||||||
|
'Tibber connection successful',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows config-error alert when Tibber is misconfigured', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockConfigDependencies()
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ApiErrorClass = (await import('../api/client')).ApiError as any
|
||||||
|
mockPost.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/tibber/test') {
|
||||||
|
throw new ApiErrorClass(400, {
|
||||||
|
result: 'config-error',
|
||||||
|
message: 'Tibber token not configured',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<ConfigPage />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('tibber-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-result-config-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failed alert when Tibber test fails', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockConfigDependencies()
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ApiErrorClass = (await import('../api/client')).ApiError as any
|
||||||
|
mockPost.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/tibber/test') {
|
||||||
|
throw new ApiErrorClass(500, {
|
||||||
|
result: 'failed',
|
||||||
|
message: 'Connection refused',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<ConfigPage />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('tibber-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-result-failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -49,6 +49,7 @@ import { TotpSettings } from './TotpSettings'
|
|||||||
|
|
||||||
type ConfigField = components['schemas']['ConfigField']
|
type ConfigField = components['schemas']['ConfigField']
|
||||||
type ConfigSection = components['schemas']['ConfigSection']
|
type ConfigSection = components['schemas']['ConfigSection']
|
||||||
|
type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
|
||||||
|
|
||||||
/** SMTP test result tri-state. */
|
/** SMTP test result tri-state. */
|
||||||
type SmtpResult =
|
type SmtpResult =
|
||||||
@@ -64,6 +65,13 @@ type MqttResult =
|
|||||||
| { kind: 'failed'; message: string }
|
| { kind: 'failed'; message: string }
|
||||||
| null
|
| null
|
||||||
|
|
||||||
|
/** Tibber test result tri-state. */
|
||||||
|
type TibberResult =
|
||||||
|
| { kind: 'success'; message: string; price?: TibberTestPriceSchema | null }
|
||||||
|
| { kind: 'config-error'; message: string }
|
||||||
|
| { kind: 'failed'; message: string }
|
||||||
|
| null
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hook: load config
|
// Hook: load config
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -339,6 +347,85 @@ function MqttTestButton({ mqttResult, setMqttResult }: MqttTestButtonProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TibberTestButton — sends POST /api/energy/tibber/test and displays tri-state result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TibberTestButtonProps {
|
||||||
|
tibberResult: TibberResult
|
||||||
|
setTibberResult: (r: TibberResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TibberTestButton({ tibberResult, setTibberResult }: TibberTestButtonProps) {
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setTibberResult(null)
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.POST('/api/energy/tibber/test')
|
||||||
|
if (res.data) {
|
||||||
|
setTibberResult({
|
||||||
|
kind: 'success',
|
||||||
|
message: res.data.message,
|
||||||
|
price: res.data.price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const body = err.body as { result?: string; message?: string } | null
|
||||||
|
const result = body?.result
|
||||||
|
const message = body?.message ?? 'Unknown error'
|
||||||
|
if (result === 'config-error') {
|
||||||
|
setTibberResult({ kind: 'config-error', message })
|
||||||
|
} else {
|
||||||
|
setTibberResult({ kind: 'failed', message })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTibberResult({ kind: 'failed', message: 'Unexpected error during Tibber test.' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
loading={testing}
|
||||||
|
data-testid="tibber-test-button"
|
||||||
|
>
|
||||||
|
Test Tibber Connection
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{tibberResult?.kind === 'success' && (
|
||||||
|
<Alert color="green" data-testid="tibber-result-success">
|
||||||
|
Tibber connection successful. {tibberResult.message}
|
||||||
|
{tibberResult.price && (
|
||||||
|
<Text size="xs" mt={4}>
|
||||||
|
Current price: {tibberResult.price.total} {tibberResult.price.currency}/kWh
|
||||||
|
{tibberResult.price.level ? ` (${tibberResult.price.level})` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{tibberResult?.kind === 'config-error' && (
|
||||||
|
<Alert color="orange" data-testid="tibber-result-config-error">
|
||||||
|
Tibber configuration error — check your Tibber API token. {tibberResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{tibberResult?.kind === 'failed' && (
|
||||||
|
<Alert color="red" data-testid="tibber-result-failed">
|
||||||
|
Tibber test failed. {tibberResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ConfigPage — main component
|
// ConfigPage — main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -374,6 +461,9 @@ export function ConfigPage() {
|
|||||||
// MQTT test tri-state
|
// MQTT test tri-state
|
||||||
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
|
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
|
||||||
|
|
||||||
|
// Tibber test tri-state
|
||||||
|
const [tibberResult, setTibberResult] = useState<TibberResult>(null)
|
||||||
|
|
||||||
function handleChange(envName: string, value: string) {
|
function handleChange(envName: string, value: string) {
|
||||||
setLocalValues((prev) => ({ ...prev, [envName]: value }))
|
setLocalValues((prev) => ({ ...prev, [envName]: value }))
|
||||||
setSaveStatus(null)
|
setSaveStatus(null)
|
||||||
@@ -432,6 +522,9 @@ export function ConfigPage() {
|
|||||||
// Detect if there is an MQTT section (to show the MQTT test button).
|
// Detect if there is an MQTT section (to show the MQTT test button).
|
||||||
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
|
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
|
||||||
|
|
||||||
|
// Detect if there is a Tibber section (to show the Tibber test button).
|
||||||
|
const hasTibberSection = data.sections.some((s) => s.name.toLowerCase() === 'tibber')
|
||||||
|
|
||||||
// Default: open the first section so users immediately see content.
|
// Default: open the first section so users immediately see content.
|
||||||
const defaultAccordionValue = data.sections[0]?.name ?? null
|
const defaultAccordionValue = data.sections[0]?.name ?? null
|
||||||
|
|
||||||
@@ -512,6 +605,9 @@ export function ConfigPage() {
|
|||||||
{hasMqttSection && (
|
{hasMqttSection && (
|
||||||
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
|
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
|
||||||
)}
|
)}
|
||||||
|
{hasTibberSection && (
|
||||||
|
<TibberTestButton tibberResult={tibberResult} setTibberResult={setTibberResult} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ describe('EnergyPage — delete device', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||||
|
// On first open (no 409 yet), the regular confirm button is visible.
|
||||||
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||||
@@ -295,6 +296,38 @@ describe('EnergyPage — delete device', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
|
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
// After 409, the force-delete button appears instead of the normal confirm.
|
||||||
|
expect(screen.getByTestId('device-delete-force')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('device-delete-confirm')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('force-delete button triggers cascade delete after 409', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
// First call → 409, second call (cascade) → success
|
||||||
|
mockDelete
|
||||||
|
.mockRejectedValueOnce(new ApiError(409, { detail: 'device has readings' }))
|
||||||
|
.mockResolvedValueOnce({ data: { deleted: true, readings_deleted: 3, toggles_deleted: 1 } })
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// First attempt → 409
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||||
|
|
||||||
|
// Force-delete button appears
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-delete-force')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Click force-delete → should call DELETE with cascade=true
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-force'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: DEVICE.uuid }, query: { cascade: true } },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
||||||
* missing payload keys.
|
* missing payload keys.
|
||||||
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
|
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
|
||||||
|
*
|
||||||
|
* M6-T10: added Tabs layout with Devices / Contracts / Prices / Costs tabs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -34,13 +36,19 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Divider,
|
Divider,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
||||||
import { DeviceForm } from '../energy/DeviceForm'
|
import { DeviceForm } from '../energy/DeviceForm'
|
||||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||||
|
import { ContractManager } from '../energy/ContractManager'
|
||||||
|
import { TibberPrices } from '../energy/TibberPrices'
|
||||||
|
import { CostView } from '../energy/CostView'
|
||||||
|
import { DsmrPanel } from '../energy/DsmrPanel'
|
||||||
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { formatMetricValue } from '../energy/format'
|
import { formatMetricValue } from '../energy/format'
|
||||||
|
import { formatLocalDateTime } from '../utils/datetime'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Delete confirmation modal (with 409 "disable instead" hint)
|
// Delete confirmation modal (with 409 "disable instead" hint)
|
||||||
@@ -49,13 +57,22 @@ import { formatMetricValue } from '../energy/format'
|
|||||||
interface ConfirmDeleteProps {
|
interface ConfirmDeleteProps {
|
||||||
device: ModbusDevice
|
device: ModbusDevice
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
|
/** Called when the user explicitly opts into cascade (force) deletion. */
|
||||||
|
onForceDelete: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
loading: boolean
|
loading: boolean
|
||||||
/** Set when the delete failed with 409. */
|
/** Set when the delete failed with 409. */
|
||||||
has409Error: boolean
|
has409Error: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
|
function ConfirmDeleteModal({
|
||||||
|
device,
|
||||||
|
onConfirm,
|
||||||
|
onForceDelete,
|
||||||
|
onCancel,
|
||||||
|
loading,
|
||||||
|
has409Error,
|
||||||
|
}: ConfirmDeleteProps) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened
|
opened
|
||||||
@@ -70,16 +87,24 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{has409Error && (
|
{has409Error && (
|
||||||
|
<>
|
||||||
<Alert color="orange" data-testid="device-delete-409-hint">
|
<Alert color="orange" data-testid="device-delete-409-hint">
|
||||||
This device has existing readings and cannot be deleted. Consider{' '}
|
This device has existing readings and cannot be deleted. Consider{' '}
|
||||||
<strong>disabling</strong> it instead (click Edit → uncheck Enabled).
|
<strong>disabling</strong> it instead (click Edit → uncheck Enabled).
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<Alert color="red" variant="light" data-testid="device-delete-force-warning">
|
||||||
|
Alternatively, you can <strong>permanently delete</strong> the device together with{' '}
|
||||||
|
all its historical readings and expose settings. This action{' '}
|
||||||
|
<strong>cannot be undone</strong>.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
|
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
{!has409Error && (
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -88,6 +113,17 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{has409Error && (
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={loading}
|
||||||
|
onClick={onForceDelete}
|
||||||
|
data-testid="device-delete-force"
|
||||||
|
>
|
||||||
|
Force Delete (all data)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -304,7 +340,7 @@ function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
{latest.recorded_at && (
|
{latest.recorded_at && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{new Date(latest.recorded_at).toLocaleString()}
|
{formatLocalDateTime(latest.recorded_at)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -488,10 +524,10 @@ function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// EnergyPage — top-level
|
// DevicesTab — self-contained devices management tab (extracted from original EnergyPage)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function EnergyPage() {
|
function DevicesTab() {
|
||||||
const devicesQuery = useDevices()
|
const devicesQuery = useDevices()
|
||||||
const deleteMutation = useDeleteDevice()
|
const deleteMutation = useDeleteDevice()
|
||||||
|
|
||||||
@@ -524,7 +560,7 @@ export function EnergyPage() {
|
|||||||
if (!deleteDevice) return
|
if (!deleteDevice) return
|
||||||
setDelete409(false)
|
setDelete409(false)
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(deleteDevice.uuid)
|
await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid })
|
||||||
closeDelete()
|
closeDelete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
@@ -534,9 +570,16 @@ export function EnergyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
async function handleForceDelete() {
|
||||||
// Loading / error states
|
if (!deleteDevice) return
|
||||||
// ---------------------------------------------------------------------------
|
try {
|
||||||
|
await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid, cascade: true })
|
||||||
|
closeDelete()
|
||||||
|
} catch {
|
||||||
|
// If cascade delete also fails for some reason, keep the modal open.
|
||||||
|
// (Should be very rare — only if the device was concurrently deleted.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (devicesQuery.isLoading) {
|
if (devicesQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -548,22 +591,15 @@ export function EnergyPage() {
|
|||||||
|
|
||||||
if (devicesQuery.isError || !devicesQuery.data) {
|
if (devicesQuery.isError || !devicesQuery.data) {
|
||||||
return (
|
return (
|
||||||
<Container size="xl" pt="xl">
|
|
||||||
<Alert color="red" data-testid="energy-load-error">
|
<Alert color="red" data-testid="energy-load-error">
|
||||||
Failed to load devices. Please refresh.
|
Failed to load devices. Please refresh.
|
||||||
</Alert>
|
</Alert>
|
||||||
</Container>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = devicesQuery.data.items
|
const devices = devicesQuery.data.items
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main render
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title order={2}>Energy — Devices</Title>
|
<Title order={2}>Energy — Devices</Title>
|
||||||
@@ -580,7 +616,6 @@ export function EnergyPage() {
|
|||||||
|
|
||||||
{/* Latest readings cards + trend charts (T07) */}
|
{/* Latest readings cards + trend charts (T07) */}
|
||||||
<DeviceReadingsSection devices={devices} />
|
<DeviceReadingsSection devices={devices} />
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Create form */}
|
{/* Create form */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
@@ -604,11 +639,62 @@ export function EnergyPage() {
|
|||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
device={deleteDevice}
|
device={deleteDevice}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onForceDelete={handleForceDelete}
|
||||||
onCancel={closeDelete}
|
onCancel={closeDelete}
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
has409Error={delete409}
|
has409Error={delete409}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnergyPage — top-level with Tabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EnergyPage() {
|
||||||
|
return (
|
||||||
|
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
||||||
|
<Tabs defaultValue="devices">
|
||||||
|
<Tabs.List mb="lg">
|
||||||
|
<Tabs.Tab value="devices" data-testid="tab-devices">
|
||||||
|
Devices
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
||||||
|
Contracts
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="prices" data-testid="tab-prices">
|
||||||
|
Prices
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="costs" data-testid="tab-costs">
|
||||||
|
Costs
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="dsmr" data-testid="tab-dsmr">
|
||||||
|
DSMR
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="devices" data-testid="panel-devices">
|
||||||
|
<DevicesTab />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
||||||
|
<ContractManager />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="prices" data-testid="panel-prices">
|
||||||
|
<TibberPrices />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="costs" data-testid="panel-costs">
|
||||||
|
<CostView />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="dsmr" data-testid="panel-dsmr">
|
||||||
|
<DsmrPanel />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type { PooRecord, LocationRecord } from '../records'
|
|||||||
import { useDevices, useMetrics, useReadings } from '../energy/hooks'
|
import { useDevices, useMetrics, useReadings } from '../energy/hooks'
|
||||||
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
||||||
import { formatMetricValue } from '../energy/format'
|
import { formatMetricValue } from '../energy/format'
|
||||||
|
import { formatLocalDateTime } from '../utils/datetime'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -359,13 +360,9 @@ interface MeterReadingsTableProps {
|
|||||||
device: ModbusDevice
|
device: ModbusDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format an ISO timestamp for display. */
|
/** Format an ISO timestamp for display (local timezone, 24-hour). */
|
||||||
function formatRecordedAt(iso: string): string {
|
function formatRecordedAt(iso: string): string {
|
||||||
try {
|
return formatLocalDateTime(iso)
|
||||||
return new Date(iso).toLocaleString()
|
|
||||||
} catch {
|
|
||||||
return iso
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeterReadingsTable({ device }: MeterReadingsTableProps) {
|
function MeterReadingsTable({ device }: MeterReadingsTableProps) {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/utils/datetime.ts
|
||||||
|
*
|
||||||
|
* Key invariants verified (timezone-agnostic where possible):
|
||||||
|
* 1. A tz-less string is parsed identically to the same string with "Z" appended
|
||||||
|
* (i.e. treated as UTC regardless of the machine's local timezone).
|
||||||
|
* 2. Formatted output never contains AM / PM / am / pm (always 24-hour).
|
||||||
|
* 3. Strings that already carry a timezone marker are NOT double-offset.
|
||||||
|
* 4. Invalid / empty input does not throw — returns original string or safe fallback.
|
||||||
|
* 5. Pure date strings (no T) are handled without crashing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
parseBackendTimestamp,
|
||||||
|
formatLocalDateTime,
|
||||||
|
formatLocalTime,
|
||||||
|
formatLocalDate,
|
||||||
|
} from './datetime'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseBackendTimestamp — core UTC-fallback logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('parseBackendTimestamp', () => {
|
||||||
|
it('returns the same Date for tz-less and Z-suffixed strings (UTC equivalence)', () => {
|
||||||
|
const noTz = parseBackendTimestamp('2026-06-24T10:27:00')
|
||||||
|
const withZ = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||||||
|
// Both must represent the exact same UTC instant.
|
||||||
|
expect(noTz.getTime()).toBe(withZ.getTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not double-offset a string that already has Z', () => {
|
||||||
|
const d1 = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||||||
|
const d2 = parseBackendTimestamp('2026-06-24T10:27:00')
|
||||||
|
expect(d1.getTime()).toBe(d2.getTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('correctly parses a string with +HH:MM offset', () => {
|
||||||
|
// 2026-06-24T12:27:00+02:00 == 2026-06-24T10:27:00Z
|
||||||
|
const withOffset = parseBackendTimestamp('2026-06-24T12:27:00+02:00')
|
||||||
|
const utc = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||||||
|
expect(withOffset.getTime()).toBe(utc.getTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('correctly parses a string with -HH:MM offset', () => {
|
||||||
|
// 2026-06-24T02:27:00-08:00 == 2026-06-24T10:27:00Z
|
||||||
|
const withOffset = parseBackendTimestamp('2026-06-24T02:27:00-08:00')
|
||||||
|
const utc = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||||||
|
expect(withOffset.getTime()).toBe(utc.getTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles pure date strings (no T) without crashing', () => {
|
||||||
|
const d = parseBackendTimestamp('2026-06-24')
|
||||||
|
expect(isNaN(d.getTime())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns Invalid Date for empty string', () => {
|
||||||
|
const d = parseBackendTimestamp('')
|
||||||
|
expect(isNaN(d.getTime())).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns Invalid Date for garbage input', () => {
|
||||||
|
const d = parseBackendTimestamp('not-a-date')
|
||||||
|
expect(isNaN(d.getTime())).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatLocalDateTime — no AM/PM, 24-hour
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatLocalDateTime', () => {
|
||||||
|
it('produces the same output for tz-less and Z-suffixed strings (UTC equivalence)', () => {
|
||||||
|
const noTz = formatLocalDateTime('2026-06-24T10:27:00')
|
||||||
|
const withZ = formatLocalDateTime('2026-06-24T10:27:00Z')
|
||||||
|
// Both should format to the same local representation.
|
||||||
|
expect(noTz).toBe(withZ)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not contain AM or PM (24-hour enforcement)', () => {
|
||||||
|
// Use noon UTC — a time where AM/PM distinction matters.
|
||||||
|
const result = formatLocalDateTime('2026-06-24T14:30:00Z')
|
||||||
|
expect(result).not.toMatch(/[Aa][Mm]|[Pp][Mm]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw or return undefined for empty string', () => {
|
||||||
|
const result = formatLocalDateTime('')
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw or return undefined for invalid string', () => {
|
||||||
|
const result = formatLocalDateTime('not-a-date')
|
||||||
|
// Should return the original string as a safe fallback.
|
||||||
|
expect(result).toBe('not-a-date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a non-empty string for a valid timestamp', () => {
|
||||||
|
const result = formatLocalDateTime('2026-06-24T10:27:00Z')
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatLocalTime — compact HH:mm, no AM/PM
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatLocalTime', () => {
|
||||||
|
it('produces the same output for tz-less and Z-suffixed strings', () => {
|
||||||
|
const noTz = formatLocalTime('2026-06-24T10:27:00')
|
||||||
|
const withZ = formatLocalTime('2026-06-24T10:27:00Z')
|
||||||
|
expect(noTz).toBe(withZ)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not contain AM or PM', () => {
|
||||||
|
const result = formatLocalTime('2026-06-24T14:30:00Z')
|
||||||
|
expect(result).not.toMatch(/[Aa][Mm]|[Pp][Mm]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a non-empty string for a valid timestamp', () => {
|
||||||
|
const result = formatLocalTime('2026-06-24T10:27:00Z')
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw for empty string', () => {
|
||||||
|
expect(() => formatLocalTime('')).not.toThrow()
|
||||||
|
expect(formatLocalTime('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw for invalid string', () => {
|
||||||
|
expect(() => formatLocalTime('bad')).not.toThrow()
|
||||||
|
expect(formatLocalTime('bad')).toBe('bad')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatLocalDate — date only
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatLocalDate', () => {
|
||||||
|
it('produces the same output for tz-less and Z-suffixed strings', () => {
|
||||||
|
// Pick a time that won't cross a date boundary in any timezone
|
||||||
|
// (noon UTC — safe for UTC-12 through UTC+14).
|
||||||
|
const noTz = formatLocalDate('2026-06-24T12:00:00')
|
||||||
|
const withZ = formatLocalDate('2026-06-24T12:00:00Z')
|
||||||
|
expect(noTz).toBe(withZ)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles pure date strings without crashing', () => {
|
||||||
|
expect(() => formatLocalDate('2026-06-24')).not.toThrow()
|
||||||
|
const result = formatLocalDate('2026-06-24')
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw for empty string', () => {
|
||||||
|
expect(() => formatLocalDate('')).not.toThrow()
|
||||||
|
expect(formatLocalDate('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw for invalid string', () => {
|
||||||
|
expect(() => formatLocalDate('not-a-date')).not.toThrow()
|
||||||
|
expect(formatLocalDate('not-a-date')).toBe('not-a-date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a non-empty string for a valid date', () => {
|
||||||
|
const result = formatLocalDate('2026-06-01T00:00:00Z')
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* datetime.ts — shared time formatting utilities for Energy/Modbus displays.
|
||||||
|
*
|
||||||
|
* Root cause addressed: the backend stores timestamps as naive ISO strings without
|
||||||
|
* timezone info (e.g. "2026-06-24T10:27:00", no Z/offset). Browsers parse these as
|
||||||
|
* *local* time by default, but the values are actually UTC. We fix this by treating
|
||||||
|
* any string that lacks a timezone marker as UTC (appending "Z" before parsing).
|
||||||
|
*
|
||||||
|
* Scope: Energy domain + Modbus meter readings ONLY.
|
||||||
|
* Do NOT use these for Location/Poo timestamps — those already use raw strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timezone-marker detection regex.
|
||||||
|
* Matches a trailing Z (any case) or a UTC offset like +05:30, -08:00, +0530, -0800.
|
||||||
|
*/
|
||||||
|
const TZ_MARKER_RE = /([zZ]|[+-]\d{2}:?\d{2})$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a backend ISO timestamp, treating strings without a timezone marker as UTC.
|
||||||
|
*
|
||||||
|
* - If the string already has Z / +HH:MM / -HHMM → parse as-is.
|
||||||
|
* - If no timezone marker → append "Z" so the browser reads it as UTC.
|
||||||
|
* - Pure date strings like "2026-06-24" (no 'T') → parse as-is (Date handles them).
|
||||||
|
* - Invalid / empty input → returns an Invalid Date (does NOT throw).
|
||||||
|
*/
|
||||||
|
export function parseBackendTimestamp(iso: string): Date {
|
||||||
|
if (!iso || typeof iso !== 'string') return new Date(NaN)
|
||||||
|
|
||||||
|
const hasTComponent = iso.includes('T')
|
||||||
|
|
||||||
|
if (hasTComponent && !TZ_MARKER_RE.test(iso)) {
|
||||||
|
// No timezone marker on a datetime string — treat as UTC.
|
||||||
|
return new Date(iso + 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Locale formatting helpers
|
||||||
|
// All use the browser's local timezone (no explicit timeZone option) and
|
||||||
|
// 24-hour clock (hour12: false).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local date + time, 24-hour, e.g. "6/24/2026, 12:27:00".
|
||||||
|
* Used for: LatestReadingsCard recorded_at, DSMR recorded_at, chart tooltips,
|
||||||
|
* meter readings table (RecordsPage), cost period detail table.
|
||||||
|
*/
|
||||||
|
export function formatLocalDateTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = parseBackendTimestamp(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
hour12: false,
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local time only, compact HH:mm (24-hour), e.g. "12:27".
|
||||||
|
* Used for: chart X-axis ticks, cost chart X-axis, Tibber price chart X-axis.
|
||||||
|
*/
|
||||||
|
export function formatLocalTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = parseBackendTimestamp(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local date only, e.g. "6/1/2026" or "2026-06-01" depending on locale.
|
||||||
|
* Used for: contract effective_from / effective_to dates, contract created_at.
|
||||||
|
*/
|
||||||
|
export function formatLocalDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = parseBackendTimestamp(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
+1406
-1
File diff suppressed because it is too large
Load Diff
+1199
-3
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260622_10_exposed_entities"
|
APP_BASELINE_REVISION = "20260624_12_dsmr_decouple_telegram_id"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -187,6 +187,28 @@ def test_put_config_with_csrf_header_updates_app_name(
|
|||||||
assert app_name_field["value"] == "Updated via API"
|
assert app_name_field["value"] == "Updated via API"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_reapplies_dsmr_subscription(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Saving config must re-apply the DSMR subscription so enabling DSMR ingest
|
||||||
|
takes effect without an app restart (the route calls apply_dsmr_subscription
|
||||||
|
with the refreshed settings)."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
payload = _full_config_payload({"DSMR_INGEST_ENABLED": "true"})
|
||||||
|
with patch("app.services.dsmr_ingest.apply_dsmr_subscription") as spy:
|
||||||
|
response = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload},
|
||||||
|
headers={"X-CSRF-Token": "any-non-empty-value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
spy.assert_called_once()
|
||||||
|
applied_settings = spy.call_args.args[0]
|
||||||
|
assert applied_settings.dsmr_ingest_enabled is True
|
||||||
|
|
||||||
|
|
||||||
def test_put_config_blank_secret_keeps_existing_value(
|
def test_put_config_blank_secret_keeps_existing_value(
|
||||||
client: TestClient, test_database_urls
|
client: TestClient, test_database_urls
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -572,6 +594,7 @@ EXPECTED_CHECKBOX_FIELDS = {
|
|||||||
"MQTT_TLS_ENABLED",
|
"MQTT_TLS_ENABLED",
|
||||||
"HA_DISCOVERY_ENABLED",
|
"HA_DISCOVERY_ENABLED",
|
||||||
"MODBUS_POLLING_ENABLED",
|
"MODBUS_POLLING_ENABLED",
|
||||||
|
"DSMR_INGEST_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -703,6 +726,259 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M6-T02: DSMR + Tibber CONFIG_FIELDS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_includes_dsmr_section(client: TestClient) -> None:
|
||||||
|
"""GET /api/config must include a DSMR section with expected fields including DSMR_TARIFF_TOPIC."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
section_names = {s["name"] for s in body["sections"]}
|
||||||
|
assert "DSMR" in section_names, f"DSMR section missing; got {section_names}"
|
||||||
|
|
||||||
|
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||||
|
env_names = {f["env_name"] for f in dsmr_section["fields"]}
|
||||||
|
assert "DSMR_INGEST_ENABLED" in env_names
|
||||||
|
assert "DSMR_MQTT_TOPIC" in env_names
|
||||||
|
assert "DSMR_SAMPLE_INTERVAL_S" in env_names
|
||||||
|
assert "DSMR_TARIFF_TOPIC" in env_names, (
|
||||||
|
f"DSMR_TARIFF_TOPIC must be present in DSMR section; got {env_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_includes_tibber_section(client: TestClient) -> None:
|
||||||
|
"""GET /api/config must include a Tibber section with expected fields."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
section_names = {s["name"] for s in body["sections"]}
|
||||||
|
assert "Tibber" in section_names, f"Tibber section missing; got {section_names}"
|
||||||
|
|
||||||
|
tibber_section = next(s for s in body["sections"] if s["name"] == "Tibber")
|
||||||
|
env_names = {f["env_name"] for f in tibber_section["fields"]}
|
||||||
|
assert "TIBBER_API_TOKEN" in env_names
|
||||||
|
assert "TIBBER_HOME_ID" in env_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_tibber_api_token_is_secret(client: TestClient) -> None:
|
||||||
|
"""TIBBER_API_TOKEN must be marked secret and return empty string in GET response."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
tibber_section = next(s for s in body["sections"] if s["name"] == "Tibber")
|
||||||
|
token_field = next(f for f in tibber_section["fields"] if f["env_name"] == "TIBBER_API_TOKEN")
|
||||||
|
|
||||||
|
assert token_field["secret"] is True
|
||||||
|
assert token_field["value"] == "", (
|
||||||
|
f"TIBBER_API_TOKEN should be masked (empty string), got {token_field['value']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_dsmr_ingest_enabled_is_checkbox(client: TestClient) -> None:
|
||||||
|
"""DSMR_INGEST_ENABLED must have input_type='checkbox' for correct frontend rendering."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||||
|
enabled_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_INGEST_ENABLED")
|
||||||
|
|
||||||
|
assert enabled_field["input_type"] == "checkbox", (
|
||||||
|
f"DSMR_INGEST_ENABLED input_type should be 'checkbox', got {enabled_field['input_type']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_dsmr_sample_interval_input_type_is_number(client: TestClient) -> None:
|
||||||
|
"""DSMR_SAMPLE_INTERVAL_S must have input_type='number'."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||||
|
interval_field = next(
|
||||||
|
f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_SAMPLE_INTERVAL_S"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert interval_field["input_type"] == "number", (
|
||||||
|
f"DSMR_SAMPLE_INTERVAL_S input_type should be 'number', got {interval_field['input_type']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_blank_tibber_api_token_keeps_existing(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Submitting blank TIBBER_API_TOKEN must NOT overwrite the stored secret."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Store a secret via full PUT
|
||||||
|
payload_with_secret = _full_config_payload({"TIBBER_API_TOKEN": "tibber-secret-token"})
|
||||||
|
resp1 = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_with_secret},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200, f"First PUT failed: {resp1.status_code} {resp1.text}"
|
||||||
|
|
||||||
|
# PUT with blank for that secret (keep-old semantics)
|
||||||
|
payload_blank_secret = _full_config_payload({"TIBBER_API_TOKEN": ""})
|
||||||
|
resp2 = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_blank_secret},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200, f"Second PUT failed: {resp2.status_code} {resp2.text}"
|
||||||
|
|
||||||
|
# The stored value in the DB should still be the original secret
|
||||||
|
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||||
|
try:
|
||||||
|
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert rows.get("TIBBER_API_TOKEN") == "tibber-secret-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_new_tibber_api_token_overwrites_existing(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Submitting a non-blank TIBBER_API_TOKEN must overwrite the stored secret."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Store initial secret
|
||||||
|
payload_initial = _full_config_payload({"TIBBER_API_TOKEN": "old-tibber-token"})
|
||||||
|
resp1 = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_initial},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
|
||||||
|
# Overwrite with a new non-blank secret
|
||||||
|
payload_new = _full_config_payload({"TIBBER_API_TOKEN": "new-tibber-token"})
|
||||||
|
resp2 = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_new},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
|
||||||
|
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||||
|
try:
|
||||||
|
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert rows.get("TIBBER_API_TOKEN") == "new-tibber-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_invalid_dsmr_sample_interval_returns_422_and_does_not_write(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Non-integer DSMR_SAMPLE_INTERVAL_S must return 422 and not persist the bad value."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
payload = _full_config_payload({"DSMR_SAMPLE_INTERVAL_S": "not-a-number"})
|
||||||
|
response = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||||
|
try:
|
||||||
|
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert rows.get("DSMR_SAMPLE_INTERVAL_S") != "not-a-number"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_dsmr_tariff_topic_persists_and_reflects_in_get(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""DSMR_TARIFF_TOPIC must persist via PUT and be readable via GET /api/config."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
new_topic = "meter/tariff/slot"
|
||||||
|
payload = _full_config_payload({"DSMR_TARIFF_TOPIC": new_topic})
|
||||||
|
with patch("app.services.dsmr_ingest.apply_dsmr_subscription"):
|
||||||
|
response = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# The updated value must appear in the GET response.
|
||||||
|
get_resp = client.get("/api/config")
|
||||||
|
body = get_resp.json()
|
||||||
|
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||||
|
tariff_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_TARIFF_TOPIC")
|
||||||
|
assert tariff_field["value"] == new_topic, (
|
||||||
|
f"Expected DSMR_TARIFF_TOPIC to be {new_topic!r}, got {tariff_field['value']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_dsmr_tariff_topic_in_settings_payload(client: TestClient) -> None:
|
||||||
|
"""DSMR_TARIFF_TOPIC must appear in _settings_payload (GET /api/config returns it)."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# The default value from Settings must appear in the DSMR section.
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||||
|
tariff_field = next(
|
||||||
|
(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_TARIFF_TOPIC"), None
|
||||||
|
)
|
||||||
|
assert tariff_field is not None, "DSMR_TARIFF_TOPIC must appear in DSMR config section"
|
||||||
|
# Default value should be the DSMR reader meter-stats topic.
|
||||||
|
assert tariff_field["value"] == "dsmr/meter-stats/electricity_tariff", (
|
||||||
|
f"Unexpected default for DSMR_TARIFF_TOPIC: {tariff_field['value']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_tibber_api_token_value_masked_after_save(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""After saving a TIBBER_API_TOKEN, the value must remain masked (empty string) in GET response."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Save a non-blank token
|
||||||
|
payload = _full_config_payload({"TIBBER_API_TOKEN": "some-tibber-token"})
|
||||||
|
put_resp = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert put_resp.status_code == 200
|
||||||
|
|
||||||
|
# After saving: value must still be masked (empty string), configured must be True
|
||||||
|
resp_after = client.get("/api/config")
|
||||||
|
body_after = resp_after.json()
|
||||||
|
tibber_section_after = next(s for s in body_after["sections"] if s["name"] == "Tibber")
|
||||||
|
token_field_after = next(
|
||||||
|
f for f in tibber_section_after["fields"] if f["env_name"] == "TIBBER_API_TOKEN"
|
||||||
|
)
|
||||||
|
assert token_field_after["configured"] is True
|
||||||
|
assert token_field_after["value"] == "", "Secret value must remain masked after save"
|
||||||
|
# The actual token must not appear anywhere in the response body
|
||||||
|
assert "some-tibber-token" not in resp_after.text
|
||||||
|
|
||||||
|
|
||||||
def test_post_mqtt_test_uses_db_broker_host(
|
def test_post_mqtt_test_uses_db_broker_host(
|
||||||
client: TestClient, test_database_urls
|
client: TestClient, test_database_urls
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,750 @@
|
|||||||
|
"""Tests for M6-T04: EnergyContract CRUD + versioning + pricing-profile API.
|
||||||
|
|
||||||
|
Coverage matrix
|
||||||
|
---------------
|
||||||
|
GET /api/energy/profiles
|
||||||
|
- unauthenticated → 401
|
||||||
|
- authenticated → 200, both 'manual' and 'tibber' profiles present
|
||||||
|
|
||||||
|
GET /api/energy/contracts
|
||||||
|
- unauthenticated → 401
|
||||||
|
- authenticated empty → 200, items=[]
|
||||||
|
- after creating two contracts → items with active flag correct
|
||||||
|
|
||||||
|
POST /api/energy/contracts
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- invalid values (missing required field) → 422, DB unchanged
|
||||||
|
- unknown kind → 422
|
||||||
|
- valid manual → 201, ContractDetailResponse with versions
|
||||||
|
- valid tibber → 201
|
||||||
|
|
||||||
|
GET /api/energy/contracts/{id}
|
||||||
|
- unauthenticated → 401
|
||||||
|
- not found → 404
|
||||||
|
- success → versions ordered by effective_from
|
||||||
|
|
||||||
|
PATCH /api/energy/contracts/{id}
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- not found → 404
|
||||||
|
- rename → name updated
|
||||||
|
- activate → mutual exclusion (A active → activate B → A.active=False)
|
||||||
|
- deactivate → active=False, others unchanged
|
||||||
|
|
||||||
|
POST /api/energy/contracts/{id}/versions
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- not found → 404
|
||||||
|
- invalid values → 422, no rows written, old version unchanged
|
||||||
|
- effective_from ≤ previous → 422
|
||||||
|
- success → old version closed (effective_to set), new open version added,
|
||||||
|
old version values unchanged (only-append semantics)
|
||||||
|
|
||||||
|
Auth/CSRF matrix tested for all write endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.energy import EnergyContract, EnergyContractVersion
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CSRF = "test-csrf-token"
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal valid values for each kind.
|
||||||
|
|
||||||
|
_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 _manual_payload(**overrides) -> dict[str, Any]:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"name": "Test Manual Contract",
|
||||||
|
"kind": "manual",
|
||||||
|
"currency": "EUR",
|
||||||
|
"values": _MANUAL_VALUES,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _tibber_payload(**overrides) -> dict[str, Any]:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"name": "Test Tibber Contract",
|
||||||
|
"kind": "tibber",
|
||||||
|
"currency": "EUR",
|
||||||
|
"values": _TIBBER_VALUES,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def contracts_client(auth_database):
|
||||||
|
"""TestClient + SQLAlchemy engine for EnergyContract 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
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.get("/api/energy/profiles")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_returns_both_kinds(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/profiles")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "profiles" in body
|
||||||
|
kinds = {p["kind"] for p in body["profiles"]}
|
||||||
|
assert "manual" in kinds
|
||||||
|
assert "tibber" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_contain_structure(contracts_client):
|
||||||
|
"""Each profile entry must have the nested structure the front-end needs."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/profiles")
|
||||||
|
body = resp.json()
|
||||||
|
for profile in body["profiles"]:
|
||||||
|
assert "kind" in profile
|
||||||
|
assert "label" in profile
|
||||||
|
assert "energy" in profile
|
||||||
|
assert "standing" in profile
|
||||||
|
assert "credits" in profile
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_contracts_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.get("/api/energy/contracts")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_contracts_empty(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/contracts")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_contracts_shows_active_flag(contracts_client):
|
||||||
|
"""After creating two contracts and activating one, active flag is correct."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Create contract A
|
||||||
|
resp_a = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Contract A"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_a.status_code == 201
|
||||||
|
id_a = resp_a.json()["id"]
|
||||||
|
|
||||||
|
# Create contract B
|
||||||
|
resp_b = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Contract B"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_b.status_code == 201
|
||||||
|
id_b = resp_b.json()["id"]
|
||||||
|
|
||||||
|
# Activate A
|
||||||
|
client.patch(
|
||||||
|
f"/api/energy/contracts/{id_a}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/energy/contracts")
|
||||||
|
items = {c["id"]: c for c in resp.json()["items"]}
|
||||||
|
assert items[id_a]["active"] is True
|
||||||
|
assert items[id_b]["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_missing_csrf_returns_403(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post("/api/energy/contracts", json=_manual_payload())
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_invalid_values_returns_422_no_db_write(contracts_client):
|
||||||
|
"""Non-conforming values must return 422 without writing any rows."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Missing required field energy.buy.normal
|
||||||
|
bad_values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"dal": 0.30}, # missing "normal"
|
||||||
|
"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},
|
||||||
|
}
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(values=bad_values),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# Confirm no rows were written.
|
||||||
|
with Session(engine) as s:
|
||||||
|
count = s.execute(select(EnergyContract)).scalars().all()
|
||||||
|
assert len(count) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_unknown_kind_returns_422(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json={
|
||||||
|
"name": "Bad Kind",
|
||||||
|
"kind": "nonexistent_kind_xyz",
|
||||||
|
"values": {},
|
||||||
|
},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_manual_contract_success(contracts_client):
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "Test Manual Contract"
|
||||||
|
assert body["kind"] == "manual"
|
||||||
|
assert body["active"] is False # new contracts are inactive by default
|
||||||
|
assert "versions" in body
|
||||||
|
assert len(body["versions"]) == 1
|
||||||
|
v = body["versions"][0]
|
||||||
|
assert v["effective_to"] is None # open-ended first version
|
||||||
|
|
||||||
|
# DB check
|
||||||
|
with Session(engine) as s:
|
||||||
|
contracts = s.execute(select(EnergyContract)).scalars().all()
|
||||||
|
assert len(contracts) == 1
|
||||||
|
assert contracts[0].name == "Test Manual Contract"
|
||||||
|
versions = s.execute(select(EnergyContractVersion)).scalars().all()
|
||||||
|
assert len(versions) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_tibber_contract_success(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_tibber_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "tibber"
|
||||||
|
assert len(body["versions"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_defaults_effective_from(contracts_client):
|
||||||
|
"""When effective_from is omitted, the version is created with a recent timestamp.
|
||||||
|
|
||||||
|
SQLite stores datetimes as naive UTC strings; we compare both sides as naive UTC.
|
||||||
|
"""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
# Use naive UTC (no tzinfo) to match what SQLite gives back via Pydantic.
|
||||||
|
# Remove tzinfo from datetime.now(UTC) to get a comparable naive datetime.
|
||||||
|
before = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
v = resp.json()["versions"][0]
|
||||||
|
# Parse as naive (SQLite round-trip strips tz)
|
||||||
|
raw_eff = datetime.fromisoformat(v["effective_from"])
|
||||||
|
# Strip tzinfo from raw_eff if present (shouldn't be, but guard anyway)
|
||||||
|
eff_naive = raw_eff.replace(tzinfo=None)
|
||||||
|
assert eff_naive >= before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/contracts/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contract_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.get("/api/energy/contracts/1")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contract_not_found_returns_404(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/contracts/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contract_returns_versions_ordered(contracts_client):
|
||||||
|
"""GET {id} must return versions ordered by effective_from."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
|
||||||
|
t1 = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
new_values = dict(_MANUAL_VALUES)
|
||||||
|
new_values = {
|
||||||
|
**_MANUAL_VALUES,
|
||||||
|
"energy": {**_MANUAL_VALUES["energy"], "buy": {"normal": 0.50, "dal": 0.40}},
|
||||||
|
}
|
||||||
|
client.post(
|
||||||
|
f"/api/energy/contracts/{contract_id}/versions",
|
||||||
|
json={"effective_from": t1.isoformat(), "values": new_values},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/energy/contracts/{contract_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
versions = resp.json()["versions"]
|
||||||
|
assert len(versions) == 2
|
||||||
|
# Ordered by effective_from ascending
|
||||||
|
eff0 = datetime.fromisoformat(versions[0]["effective_from"])
|
||||||
|
eff1 = datetime.fromisoformat(versions[1]["effective_from"])
|
||||||
|
assert eff0 < eff1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/energy/contracts/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/energy/contracts/1",
|
||||||
|
json={"name": "Renamed"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_missing_csrf_returns_403(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
# Create a contract first
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
# PATCH without CSRF
|
||||||
|
resp = client.patch(f"/api/energy/contracts/{contract_id}", json={"name": "Renamed"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_not_found_returns_404(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/energy/contracts/99999",
|
||||||
|
json={"name": "Does Not Exist"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_rename(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Original Name"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{contract_id}",
|
||||||
|
json={"name": "Renamed Contract"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "Renamed Contract"
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_contract_mutual_exclusion(contracts_client):
|
||||||
|
"""Activating B deactivates A; at most one contract is active."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Create A and B
|
||||||
|
resp_a = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="A"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
id_a = resp_a.json()["id"]
|
||||||
|
|
||||||
|
resp_b = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="B"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
id_b = resp_b.json()["id"]
|
||||||
|
|
||||||
|
# Activate A
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{id_a}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is True
|
||||||
|
|
||||||
|
# Now activate B — A must become inactive
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{id_b}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is True
|
||||||
|
|
||||||
|
# Verify A is now inactive via list
|
||||||
|
list_resp = client.get("/api/energy/contracts")
|
||||||
|
items = {c["id"]: c for c in list_resp.json()["items"]}
|
||||||
|
assert items[id_a]["active"] is False
|
||||||
|
assert items[id_b]["active"] is True
|
||||||
|
|
||||||
|
# DB check: only one active
|
||||||
|
with Session(engine) as s:
|
||||||
|
active_contracts = (
|
||||||
|
s.execute(select(EnergyContract).where(EnergyContract.active.is_(True)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(active_contracts) == 1
|
||||||
|
assert active_contracts[0].id == id_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivate_contract(contracts_client):
|
||||||
|
"""PATCH active=false deactivates the contract without touching others."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Activate it first
|
||||||
|
client.patch(
|
||||||
|
f"/api/energy/contracts/{contract_id}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now deactivate
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{contract_id}",
|
||||||
|
json={"active": False},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/contracts/{id}/versions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts/1/versions",
|
||||||
|
json={"effective_from": datetime.now(UTC).isoformat(), "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_missing_csrf_returns_403(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
# POST version without CSRF
|
||||||
|
t1 = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t1, "values": _MANUAL_VALUES},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_not_found_returns_404(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
t1 = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts/99999/versions",
|
||||||
|
json={"effective_from": t1, "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_invalid_values_returns_422_no_db_write(contracts_client):
|
||||||
|
"""Non-conforming values for a new version must return 422; DB unchanged."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
|
||||||
|
bad_values = {"energy": {}} # missing almost everything
|
||||||
|
t1 = datetime(2026, 6, 1, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t1.isoformat(), "values": bad_values},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# Only the original version should exist in DB.
|
||||||
|
with Session(engine) as s:
|
||||||
|
versions = (
|
||||||
|
s.execute(
|
||||||
|
select(EnergyContractVersion).where(EnergyContractVersion.contract_id == cid)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(versions) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_effective_from_not_after_previous_returns_422(contracts_client):
|
||||||
|
"""effective_from ≤ previous open version's effective_from must return 422."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 6, 1, tzinfo=UTC)
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
|
||||||
|
# Attempt to add a version with t1 == t0 (not strictly after)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t0.isoformat(), "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# Attempt to add a version with t1 < t0
|
||||||
|
t_before = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t_before.isoformat(), "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_closes_previous_and_appends(contracts_client):
|
||||||
|
"""Adding a new version must close the previous one and create a new open version.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- Old version's effective_to == new version's effective_from.
|
||||||
|
- Old version's values are unchanged (append-only; never overwritten).
|
||||||
|
- New version has effective_to=None (open-ended).
|
||||||
|
"""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
old_values = _MANUAL_VALUES
|
||||||
|
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat(), values=old_values),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_c.status_code == 201
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
v1_id = resp_c.json()["versions"][0]["id"]
|
||||||
|
|
||||||
|
t1 = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
new_values = {
|
||||||
|
**old_values,
|
||||||
|
"energy": {**old_values["energy"], "buy": {"normal": 0.50, "dal": 0.40}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t1.isoformat(), "values": new_values},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
versions = resp.json()["versions"]
|
||||||
|
assert len(versions) == 2
|
||||||
|
|
||||||
|
# Ordered ascending — first is the old version
|
||||||
|
ver_old = versions[0]
|
||||||
|
ver_new = versions[1]
|
||||||
|
|
||||||
|
# Old version must be closed at t1.
|
||||||
|
# SQLite round-trips datetimes as naive UTC strings; compare without tzinfo.
|
||||||
|
assert ver_old["id"] == v1_id
|
||||||
|
assert ver_old["effective_to"] is not None
|
||||||
|
eff_to = datetime.fromisoformat(ver_old["effective_to"]).replace(tzinfo=None)
|
||||||
|
assert eff_to == t1.replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Old version values must be unchanged
|
||||||
|
assert ver_old["values"]["energy"]["buy"]["normal"] == old_values["energy"]["buy"]["normal"]
|
||||||
|
|
||||||
|
# New version must be open-ended
|
||||||
|
assert ver_new["effective_to"] is None
|
||||||
|
assert ver_new["values"]["energy"]["buy"]["normal"] == 0.50
|
||||||
|
|
||||||
|
# Double-check via DB
|
||||||
|
with Session(engine) as s:
|
||||||
|
v1 = s.get(EnergyContractVersion, v1_id)
|
||||||
|
assert v1 is not None
|
||||||
|
assert v1.effective_to is not None
|
||||||
|
assert v1.values["energy"]["buy"]["normal"] == old_values["energy"]["buy"]["normal"]
|
||||||
|
|
||||||
|
all_versions = (
|
||||||
|
s.execute(
|
||||||
|
select(EnergyContractVersion).where(EnergyContractVersion.contract_id == cid)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(all_versions) == 2
|
||||||
@@ -432,6 +432,158 @@ def test_delete_device_not_found_returns_404(modbus_client):
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /api/modbus/devices/{uuid}?cascade=true
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_false_with_readings_still_returns_409(modbus_client):
|
||||||
|
"""cascade=false (default) + readings → 409, device and readings remain."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
_make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
params={"cascade": "false"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
# Device and reading still in DB
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(ModbusDevice).count() == 1
|
||||||
|
assert session.query(ModbusReading).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_true_with_readings_deletes_device_readings_and_toggles(modbus_client):
|
||||||
|
"""cascade=true + readings → device, readings, and toggle rows all deleted; 200 response."""
|
||||||
|
from datetime import UTC
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
# Add two readings
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
_make_reading(engine, device.id, now, {"voltage": 230.0})
|
||||||
|
_make_reading(engine, device.id, now - timedelta(minutes=1), {"voltage": 229.0})
|
||||||
|
|
||||||
|
# Add an ExposedEntityToggle row for this device
|
||||||
|
with Session(engine) as session:
|
||||||
|
toggle = ExposedEntityToggle(
|
||||||
|
key=f"modbus.{device.uuid}.voltage",
|
||||||
|
enabled=True,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(toggle)
|
||||||
|
# Add one toggle for a different device to verify isolation
|
||||||
|
other_toggle = ExposedEntityToggle(
|
||||||
|
key="modbus.other-uuid.voltage",
|
||||||
|
enabled=True,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(other_toggle)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
params={"cascade": "true"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["deleted"] is True
|
||||||
|
assert body["readings_deleted"] == 2
|
||||||
|
assert body["toggles_deleted"] == 1
|
||||||
|
|
||||||
|
# All rows for this device must be gone
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(ModbusDevice).count() == 0
|
||||||
|
assert session.query(ModbusReading).count() == 0
|
||||||
|
# Only the other device's toggle remains
|
||||||
|
remaining_toggles = session.query(ExposedEntityToggle).all()
|
||||||
|
assert len(remaining_toggles) == 1
|
||||||
|
assert remaining_toggles[0].key == "modbus.other-uuid.voltage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_true_no_readings_deletes_device(modbus_client):
|
||||||
|
"""cascade=true + no readings → device deleted, response shows 0 counts."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
params={"cascade": "true"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["deleted"] is True
|
||||||
|
assert body["readings_deleted"] == 0
|
||||||
|
assert body["toggles_deleted"] == 0
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(ModbusDevice).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_unauthenticated_returns_401(modbus_client):
|
||||||
|
"""Unauthenticated cascade delete request → 401."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
params={"cascade": "true"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_missing_csrf_returns_403(modbus_client):
|
||||||
|
"""cascade=true without CSRF → 403."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
params={"cascade": "true"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_mqtt_unavailable_still_deletes(modbus_client):
|
||||||
|
"""When MQTT is not connected, cascade delete must still succeed (HA cleanup is best-effort)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
_make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})
|
||||||
|
|
||||||
|
# Simulate MQTT not connected so clear_device_discovery is a no-op
|
||||||
|
with patch("app.services.ha_discovery.mqtt_manager") as mock_mqtt:
|
||||||
|
mock_mqtt.is_connected = False
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
params={"cascade": "true"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["deleted"] is True
|
||||||
|
assert body["readings_deleted"] == 1
|
||||||
|
|
||||||
|
# DB rows gone
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(ModbusDevice).count() == 0
|
||||||
|
assert session.query(ModbusReading).count() == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /api/modbus/devices/{uuid}/latest
|
# GET /api/modbus/devices/{uuid}/latest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"""Tests for M6-T06: DSMR ingest service (handle_message).
|
||||||
|
|
||||||
|
All tests use a temporary SQLite database (upgraded to the full Alembic head)
|
||||||
|
so that the real DsmrReading model/constraints are exercised.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Sample telegram with second=00 (on interval) is persisted as a full-frame blob.
|
||||||
|
- Sample telegram with second=48 (off interval, 10s default) is discarded.
|
||||||
|
- Telegram with second=00 → all fields including gas (extra_device_*) and null
|
||||||
|
phases are stored verbatim.
|
||||||
|
- Duplicate source_id → not re-inserted (idempotency).
|
||||||
|
- source_id absent → row still inserted with source_id=None.
|
||||||
|
- dsmr_sample_interval_s=0 → no ZeroDivisionError; every telegram is persisted.
|
||||||
|
- Invalid JSON → silently discarded (no exception propagated).
|
||||||
|
- Missing 'timestamp' field → silently discarded.
|
||||||
|
- Unparseable 'timestamp' → silently discarded.
|
||||||
|
- Handler exception (e.g. bad DB) → does not propagate out of handle_message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.energy import DsmrReading
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers / fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_config(database_url: str) -> Config:
|
||||||
|
cfg = Config("alembic_app.ini")
|
||||||
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def dsmr_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to the Alembic head; yields (engine, session_factory)."""
|
||||||
|
db_path = tmp_path / "dsmr_ingest_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
command.upgrade(_alembic_config(db_url), "head")
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||||
|
yield engine, SessionLocal
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_settings(
|
||||||
|
*,
|
||||||
|
dsmr_sample_interval_s: int = 10,
|
||||||
|
dsmr_ingest_enabled: bool = True,
|
||||||
|
dsmr_mqtt_topic: str = "dsmr/json",
|
||||||
|
):
|
||||||
|
s = MagicMock()
|
||||||
|
s.dsmr_sample_interval_s = dsmr_sample_interval_s
|
||||||
|
s.dsmr_ingest_enabled = dsmr_ingest_enabled
|
||||||
|
s.dsmr_mqtt_topic = dsmr_mqtt_topic
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# The reference telegram sample from §6.3 of the design doc.
|
||||||
|
# Numeric values are JSON strings; missing phases are null.
|
||||||
|
_SAMPLE_TELEGRAM = {
|
||||||
|
"id": 200086230,
|
||||||
|
"timestamp": "2026-06-23T12:16:00Z", # second=00, on 10s boundary
|
||||||
|
"electricity_delivered_1": "20915.154",
|
||||||
|
"electricity_returned_1": "2979.905",
|
||||||
|
"electricity_delivered_2": "15212.090",
|
||||||
|
"electricity_returned_2": "6786.406",
|
||||||
|
"electricity_currently_delivered": "0.000",
|
||||||
|
"phase_currently_delivered_l1": "0.000",
|
||||||
|
"phase_currently_delivered_l2": None, # absent phase
|
||||||
|
"extra_device_timestamp": "2026-06-23T12:15:00Z",
|
||||||
|
"extra_device_delivered": "6208.234",
|
||||||
|
"phase_voltage_l1": "237.0",
|
||||||
|
"phase_voltage_l2": None, # absent phase
|
||||||
|
}
|
||||||
|
|
||||||
|
# Telegram with second=48 (not divisible by 10) — should be discarded.
|
||||||
|
_SAMPLE_TELEGRAM_SECOND_48 = {
|
||||||
|
**_SAMPLE_TELEGRAM,
|
||||||
|
"id": 200086248,
|
||||||
|
"timestamp": "2026-06-23T12:16:48Z", # second=48
|
||||||
|
}
|
||||||
|
|
||||||
|
# Telegram with second=10 — another valid boundary.
|
||||||
|
_SAMPLE_TELEGRAM_SECOND_10 = {
|
||||||
|
**_SAMPLE_TELEGRAM,
|
||||||
|
"id": 200086210,
|
||||||
|
"timestamp": "2026-06-23T12:16:10Z", # second=10
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(data: dict) -> bytes:
|
||||||
|
return json.dumps(data).encode()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: invoke handle_message with the test DB injected via patch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _call_handle_message(
|
||||||
|
data: dict,
|
||||||
|
settings,
|
||||||
|
SessionLocal,
|
||||||
|
) -> None:
|
||||||
|
"""Call dsmr_ingest.handle_message with the test SessionLocal patched in."""
|
||||||
|
from app.services import dsmr_ingest
|
||||||
|
|
||||||
|
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
|
||||||
|
dsmr_ingest.handle_message(_payload(data), settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _count_readings(SessionLocal) -> int:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
return session.scalar(
|
||||||
|
__import__("sqlalchemy", fromlist=["func"]).func.count(DsmrReading.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_readings(SessionLocal) -> list[DsmrReading]:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
return session.scalars(select(DsmrReading)).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. On-interval telegram is persisted (whole frame)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_00_persists_full_frame(dsmr_db):
|
||||||
|
"""A telegram with second=00 (10s boundary) must be persisted with the full payload."""
|
||||||
|
engine, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
readings = session.scalars(select(DsmrReading)).all()
|
||||||
|
|
||||||
|
assert len(readings) == 1
|
||||||
|
row = readings[0]
|
||||||
|
|
||||||
|
# Recorded_at must be parsed from the telegram timestamp.
|
||||||
|
assert row.recorded_at is not None
|
||||||
|
assert row.recorded_at.second == 0
|
||||||
|
|
||||||
|
# source_id must equal the telegram id.
|
||||||
|
assert row.source_id == _SAMPLE_TELEGRAM["id"]
|
||||||
|
|
||||||
|
# Full frame preserved — spot-check several fields.
|
||||||
|
payload = row.payload
|
||||||
|
assert payload["electricity_delivered_1"] == "20915.154"
|
||||||
|
assert payload["electricity_returned_2"] == "6786.406"
|
||||||
|
assert payload["extra_device_delivered"] == "6208.234"
|
||||||
|
assert payload["extra_device_timestamp"] == "2026-06-23T12:15:00Z"
|
||||||
|
|
||||||
|
# Null phases stored verbatim (not omitted, not converted).
|
||||||
|
assert "phase_currently_delivered_l2" in payload
|
||||||
|
assert payload["phase_currently_delivered_l2"] is None
|
||||||
|
assert "phase_voltage_l2" in payload
|
||||||
|
assert payload["phase_voltage_l2"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_10_persists(dsmr_db):
|
||||||
|
"""A telegram with second=10 (another 10s boundary) must also be persisted."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM_SECOND_10, settings, SessionLocal)
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Off-interval telegram is discarded
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_48_discarded(dsmr_db):
|
||||||
|
"""A telegram with second=48 (not on 10s boundary) must be silently discarded."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM_SECOND_48, settings, SessionLocal)
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_interval_not_multiple_of_10(dsmr_db):
|
||||||
|
"""Seconds 1–9, 11–19, etc. are all off-interval and must be discarded."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
for second in (1, 3, 7, 9, 11, 23, 47, 59):
|
||||||
|
ts = f"2026-06-23T12:16:{second:02d}Z"
|
||||||
|
data = {**_SAMPLE_TELEGRAM, "id": 200000000 + second, "timestamp": ts}
|
||||||
|
_call_handle_message(data, settings, SessionLocal)
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Idempotency — keyed on recorded_at (telegram timestamp), NOT the telegram id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_timestamp_not_reinserted(dsmr_db):
|
||||||
|
"""Feeding the same telegram twice (same timestamp) must result in one row."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal) # duplicate
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_timestamps_are_independent(dsmr_db):
|
||||||
|
"""Telegrams with different timestamps produce separate rows."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM_SECOND_10, settings, SessionLocal)
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_id_collision_does_not_drop_new_data(dsmr_db):
|
||||||
|
"""Regression: the telegram id overflows / gets reset to zero in DSMR firmware.
|
||||||
|
Two DISTINCT telegrams (different timestamps) that happen to share the SAME
|
||||||
|
telegram id must BOTH be stored — dedup must not depend on the telegram id."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
first = {**_SAMPLE_TELEGRAM, "id": 0, "timestamp": "2026-06-23T12:16:00Z"}
|
||||||
|
# Later telegram, id reset back to the same value after an overflow.
|
||||||
|
second = {**_SAMPLE_TELEGRAM, "id": 0, "timestamp": "2026-06-23T12:16:10Z"}
|
||||||
|
|
||||||
|
_call_handle_message(first, settings, SessionLocal)
|
||||||
|
_call_handle_message(second, settings, SessionLocal)
|
||||||
|
|
||||||
|
# Both must persist — the colliding telegram id must not cause a drop.
|
||||||
|
assert _count_readings(SessionLocal) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Missing source_id — still persisted with source_id=None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_id_persisted_with_source_id_none(dsmr_db):
|
||||||
|
"""Telegram without an 'id' field must be stored with source_id=None."""
|
||||||
|
engine, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
data = {k: v for k, v in _SAMPLE_TELEGRAM.items() if k != "id"}
|
||||||
|
|
||||||
|
_call_handle_message(data, settings, SessionLocal)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
readings = session.scalars(select(DsmrReading)).all()
|
||||||
|
|
||||||
|
assert len(readings) == 1
|
||||||
|
assert readings[0].source_id is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. interval=0 → no ZeroDivisionError; all telegrams persisted
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_zero_does_not_crash(dsmr_db):
|
||||||
|
"""When dsmr_sample_interval_s=0, no ZeroDivisionError; all telegrams kept."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=0)
|
||||||
|
|
||||||
|
for second in (0, 7, 13, 48, 59):
|
||||||
|
ts = f"2026-06-23T12:16:{second:02d}Z"
|
||||||
|
data = {**_SAMPLE_TELEGRAM, "id": 300000000 + second, "timestamp": ts}
|
||||||
|
_call_handle_message(data, settings, SessionLocal)
|
||||||
|
|
||||||
|
# All 5 should have been persisted (no sampling applied).
|
||||||
|
assert _count_readings(SessionLocal) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Invalid / malformed payloads — silently discarded, no exception propagated
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_json_discarded(dsmr_db):
|
||||||
|
"""Invalid JSON payload must be silently discarded without raising."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings()
|
||||||
|
from app.services import dsmr_ingest
|
||||||
|
|
||||||
|
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
|
||||||
|
# Must not raise
|
||||||
|
dsmr_ingest.handle_message(b"not-valid-json", settings)
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_timestamp_discarded(dsmr_db):
|
||||||
|
"""Telegram without a 'timestamp' field must be discarded."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings()
|
||||||
|
data = {k: v for k, v in _SAMPLE_TELEGRAM.items() if k != "timestamp"}
|
||||||
|
|
||||||
|
_call_handle_message(data, settings, SessionLocal)
|
||||||
|
assert _count_readings(SessionLocal) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unparseable_timestamp_discarded(dsmr_db):
|
||||||
|
"""Telegram with a garbage 'timestamp' field must be discarded."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings()
|
||||||
|
data = {**_SAMPLE_TELEGRAM, "timestamp": "not-a-date"}
|
||||||
|
|
||||||
|
_call_handle_message(data, settings, SessionLocal)
|
||||||
|
assert _count_readings(SessionLocal) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_message_does_not_propagate_any_exception() -> None:
|
||||||
|
"""handle_message must never propagate any exception to the caller."""
|
||||||
|
from app.services import dsmr_ingest
|
||||||
|
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
def _explode():
|
||||||
|
raise RuntimeError("DB is on fire")
|
||||||
|
|
||||||
|
with patch.object(dsmr_ingest, "get_session_local", side_effect=_explode):
|
||||||
|
# Must not raise
|
||||||
|
dsmr_ingest.handle_message(_payload(_SAMPLE_TELEGRAM), settings)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Null phase values stored verbatim
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_phases_stored_as_none(dsmr_db):
|
||||||
|
"""Null phase values must be preserved as JSON null (Python None) in payload."""
|
||||||
|
engine, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
reading = session.scalars(select(DsmrReading)).first()
|
||||||
|
|
||||||
|
assert reading is not None
|
||||||
|
assert reading.payload.get("phase_currently_delivered_l2") is None
|
||||||
|
assert reading.payload.get("phase_voltage_l2") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Gas / extra_device fields stored
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_gas_fields_stored(dsmr_db):
|
||||||
|
"""extra_device_* (gas) fields must be present in the stored payload."""
|
||||||
|
engine, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
reading = session.scalars(select(DsmrReading)).first()
|
||||||
|
|
||||||
|
assert reading is not None
|
||||||
|
payload = reading.payload
|
||||||
|
assert "extra_device_delivered" in payload
|
||||||
|
assert payload["extra_device_delivered"] == "6208.234"
|
||||||
|
assert "extra_device_timestamp" in payload
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Timestamp with +00:00 suffix (no Z) also works
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_timestamp_with_utc_offset_suffix(dsmr_db):
|
||||||
|
"""Timestamps using '+00:00' instead of 'Z' must also be parsed correctly."""
|
||||||
|
_, SessionLocal = dsmr_db
|
||||||
|
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
**_SAMPLE_TELEGRAM,
|
||||||
|
"id": 999999,
|
||||||
|
"timestamp": "2026-06-23T12:16:00+00:00", # no Z, uses +00:00
|
||||||
|
}
|
||||||
|
_call_handle_message(data, settings, SessionLocal)
|
||||||
|
|
||||||
|
assert _count_readings(SessionLocal) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. handle_tariff_message: parse, validate, update, reject invalid payloads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def reset_tariff(monkeypatch):
|
||||||
|
"""Reset _current_tariff to None before and after each tariff test."""
|
||||||
|
from app.services import dsmr_ingest as _di
|
||||||
|
|
||||||
|
monkeypatch.setattr(_di, "_current_tariff", None)
|
||||||
|
yield
|
||||||
|
# monkeypatch auto-restores on teardown
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_value_2_sets_tariff(reset_tariff):
|
||||||
|
"""Payload b'2' must set the current tariff to 2 (normal/peak)."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
|
||||||
|
|
||||||
|
handle_tariff_message(b"2")
|
||||||
|
assert get_current_tariff() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_value_1_sets_tariff(reset_tariff):
|
||||||
|
"""Payload b'1' must set the current tariff to 1 (dal/off-peak)."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
|
||||||
|
|
||||||
|
handle_tariff_message(b"1")
|
||||||
|
assert get_current_tariff() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_updates_from_2_to_1(reset_tariff):
|
||||||
|
"""Subsequent payloads must overwrite the previous tariff value."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
|
||||||
|
|
||||||
|
handle_tariff_message(b"2")
|
||||||
|
assert get_current_tariff() == 2
|
||||||
|
handle_tariff_message(b"1")
|
||||||
|
assert get_current_tariff() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_strips_whitespace(reset_tariff):
|
||||||
|
"""Payloads with surrounding whitespace (e.g. b'2\\n') must be accepted."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff
|
||||||
|
|
||||||
|
handle_tariff_message(b"2\n")
|
||||||
|
assert get_current_tariff() == 2
|
||||||
|
|
||||||
|
handle_tariff_message(b" 1 ")
|
||||||
|
assert get_current_tariff() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_invalid_non_numeric_does_not_update(reset_tariff):
|
||||||
|
"""Non-numeric payload must not update the tariff; previous value is preserved."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff, set_current_tariff
|
||||||
|
|
||||||
|
set_current_tariff(2)
|
||||||
|
handle_tariff_message(b"x")
|
||||||
|
# Must NOT raise and must NOT change the tariff.
|
||||||
|
assert get_current_tariff() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_invalid_empty_does_not_update(reset_tariff):
|
||||||
|
"""Empty payload must not update the tariff; previous value is preserved."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff, set_current_tariff
|
||||||
|
|
||||||
|
set_current_tariff(1)
|
||||||
|
handle_tariff_message(b"")
|
||||||
|
assert get_current_tariff() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_out_of_range_value_does_not_update(reset_tariff):
|
||||||
|
"""Payload with out-of-range integer (not 1 or 2) must not update the tariff."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message, get_current_tariff, set_current_tariff
|
||||||
|
|
||||||
|
set_current_tariff(2)
|
||||||
|
handle_tariff_message(b"3") # 3 is not a valid tariff
|
||||||
|
assert get_current_tariff() == 2
|
||||||
|
|
||||||
|
handle_tariff_message(b"0") # 0 is not a valid tariff
|
||||||
|
assert get_current_tariff() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_message_does_not_raise_on_any_input(reset_tariff):
|
||||||
|
"""handle_tariff_message must never propagate any exception to the caller."""
|
||||||
|
from app.services.dsmr_ingest import handle_tariff_message
|
||||||
|
|
||||||
|
# All of these must complete without raising.
|
||||||
|
for payload in (b"", b"x", b"99", b"\xff\xfe", b"None", b"2.0"):
|
||||||
|
handle_tariff_message(payload) # must not raise
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for restart-free DSMR subscription management (apply_dsmr_subscription).
|
||||||
|
|
||||||
|
These verify that toggling DSMR ingest / changing its topic / changing its sample
|
||||||
|
interval via the config UI is reflected in the live MQTT subscription without an
|
||||||
|
app restart. A fake MQTT manager is injected so no real broker is touched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import dsmr_ingest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMqtt:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.subscribe_calls: list[tuple[str, object]] = []
|
||||||
|
self.unsubscribe_calls: list[str] = []
|
||||||
|
|
||||||
|
def subscribe(self, topic: str, handler) -> None:
|
||||||
|
self.subscribe_calls.append((topic, handler))
|
||||||
|
|
||||||
|
def unsubscribe(self, topic: str) -> None:
|
||||||
|
self.unsubscribe_calls.append(topic)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(
|
||||||
|
*,
|
||||||
|
enabled: bool = True,
|
||||||
|
topic: str = "dsmr/json",
|
||||||
|
interval: int = 10,
|
||||||
|
tariff_topic: str = "",
|
||||||
|
):
|
||||||
|
s = MagicMock()
|
||||||
|
s.dsmr_ingest_enabled = enabled
|
||||||
|
s.dsmr_mqtt_topic = topic
|
||||||
|
s.dsmr_sample_interval_s = interval
|
||||||
|
s.dsmr_tariff_topic = tariff_topic
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_mqtt(monkeypatch):
|
||||||
|
fake = _FakeMqtt()
|
||||||
|
# apply_dsmr_subscription does `from app.integrations.mqtt import mqtt_manager`
|
||||||
|
# at call time, so patching the module attribute is picked up.
|
||||||
|
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
||||||
|
# Reset (and auto-restore) the module-level "currently subscribed topics".
|
||||||
|
monkeypatch.setattr(dsmr_ingest, "_current_dsmr_topic", None)
|
||||||
|
monkeypatch.setattr(dsmr_ingest, "_current_tariff_topic", None)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_subscribes_to_topic(fake_mqtt):
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=True, topic="dsmr/json"))
|
||||||
|
|
||||||
|
assert len(fake_mqtt.subscribe_calls) == 1
|
||||||
|
assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json"
|
||||||
|
assert fake_mqtt.unsubscribe_calls == []
|
||||||
|
assert dsmr_ingest._current_dsmr_topic == "dsmr/json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_after_enabled_unsubscribes(fake_mqtt):
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=True, topic="dsmr/json"))
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
|
||||||
|
|
||||||
|
assert fake_mqtt.unsubscribe_calls == ["dsmr/json"]
|
||||||
|
assert dsmr_ingest._current_dsmr_topic is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_topic_change_unsubscribes_old_subscribes_new(fake_mqtt):
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json"))
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="meter/dsmr"))
|
||||||
|
|
||||||
|
assert fake_mqtt.unsubscribe_calls == ["dsmr/json"]
|
||||||
|
assert [t for t, _ in fake_mqtt.subscribe_calls] == ["dsmr/json", "meter/dsmr"]
|
||||||
|
assert dsmr_ingest._current_dsmr_topic == "meter/dsmr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reapply_same_topic_resubscribes_fresh_handler(fake_mqtt):
|
||||||
|
# A changed sample interval must take effect — the handler is re-bound to a
|
||||||
|
# fresh settings snapshot, so re-applying the same topic re-subscribes.
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json", interval=10))
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json", interval=20))
|
||||||
|
|
||||||
|
assert len(fake_mqtt.subscribe_calls) == 2
|
||||||
|
assert fake_mqtt.unsubscribe_calls == [] # same topic, no churn
|
||||||
|
handler1 = fake_mqtt.subscribe_calls[0][1]
|
||||||
|
handler2 = fake_mqtt.subscribe_calls[1][1]
|
||||||
|
assert handler1 is not handler2 # fresh closure carrying the new settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_when_never_enabled_is_noop(fake_mqtt):
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
|
||||||
|
|
||||||
|
assert fake_mqtt.subscribe_calls == []
|
||||||
|
assert fake_mqtt.unsubscribe_calls == []
|
||||||
|
assert dsmr_ingest._current_dsmr_topic is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tariff topic subscription management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_with_tariff_topic_subscribes_both(fake_mqtt):
|
||||||
|
"""When enabled and tariff_topic is non-empty, both topics must be subscribed."""
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
|
||||||
|
)
|
||||||
|
|
||||||
|
subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls]
|
||||||
|
assert "dsmr/json" in subscribed_topics
|
||||||
|
assert "dsmr/meter-stats/electricity_tariff" in subscribed_topics
|
||||||
|
assert len(fake_mqtt.subscribe_calls) == 2
|
||||||
|
assert dsmr_ingest._current_tariff_topic == "dsmr/meter-stats/electricity_tariff"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_with_empty_tariff_topic_subscribes_only_main(fake_mqtt):
|
||||||
|
"""When tariff_topic is empty, only the main DSMR topic is subscribed."""
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(fake_mqtt.subscribe_calls) == 1
|
||||||
|
assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json"
|
||||||
|
assert dsmr_ingest._current_tariff_topic is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_after_tariff_subscription_unsubscribes_both(fake_mqtt):
|
||||||
|
"""Disabling ingest must also unsubscribe the tariff topic."""
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
|
||||||
|
)
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
|
||||||
|
|
||||||
|
assert "dsmr/json" in fake_mqtt.unsubscribe_calls
|
||||||
|
assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls
|
||||||
|
assert dsmr_ingest._current_dsmr_topic is None
|
||||||
|
assert dsmr_ingest._current_tariff_topic is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_topic_change_resubscribes(fake_mqtt):
|
||||||
|
"""Changing the tariff topic must unsubscribe the old one and subscribe the new one."""
|
||||||
|
old_tariff = "dsmr/meter-stats/electricity_tariff"
|
||||||
|
new_tariff = "meter/tariff"
|
||||||
|
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic=old_tariff)
|
||||||
|
)
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic=new_tariff)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert old_tariff in fake_mqtt.unsubscribe_calls
|
||||||
|
subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls]
|
||||||
|
assert new_tariff in subscribed_topics
|
||||||
|
assert dsmr_ingest._current_tariff_topic == new_tariff
|
||||||
|
|
||||||
|
|
||||||
|
def test_tariff_topic_cleared_unsubscribes(fake_mqtt):
|
||||||
|
"""Setting tariff_topic to empty after it was subscribed must unsubscribe it."""
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
|
||||||
|
)
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(
|
||||||
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls
|
||||||
|
assert dsmr_ingest._current_tariff_topic is None
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,741 @@
|
|||||||
|
"""Tests for M6-T01: energy pricing and DSMR metering tables and ORM models.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. Migration shape: upgrade to head creates all five tables with correct columns,
|
||||||
|
constraints, and indexes; downgrade -1 cleanly removes them.
|
||||||
|
2. ORM metadata: Base.metadata.tables contains all five tables; FKs are RESTRICT;
|
||||||
|
JSON columns are present; unique and index constraints are correct.
|
||||||
|
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
|
||||||
|
4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip.
|
||||||
|
5. FK RESTRICT: deleting a referenced parent row must fail when children exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, event as sa_event, inspect, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
from app.models.energy import (
|
||||||
|
DsmrReading,
|
||||||
|
EnergyContract,
|
||||||
|
EnergyContractVersion,
|
||||||
|
TibberPrice,
|
||||||
|
EnergyCostPeriod,
|
||||||
|
)
|
||||||
|
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app_alembic_config(database_url: str) -> Config:
|
||||||
|
cfg = Config("alembic_app.ini")
|
||||||
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_with_fk(db_url: str):
|
||||||
|
"""Create a SQLAlchemy engine with SQLite FK enforcement enabled."""
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
@sa_event.listens_for(engine, "connect")
|
||||||
|
def _enable_fk(dbapi_conn, _rec):
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def energy_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to the current Alembic head."""
|
||||||
|
db_path = tmp_path / "energy_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def energy_db_with_fk(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to head with FK enforcement enabled."""
|
||||||
|
db_path = tmp_path / "energy_fk_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Migration shape tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_tables_exist_after_upgrade(energy_db):
|
||||||
|
"""All five energy tables must exist after upgrade to head."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
table_names = inspector.get_table_names()
|
||||||
|
expected_tables = {
|
||||||
|
"dsmr_reading",
|
||||||
|
"energy_contract",
|
||||||
|
"energy_contract_version",
|
||||||
|
"tibber_price",
|
||||||
|
"energy_cost_period",
|
||||||
|
}
|
||||||
|
for table in expected_tables:
|
||||||
|
assert table in table_names, f"{table!r} missing after upgrade to head"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_columns(energy_db):
|
||||||
|
"""dsmr_reading must have id, recorded_at (NOT NULL), source_id (nullable), payload (NOT NULL)."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("dsmr_reading")}
|
||||||
|
|
||||||
|
assert "id" in columns and not columns["id"]["nullable"]
|
||||||
|
assert "recorded_at" in columns and not columns["recorded_at"]["nullable"]
|
||||||
|
assert "source_id" in columns and columns["source_id"]["nullable"]
|
||||||
|
assert "payload" in columns and not columns["payload"]["nullable"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_recorded_at_unique(energy_db):
|
||||||
|
"""dsmr_reading.recorded_at is the UNIQUE de-dup key (telegram-id-independent)."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("dsmr_reading")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "recorded_at" in unique_cols, "recorded_at must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_source_id_not_unique(energy_db):
|
||||||
|
"""dsmr_reading.source_id (telegram id) must NOT be unique — it overflows/resets,
|
||||||
|
so it is kept only as a reference value and never relied on for dedup."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("dsmr_reading")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "source_id" not in unique_cols, "source_id must NOT have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_columns(energy_db):
|
||||||
|
"""energy_contract must have all required columns."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("energy_contract")}
|
||||||
|
|
||||||
|
required_non_nullable = {"id", "name", "kind", "active", "currency", "created_at", "updated_at"}
|
||||||
|
for col_name in required_non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_version_columns(energy_db):
|
||||||
|
"""energy_contract_version must have all required columns with correct nullability."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("energy_contract_version")}
|
||||||
|
|
||||||
|
non_nullable = {"id", "contract_id", "effective_from", "values", "created_at"}
|
||||||
|
nullable = {"effective_to"}
|
||||||
|
|
||||||
|
for col_name in non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
for col_name in nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tibber_price_columns(energy_db):
|
||||||
|
"""tibber_price must have all required columns."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("tibber_price")}
|
||||||
|
|
||||||
|
non_nullable = {
|
||||||
|
"id", "starts_at", "resolution", "energy", "tax", "total",
|
||||||
|
"currency", "fetched_at",
|
||||||
|
}
|
||||||
|
nullable = {"level"}
|
||||||
|
|
||||||
|
for col_name in non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
for col_name in nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tibber_price_starts_at_unique(energy_db):
|
||||||
|
"""tibber_price.starts_at must have a unique constraint."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("tibber_price")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "starts_at" in unique_cols, "starts_at must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_columns(energy_db):
|
||||||
|
"""energy_cost_period must have all required columns with correct nullability."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("energy_cost_period")}
|
||||||
|
|
||||||
|
non_nullable = {
|
||||||
|
"id", "period_start", "d1_kwh", "d2_kwh", "r1_kwh", "r2_kwh",
|
||||||
|
"import_cost", "export_revenue", "net_cost", "currency",
|
||||||
|
"pricing", "degraded", "computed_at",
|
||||||
|
}
|
||||||
|
nullable = {"contract_version_id"}
|
||||||
|
|
||||||
|
for col_name in non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
for col_name in nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_period_start_unique(energy_db):
|
||||||
|
"""energy_cost_period.period_start must have a unique constraint."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("energy_cost_period")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "period_start" in unique_cols, "period_start must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_version_fk_to_contract(energy_db):
|
||||||
|
"""energy_contract_version.contract_id must have a FK referencing energy_contract.id."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
fks = inspector.get_foreign_keys("energy_contract_version")
|
||||||
|
assert len(fks) == 1, f"Expected 1 FK on energy_contract_version, got {len(fks)}"
|
||||||
|
fk = fks[0]
|
||||||
|
assert fk["referred_table"] == "energy_contract"
|
||||||
|
assert "contract_id" in fk["constrained_columns"]
|
||||||
|
assert "id" in fk["referred_columns"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_fk_to_contract_version(energy_db):
|
||||||
|
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
fks = inspector.get_foreign_keys("energy_cost_period")
|
||||||
|
assert len(fks) == 1, f"Expected 1 FK on energy_cost_period, got {len(fks)}"
|
||||||
|
fk = fks[0]
|
||||||
|
assert fk["referred_table"] == "energy_contract_version"
|
||||||
|
assert "contract_version_id" in fk["constrained_columns"]
|
||||||
|
assert "id" in fk["referred_columns"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_removes_energy_tables(tmp_path: Path):
|
||||||
|
"""Downgrading to 20260622_10_exposed_entities must cleanly remove all five energy tables."""
|
||||||
|
db_path = tmp_path / "downgrade_energy_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
for table in ("dsmr_reading", "energy_contract", "energy_contract_version",
|
||||||
|
"tibber_price", "energy_cost_period"):
|
||||||
|
assert table in inspector.get_table_names(), f"{table} should exist after upgrade"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# Downgrade to the revision just before ours — the explicit down_revision.
|
||||||
|
command.downgrade(alembic_cfg, "20260622_10_exposed_entities")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
table_names = inspector.get_table_names()
|
||||||
|
for table in ("dsmr_reading", "energy_contract", "energy_contract_version",
|
||||||
|
"tibber_price", "energy_cost_period"):
|
||||||
|
assert table not in table_names, f"{table} should be gone after downgrade"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_decouple_migration_downgrade_restores_source_id_unique(tmp_path: Path):
|
||||||
|
"""Downgrading the telegram-id-decouple migration to 20260623_11 must restore the
|
||||||
|
original constraints: source_id UNIQUE, recorded_at non-unique index."""
|
||||||
|
db_path = tmp_path / "dsmr_decouple_downgrade.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
# At head: recorded_at is unique, source_id is not.
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
head_unique = [
|
||||||
|
c for uc in inspector.get_unique_constraints("dsmr_reading") for c in uc["column_names"]
|
||||||
|
]
|
||||||
|
assert "recorded_at" in head_unique
|
||||||
|
assert "source_id" not in head_unique
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# Downgrade just this migration.
|
||||||
|
command.downgrade(alembic_cfg, "20260623_11_energy_tables")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
pre_unique = [
|
||||||
|
c for uc in inspector.get_unique_constraints("dsmr_reading") for c in uc["column_names"]
|
||||||
|
]
|
||||||
|
assert "source_id" in pre_unique, "source_id unique must be restored on downgrade"
|
||||||
|
assert "recorded_at" not in pre_unique
|
||||||
|
index_names = {idx["name"] for idx in inspector.get_indexes("dsmr_reading")}
|
||||||
|
assert "ix_dsmr_reading_recorded_at" in index_names
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. ORM metadata checks (Base.metadata)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_metadata_contains_energy_tables():
|
||||||
|
"""Base.metadata.tables must include all five new energy tables."""
|
||||||
|
expected = {
|
||||||
|
"dsmr_reading",
|
||||||
|
"energy_contract",
|
||||||
|
"energy_contract_version",
|
||||||
|
"tibber_price",
|
||||||
|
"energy_cost_period",
|
||||||
|
}
|
||||||
|
for table in expected:
|
||||||
|
assert table in Base.metadata.tables, f"{table!r} not in Base.metadata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_payload_is_json():
|
||||||
|
"""DsmrReading.payload must be mapped as a JSON column in ORM metadata."""
|
||||||
|
from sqlalchemy.types import JSON as JSON_TYPE
|
||||||
|
table = Base.metadata.tables["dsmr_reading"]
|
||||||
|
col = table.columns["payload"]
|
||||||
|
assert isinstance(col.type, JSON_TYPE), "payload column must be JSON type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_version_values_is_json():
|
||||||
|
"""EnergyContractVersion.values must be mapped as a JSON column in ORM metadata."""
|
||||||
|
from sqlalchemy.types import JSON as JSON_TYPE
|
||||||
|
table = Base.metadata.tables["energy_contract_version"]
|
||||||
|
col = table.columns["values"]
|
||||||
|
assert isinstance(col.type, JSON_TYPE), "values column must be JSON type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_pricing_is_json():
|
||||||
|
"""EnergyCostPeriod.pricing must be mapped as a JSON column in ORM metadata."""
|
||||||
|
from sqlalchemy.types import JSON as JSON_TYPE
|
||||||
|
table = Base.metadata.tables["energy_cost_period"]
|
||||||
|
col = table.columns["pricing"]
|
||||||
|
assert isinstance(col.type, JSON_TYPE), "pricing column must be JSON type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_version_fk_ondelete_restrict():
|
||||||
|
"""The FK from energy_contract_version.contract_id must be ON DELETE RESTRICT."""
|
||||||
|
table = Base.metadata.tables["energy_contract_version"]
|
||||||
|
contract_id_col = table.columns["contract_id"]
|
||||||
|
assert contract_id_col.foreign_keys, "contract_id must have a foreign key"
|
||||||
|
fk = next(iter(contract_id_col.foreign_keys))
|
||||||
|
assert fk.ondelete == "RESTRICT", (
|
||||||
|
f"FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_fk_ondelete_restrict():
|
||||||
|
"""The FK from energy_cost_period.contract_version_id must be ON DELETE RESTRICT."""
|
||||||
|
table = Base.metadata.tables["energy_cost_period"]
|
||||||
|
col = table.columns["contract_version_id"]
|
||||||
|
assert col.foreign_keys, "contract_version_id must have a foreign key"
|
||||||
|
fk = next(iter(col.foreign_keys))
|
||||||
|
assert fk.ondelete == "RESTRICT", (
|
||||||
|
f"FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_recorded_at_unique_in_metadata():
|
||||||
|
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
|
||||||
|
and source_id must NOT be unique (decoupled from the telegram id)."""
|
||||||
|
table = Base.metadata.tables["dsmr_reading"]
|
||||||
|
assert table.columns["recorded_at"].unique, "recorded_at must be declared unique"
|
||||||
|
assert not table.columns["source_id"].unique, "source_id must NOT be unique"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tibber_price_starts_at_unique_in_metadata():
|
||||||
|
"""TibberPrice.starts_at must be declared unique in ORM metadata."""
|
||||||
|
table = Base.metadata.tables["tibber_price"]
|
||||||
|
col = table.columns["starts_at"]
|
||||||
|
assert col.unique, "starts_at must be declared unique"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Baseline constant
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_baseline_revision_matches_head(tmp_path: Path):
|
||||||
|
"""APP_BASELINE_REVISION must equal the Alembic head revision."""
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
db_url = f"sqlite:///{tmp_path / 'rev_check.db'}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
script = ScriptDirectory.from_config(alembic_cfg)
|
||||||
|
heads = script.get_heads()
|
||||||
|
assert len(heads) == 1, f"Expected exactly 1 Alembic head, got {heads}"
|
||||||
|
head = heads[0]
|
||||||
|
assert APP_BASELINE_REVISION == head, (
|
||||||
|
f"APP_BASELINE_REVISION={APP_BASELINE_REVISION!r} does not match "
|
||||||
|
f"Alembic head={head!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. ORM round-trip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_insert_and_retrieve(energy_db):
|
||||||
|
"""A DsmrReading can be inserted with a JSON payload and retrieved correctly."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
sample_payload = {
|
||||||
|
"timestamp": "2026-06-23T10:00:00+00:00",
|
||||||
|
"electricity_delivered_1": "20915.154",
|
||||||
|
"electricity_delivered_2": "18372.099",
|
||||||
|
"electricity_returned_1": "1234.567",
|
||||||
|
"electricity_returned_2": "890.123",
|
||||||
|
"current_electricity_usage": "1.234",
|
||||||
|
"extra_device_timestamp": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
reading = DsmrReading(
|
||||||
|
recorded_at=now,
|
||||||
|
source_id=42,
|
||||||
|
payload=sample_payload,
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
reading_id = reading.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(DsmrReading, reading_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.source_id == 42
|
||||||
|
assert fetched.payload == sample_payload
|
||||||
|
assert fetched.payload["electricity_delivered_1"] == "20915.154"
|
||||||
|
assert fetched.payload["extra_device_timestamp"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsmr_reading_source_id_nullable(energy_db):
|
||||||
|
"""DsmrReading can be inserted without a source_id (nullable)."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
reading = DsmrReading(
|
||||||
|
recorded_at=now,
|
||||||
|
source_id=None,
|
||||||
|
payload={"test": True},
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
reading_id = reading.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(DsmrReading, reading_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.source_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_insert_and_retrieve(energy_db):
|
||||||
|
"""An EnergyContract can be inserted and retrieved with correct defaults."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="My Manual Contract",
|
||||||
|
kind="manual",
|
||||||
|
active=True,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.commit()
|
||||||
|
contract_id = contract.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(EnergyContract, contract_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.name == "My Manual Contract"
|
||||||
|
assert fetched.kind == "manual"
|
||||||
|
assert fetched.active is True
|
||||||
|
assert fetched.currency == "EUR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_contract_version_insert_and_retrieve(energy_db):
|
||||||
|
"""An EnergyContractVersion can be inserted with a JSON values blob."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
pricing_values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {
|
||||||
|
"network_fee": 9.87,
|
||||||
|
"management_fee": 9.87,
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"heffingskorting": 600.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Fixed Rate 2026",
|
||||||
|
kind="manual",
|
||||||
|
active=False,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
version = EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=now,
|
||||||
|
effective_to=None,
|
||||||
|
values=pricing_values,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
version_id = version.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(EnergyContractVersion, version_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.effective_to is None
|
||||||
|
assert fetched.values == pricing_values
|
||||||
|
assert fetched.values["energy"]["buy"]["normal"] == 0.133
|
||||||
|
|
||||||
|
|
||||||
|
def test_tibber_price_insert_and_retrieve(energy_db):
|
||||||
|
"""A TibberPrice can be inserted and retrieved correctly."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
price = TibberPrice(
|
||||||
|
starts_at=now,
|
||||||
|
resolution="QUARTER_HOURLY",
|
||||||
|
energy=0.08,
|
||||||
|
tax=0.05,
|
||||||
|
total=0.13,
|
||||||
|
level="NORMAL",
|
||||||
|
currency="EUR",
|
||||||
|
fetched_at=now,
|
||||||
|
)
|
||||||
|
session.add(price)
|
||||||
|
session.commit()
|
||||||
|
price_id = price.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(TibberPrice, price_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.total == 0.13
|
||||||
|
assert fetched.level == "NORMAL"
|
||||||
|
assert fetched.currency == "EUR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tibber_price_level_nullable(energy_db):
|
||||||
|
"""TibberPrice.level can be None."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
from datetime import timedelta
|
||||||
|
other_time = now + timedelta(minutes=15)
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
price = TibberPrice(
|
||||||
|
starts_at=other_time,
|
||||||
|
resolution="QUARTER_HOURLY",
|
||||||
|
energy=0.07,
|
||||||
|
tax=0.04,
|
||||||
|
total=0.11,
|
||||||
|
level=None,
|
||||||
|
currency="EUR",
|
||||||
|
fetched_at=now,
|
||||||
|
)
|
||||||
|
session.add(price)
|
||||||
|
session.commit()
|
||||||
|
price_id = price.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(TibberPrice, price_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.level is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_insert_and_retrieve(energy_db):
|
||||||
|
"""An EnergyCostPeriod can be inserted with a JSON pricing snapshot."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
pricing_snapshot = {
|
||||||
|
"kind": "manual",
|
||||||
|
"buy_normal": 0.133,
|
||||||
|
"buy_dal": 0.127,
|
||||||
|
"sell_normal": 0.05,
|
||||||
|
"sell_dal": 0.05,
|
||||||
|
}
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=now,
|
||||||
|
d1_kwh=0.5,
|
||||||
|
d2_kwh=1.2,
|
||||||
|
r1_kwh=0.0,
|
||||||
|
r2_kwh=0.0,
|
||||||
|
import_cost=0.225,
|
||||||
|
export_revenue=0.0,
|
||||||
|
net_cost=0.225,
|
||||||
|
currency="EUR",
|
||||||
|
pricing=pricing_snapshot,
|
||||||
|
contract_version_id=None,
|
||||||
|
degraded=False,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
session.commit()
|
||||||
|
period_id = period.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(EnergyCostPeriod, period_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.d1_kwh == 0.5
|
||||||
|
assert fetched.d2_kwh == 1.2
|
||||||
|
assert fetched.net_cost == 0.225
|
||||||
|
assert fetched.degraded is False
|
||||||
|
assert fetched.contract_version_id is None
|
||||||
|
assert fetched.pricing == pricing_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. FK RESTRICT enforcement tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_version_restrict_prevents_contract_deletion(
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""Deleting an EnergyContract with versions must fail due to ON DELETE RESTRICT."""
|
||||||
|
db_path = tmp_path / "restrict_contract_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="RESTRICT Test Contract",
|
||||||
|
kind="manual",
|
||||||
|
active=False,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
version = EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=now,
|
||||||
|
effective_to=None,
|
||||||
|
values={"test": True},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
contract_id = contract.id
|
||||||
|
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(
|
||||||
|
text("DELETE FROM energy_contract WHERE id = :cid"),
|
||||||
|
{"cid": contract_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_period_restrict_prevents_version_deletion(tmp_path: Path):
|
||||||
|
"""Deleting an EnergyContractVersion with cost periods must fail due to ON DELETE RESTRICT."""
|
||||||
|
db_path = tmp_path / "restrict_version_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="RESTRICT Version Test",
|
||||||
|
kind="manual",
|
||||||
|
active=False,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
version = EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=now,
|
||||||
|
effective_to=None,
|
||||||
|
values={"test": True},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=now,
|
||||||
|
d1_kwh=0.1,
|
||||||
|
d2_kwh=0.2,
|
||||||
|
r1_kwh=0.0,
|
||||||
|
r2_kwh=0.0,
|
||||||
|
import_cost=0.05,
|
||||||
|
export_revenue=0.0,
|
||||||
|
net_cost=0.05,
|
||||||
|
currency="EUR",
|
||||||
|
pricing={"kind": "manual"},
|
||||||
|
contract_version_id=version.id,
|
||||||
|
degraded=False,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
session.commit()
|
||||||
|
version_id = version.id
|
||||||
|
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(
|
||||||
|
text("DELETE FROM energy_contract_version WHERE id = :vid"),
|
||||||
|
{"vid": version_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
@@ -203,16 +203,29 @@ def test_register_provider_direct_call():
|
|||||||
|
|
||||||
|
|
||||||
def test_build_catalog_empty_with_no_devices(expose_db):
|
def test_build_catalog_empty_with_no_devices(expose_db):
|
||||||
"""build_catalog with no enabled devices must return an empty list."""
|
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
||||||
|
|
||||||
|
The energy_cost provider is always registered and always produces its 4 entities
|
||||||
|
regardless of device state, so the catalog will not be empty. This test checks
|
||||||
|
that no *modbus* entities are present when there are no enabled modbus devices.
|
||||||
|
"""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
with Session(expose_db) as session:
|
with Session(expose_db) as session:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
# The modbus provider will find no enabled devices; other providers may
|
# The modbus provider finds no enabled devices → no modbus entities.
|
||||||
# add entries only if registered. Since we only register the modbus
|
# The energy_cost provider always produces 4 entities, so the catalog is non-empty.
|
||||||
# provider at module load, the result should be empty.
|
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
||||||
assert catalog == []
|
assert modbus_entities == [], (
|
||||||
|
"Expected no modbus entities when no modbus devices are enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The 4 energy_cost entities should always be present.
|
||||||
|
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
||||||
|
assert len(energy_keys) == 4, (
|
||||||
|
f"Expected exactly 4 energy_cost entities, got {energy_keys!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_catalog_produces_entities_for_enabled_device(expose_db):
|
def test_build_catalog_produces_entities_for_enabled_device(expose_db):
|
||||||
@@ -530,7 +543,13 @@ def test_exposed_entity_toggle_key_unique_index(expose_db):
|
|||||||
|
|
||||||
|
|
||||||
def test_downgrade_removes_toggle_table(tmp_path: Path):
|
def test_downgrade_removes_toggle_table(tmp_path: Path):
|
||||||
"""downgrade -1 from head must drop the exposed_entity_toggle table cleanly."""
|
"""Downgrading to the revision before exposed_entity_toggle must drop that table cleanly.
|
||||||
|
|
||||||
|
We downgrade to the explicit down_revision of the expose-toggle migration
|
||||||
|
(``20260622_09_modbus_tables``) rather than using ``-1`` from head, so the test
|
||||||
|
remains correct as new revisions are added on top. This is the same pattern
|
||||||
|
used in test_modbus_models.py for the modbus downgrade test.
|
||||||
|
"""
|
||||||
db_path = tmp_path / "downgrade_expose_test.db"
|
db_path = tmp_path / "downgrade_expose_test.db"
|
||||||
db_url = f"sqlite:///{db_path}"
|
db_url = f"sqlite:///{db_path}"
|
||||||
alembic_cfg = _make_app_alembic_config(db_url)
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
@@ -542,14 +561,15 @@ def test_downgrade_removes_toggle_table(tmp_path: Path):
|
|||||||
assert "exposed_entity_toggle" in inspector.get_table_names()
|
assert "exposed_entity_toggle" in inspector.get_table_names()
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
# Downgrade one step removes the exposed_entity_toggle revision.
|
# Downgrade to the explicit down_revision of the exposed_entity_toggle migration,
|
||||||
command.downgrade(alembic_cfg, "-1")
|
# consistent with how test_modbus_models.py handles its own downgrade test.
|
||||||
|
command.downgrade(alembic_cfg, "20260622_09_modbus_tables")
|
||||||
|
|
||||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
table_names = inspector.get_table_names()
|
table_names = inspector.get_table_names()
|
||||||
assert "exposed_entity_toggle" not in table_names, (
|
assert "exposed_entity_toggle" not in table_names, (
|
||||||
"exposed_entity_toggle should be gone after downgrade -1"
|
"exposed_entity_toggle should be gone after downgrade to 20260622_09_modbus_tables"
|
||||||
)
|
)
|
||||||
# Previous tables must still exist.
|
# Previous tables must still exist.
|
||||||
assert "modbus_device" in table_names
|
assert "modbus_device" in table_names
|
||||||
@@ -668,7 +688,7 @@ def test_expose_toggle_update(expose_db):
|
|||||||
|
|
||||||
|
|
||||||
def test_app_baseline_revision_matches_new_head(tmp_path: Path):
|
def test_app_baseline_revision_matches_new_head(tmp_path: Path):
|
||||||
"""APP_BASELINE_REVISION must equal the Alembic head after the new migration."""
|
"""APP_BASELINE_REVISION must equal the Alembic head revision."""
|
||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
||||||
@@ -683,7 +703,3 @@ def test_app_baseline_revision_matches_new_head(tmp_path: Path):
|
|||||||
f"APP_BASELINE_REVISION={APP_BASELINE_REVISION!r} does not match "
|
f"APP_BASELINE_REVISION={APP_BASELINE_REVISION!r} does not match "
|
||||||
f"Alembic head={head!r}"
|
f"Alembic head={head!r}"
|
||||||
)
|
)
|
||||||
# Confirm the new revision is the head.
|
|
||||||
assert head == "20260622_10_exposed_entities", (
|
|
||||||
f"Expected new head to be '20260622_10_exposed_entities', got {head!r}"
|
|
||||||
)
|
|
||||||
|
|||||||
+200
-12
@@ -90,11 +90,13 @@ def _make_settings(
|
|||||||
mqtt_enabled: bool = True,
|
mqtt_enabled: bool = True,
|
||||||
ha_discovery_enabled: bool = True,
|
ha_discovery_enabled: bool = True,
|
||||||
ha_discovery_prefix: str = "homeassistant",
|
ha_discovery_prefix: str = "homeassistant",
|
||||||
|
ha_state_topic_prefix: str = "home_automation",
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
s = MagicMock()
|
s = MagicMock()
|
||||||
s.mqtt_enabled = mqtt_enabled
|
s.mqtt_enabled = mqtt_enabled
|
||||||
s.ha_discovery_enabled = ha_discovery_enabled
|
s.ha_discovery_enabled = ha_discovery_enabled
|
||||||
s.ha_discovery_prefix = ha_discovery_prefix
|
s.ha_discovery_prefix = ha_discovery_prefix
|
||||||
|
s.ha_state_topic_prefix = ha_state_topic_prefix
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ def test_build_discovery_payload_topic_format() -> None:
|
|||||||
name="My Meter Voltage",
|
name="My Meter Voltage",
|
||||||
)
|
)
|
||||||
|
|
||||||
topic, config = build_discovery_payload(entity, prefix="homeassistant")
|
topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
# node_id replaces hyphens with underscores
|
# node_id replaces hyphens with underscores
|
||||||
node_id = uuid_val.replace("-", "_")
|
node_id = uuid_val.replace("-", "_")
|
||||||
@@ -168,7 +170,7 @@ def test_build_discovery_payload_unique_id_from_uuid() -> None:
|
|||||||
name="Meter A Current",
|
name="Meter A Current",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config = build_discovery_payload(entity, prefix="homeassistant")
|
_, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
# unique_id must contain the device uuid (normalised)
|
# unique_id must contain the device uuid (normalised)
|
||||||
uid = config["unique_id"].replace("-", "").replace("_", "")
|
uid = config["unique_id"].replace("-", "").replace("_", "")
|
||||||
@@ -210,8 +212,8 @@ def test_build_discovery_payload_unique_id_is_stable_across_renames() -> None:
|
|||||||
name="New Name Voltage",
|
name="New Name Voltage",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config_a = build_discovery_payload(entity_a, prefix="homeassistant")
|
_, config_a = build_discovery_payload(entity_a, discovery_prefix="homeassistant")
|
||||||
_, config_b = build_discovery_payload(entity_b, prefix="homeassistant")
|
_, config_b = build_discovery_payload(entity_b, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
assert config_a["unique_id"] == config_b["unique_id"], (
|
assert config_a["unique_id"] == config_b["unique_id"], (
|
||||||
"unique_id must not change when friendly_name changes"
|
"unique_id must not change when friendly_name changes"
|
||||||
@@ -234,7 +236,7 @@ def test_build_discovery_payload_device_block() -> None:
|
|||||||
name="Friendly Name Voltage",
|
name="Friendly Name Voltage",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config = build_discovery_payload(entity, prefix="homeassistant")
|
_, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
assert "device" in config
|
assert "device" in config
|
||||||
assert "identifiers" in config["device"]
|
assert "identifiers" in config["device"]
|
||||||
@@ -259,7 +261,7 @@ def test_build_discovery_payload_sensor_fields() -> None:
|
|||||||
state_class="total_increasing",
|
state_class="total_increasing",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config = build_discovery_payload(entity, prefix="homeassistant")
|
_, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
assert config["device_class"] == "energy"
|
assert config["device_class"] == "energy"
|
||||||
assert config["unit_of_measurement"] == "kWh"
|
assert config["unit_of_measurement"] == "kWh"
|
||||||
@@ -284,7 +286,7 @@ def test_build_discovery_payload_binary_sensor() -> None:
|
|||||||
name="Meter Online Online",
|
name="Meter Online Online",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config = build_discovery_payload(entity, prefix="homeassistant")
|
_, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
assert config.get("payload_on") == "ON"
|
assert config.get("payload_on") == "ON"
|
||||||
assert config.get("payload_off") == "OFF"
|
assert config.get("payload_off") == "OFF"
|
||||||
@@ -307,7 +309,7 @@ def test_build_discovery_payload_state_topic_matches_node_and_object() -> None:
|
|||||||
name="Meter Voltage",
|
name="Meter Voltage",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config = build_discovery_payload(entity, prefix="homeassistant")
|
_, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
state_topic = config["state_topic"]
|
state_topic = config["state_topic"]
|
||||||
assert "homeassistant/sensor/" in state_topic
|
assert "homeassistant/sensor/" in state_topic
|
||||||
@@ -991,7 +993,7 @@ def test_build_discovery_payload_includes_availability_topic() -> None:
|
|||||||
name="Avail Meter Voltage",
|
name="Avail Meter Voltage",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, config = build_discovery_payload(entity, prefix="homeassistant")
|
_, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||||
|
|
||||||
expected_avail_topic = _availability_topic(uuid_val, "homeassistant")
|
expected_avail_topic = _availability_topic(uuid_val, "homeassistant")
|
||||||
assert "availability" in config
|
assert "availability" in config
|
||||||
@@ -1142,7 +1144,9 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
|
|||||||
topics_to_payloads: dict[str, Any] = {t: p for t, p in published}
|
topics_to_payloads: dict[str, Any] = {t: p for t, p in published}
|
||||||
|
|
||||||
# Availability topic must be "online".
|
# Availability topic must be "online".
|
||||||
avail_topic = f"homeassistant/modbus/{uuid_val.replace('-', '_')}/availability"
|
# State/availability topics now use ha_state_topic_prefix ("home_automation"), not
|
||||||
|
# ha_discovery_prefix ("homeassistant").
|
||||||
|
avail_topic = f"home_automation/modbus/{uuid_val.replace('-', '_')}/availability"
|
||||||
assert avail_topic in topics_to_payloads, (
|
assert avail_topic in topics_to_payloads, (
|
||||||
f"Availability topic {avail_topic!r} not published. All topics: {list(topics_to_payloads)}"
|
f"Availability topic {avail_topic!r} not published. All topics: {list(topics_to_payloads)}"
|
||||||
)
|
)
|
||||||
@@ -1153,7 +1157,8 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
|
|||||||
# Voltage state topic must carry the real value from the reading (231.5).
|
# Voltage state topic must carry the real value from the reading (231.5).
|
||||||
node_id = uuid_val.replace("-", "_")
|
node_id = uuid_val.replace("-", "_")
|
||||||
voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage"
|
voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage"
|
||||||
voltage_state_topic = f"homeassistant/sensor/{node_id}/{voltage_obj_id}/state"
|
# State topics use ha_state_topic_prefix ("home_automation").
|
||||||
|
voltage_state_topic = f"home_automation/sensor/{node_id}/{voltage_obj_id}/state"
|
||||||
assert voltage_state_topic in topics_to_payloads, (
|
assert voltage_state_topic in topics_to_payloads, (
|
||||||
f"Voltage state topic {voltage_state_topic!r} not published. "
|
f"Voltage state topic {voltage_state_topic!r} not published. "
|
||||||
f"Sensor state topics: {[t for t in topics_to_payloads if 'sensor' in t and 'state' in t]}"
|
f"Sensor state topics: {[t for t in topics_to_payloads if 'sensor' in t and 'state' in t]}"
|
||||||
@@ -1165,7 +1170,8 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
|
|||||||
|
|
||||||
# Online binary_sensor state topic must be "ON" (last_poll_ok=True).
|
# Online binary_sensor state topic must be "ON" (last_poll_ok=True).
|
||||||
online_obj_id = f"modbus_{uuid_val.replace('-', '_')}_online"
|
online_obj_id = f"modbus_{uuid_val.replace('-', '_')}_online"
|
||||||
online_state_topic = f"homeassistant/binary_sensor/{node_id}/{online_obj_id}/state"
|
# State topics use ha_state_topic_prefix ("home_automation").
|
||||||
|
online_state_topic = f"home_automation/binary_sensor/{node_id}/{online_obj_id}/state"
|
||||||
assert online_state_topic in topics_to_payloads, (
|
assert online_state_topic in topics_to_payloads, (
|
||||||
f"Online state topic {online_state_topic!r} not published. "
|
f"Online state topic {online_state_topic!r} not published. "
|
||||||
f"Topics: {list(topics_to_payloads)}"
|
f"Topics: {list(topics_to_payloads)}"
|
||||||
@@ -1173,3 +1179,185 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
|
|||||||
assert topics_to_payloads[online_state_topic] == "ON", (
|
assert topics_to_payloads[online_state_topic] == "ON", (
|
||||||
f"Expected 'ON' for online sensor, got {topics_to_payloads[online_state_topic]!r}"
|
f"Expected 'ON' for online sensor, got {topics_to_payloads[online_state_topic]!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. Topic prefix separation: config vs state/availability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_discovery_payload_state_topic_uses_state_prefix() -> None:
|
||||||
|
"""When discovery_prefix and state_prefix differ, state_topic must use state_prefix."""
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
from app.services.ha_discovery import build_discovery_payload
|
||||||
|
|
||||||
|
uuid_val = "aaaaaaaa-1111-2222-3333-444444444444"
|
||||||
|
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Split Prefix Meter")
|
||||||
|
entity = ExposableEntity(
|
||||||
|
key=f"modbus.{uuid_val}.voltage",
|
||||||
|
component="sensor",
|
||||||
|
device=device,
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="Split Prefix Meter Voltage",
|
||||||
|
)
|
||||||
|
|
||||||
|
topic, config = build_discovery_payload(
|
||||||
|
entity,
|
||||||
|
discovery_prefix="homeassistant",
|
||||||
|
state_prefix="home_automation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Config topic must use the discovery prefix.
|
||||||
|
assert topic.startswith("homeassistant/"), (
|
||||||
|
f"Config topic must start with 'homeassistant/', got {topic!r}"
|
||||||
|
)
|
||||||
|
assert topic.endswith("/config"), f"Config topic must end with /config, got {topic!r}"
|
||||||
|
|
||||||
|
# State topic in payload must use the state prefix.
|
||||||
|
state_topic = config["state_topic"]
|
||||||
|
assert state_topic.startswith("home_automation/"), (
|
||||||
|
f"state_topic must start with 'home_automation/', got {state_topic!r}"
|
||||||
|
)
|
||||||
|
assert state_topic.endswith("/state"), f"state_topic must end with /state, got {state_topic!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_discovery_payload_availability_topic_uses_state_prefix() -> None:
|
||||||
|
"""When state_prefix differs from discovery_prefix, availability must use state_prefix."""
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
from app.services.ha_discovery import build_discovery_payload
|
||||||
|
|
||||||
|
uuid_val = "bbbbbbbb-1111-2222-3333-444444444444"
|
||||||
|
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Avail Split Meter")
|
||||||
|
entity = ExposableEntity(
|
||||||
|
key=f"modbus.{uuid_val}.voltage",
|
||||||
|
component="sensor",
|
||||||
|
device=device,
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="Avail Split Meter Voltage",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, config = build_discovery_payload(
|
||||||
|
entity,
|
||||||
|
discovery_prefix="homeassistant",
|
||||||
|
state_prefix="home_automation",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "availability" in config, "Entity with provides_availability=True must have availability"
|
||||||
|
avail_topics = [a["topic"] for a in config["availability"]]
|
||||||
|
assert len(avail_topics) == 1
|
||||||
|
avail_topic = avail_topics[0]
|
||||||
|
assert avail_topic.startswith("home_automation/"), (
|
||||||
|
f"Availability topic must start with 'home_automation/', got {avail_topic!r}"
|
||||||
|
)
|
||||||
|
# The /modbus/ path segment must still be present (hardcoded structure preserved).
|
||||||
|
assert "/modbus/" in avail_topic, (
|
||||||
|
f"Availability topic must contain /modbus/, got {avail_topic!r}"
|
||||||
|
)
|
||||||
|
assert avail_topic.endswith("/availability"), (
|
||||||
|
f"Availability topic must end with /availability, got {avail_topic!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_discovery_payload_custom_state_prefix_changes_state_not_config() -> None:
|
||||||
|
"""Custom ha_state_topic_prefix changes state/availability topics but NOT config topic."""
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
from app.services.ha_discovery import build_discovery_payload
|
||||||
|
|
||||||
|
uuid_val = "cccccccc-5555-6666-7777-888888888888"
|
||||||
|
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Custom Prefix Meter")
|
||||||
|
entity = ExposableEntity(
|
||||||
|
key=f"modbus.{uuid_val}.current",
|
||||||
|
component="sensor",
|
||||||
|
device=device,
|
||||||
|
device_class="current",
|
||||||
|
unit="A",
|
||||||
|
name="Custom Prefix Meter Current",
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_default, config_default = build_discovery_payload(
|
||||||
|
entity,
|
||||||
|
discovery_prefix="homeassistant",
|
||||||
|
state_prefix="home_automation",
|
||||||
|
)
|
||||||
|
topic_custom, config_custom = build_discovery_payload(
|
||||||
|
entity,
|
||||||
|
discovery_prefix="homeassistant",
|
||||||
|
state_prefix="custom_prefix",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Config topic must be identical (discovery_prefix unchanged).
|
||||||
|
assert topic_default == topic_custom, (
|
||||||
|
f"Config topic must not change with different state_prefix. "
|
||||||
|
f"default={topic_default!r}, custom={topic_custom!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# State topic must differ.
|
||||||
|
assert config_default["state_topic"] != config_custom["state_topic"], (
|
||||||
|
"state_topic must change when state_prefix changes"
|
||||||
|
)
|
||||||
|
assert config_custom["state_topic"].startswith("custom_prefix/"), (
|
||||||
|
f"Custom state_topic must start with 'custom_prefix/', got {config_custom['state_topic']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Availability topic must differ.
|
||||||
|
avail_default = config_default["availability"][0]["topic"]
|
||||||
|
avail_custom = config_custom["availability"][0]["topic"]
|
||||||
|
assert avail_default != avail_custom, (
|
||||||
|
"Availability topic must change when state_prefix changes"
|
||||||
|
)
|
||||||
|
assert avail_custom.startswith("custom_prefix/"), (
|
||||||
|
f"Custom avail topic must start with 'custom_prefix/', got {avail_custom!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_ha_state_topic_prefix_default_and_configurable() -> None:
|
||||||
|
"""ha_state_topic_prefix must default to 'home_automation' and be configurable."""
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
# Default value.
|
||||||
|
s = Settings(_env_file=None)
|
||||||
|
assert s.ha_state_topic_prefix == "home_automation", (
|
||||||
|
f"Default ha_state_topic_prefix must be 'home_automation', got {s.ha_state_topic_prefix!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configurable via constructor.
|
||||||
|
s2 = Settings(_env_file=None, ha_state_topic_prefix="custom_state")
|
||||||
|
assert s2.ha_state_topic_prefix == "custom_state", (
|
||||||
|
f"ha_state_topic_prefix must be overridable, got {s2.ha_state_topic_prefix!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_page_ha_state_topic_prefix_in_discovery_section() -> None:
|
||||||
|
"""HA_STATE_TOPIC_PREFIX must appear in the 'Home Assistant Discovery' config section."""
|
||||||
|
from app.services.config_page import CONFIG_FIELDS
|
||||||
|
|
||||||
|
ha_state_field = next(
|
||||||
|
(f for f in CONFIG_FIELDS if f.env_name == "HA_STATE_TOPIC_PREFIX"), None
|
||||||
|
)
|
||||||
|
assert ha_state_field is not None, (
|
||||||
|
"HA_STATE_TOPIC_PREFIX must be registered in CONFIG_FIELDS"
|
||||||
|
)
|
||||||
|
assert ha_state_field.section == "Home Assistant Discovery", (
|
||||||
|
f"HA_STATE_TOPIC_PREFIX must be in 'Home Assistant Discovery' section, "
|
||||||
|
f"got {ha_state_field.section!r}"
|
||||||
|
)
|
||||||
|
assert ha_state_field.setting_attr == "ha_state_topic_prefix", (
|
||||||
|
f"setting_attr must be 'ha_state_topic_prefix', got {ha_state_field.setting_attr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_payload_includes_ha_state_topic_prefix() -> None:
|
||||||
|
"""_settings_payload must include ha_state_topic_prefix so it can be round-tripped."""
|
||||||
|
from app.config import Settings
|
||||||
|
from app.services.config_page import _settings_payload
|
||||||
|
|
||||||
|
s = Settings(_env_file=None, ha_state_topic_prefix="my_prefix")
|
||||||
|
payload = _settings_payload(s)
|
||||||
|
assert "ha_state_topic_prefix" in payload, (
|
||||||
|
"_settings_payload must include 'ha_state_topic_prefix'"
|
||||||
|
)
|
||||||
|
assert payload["ha_state_topic_prefix"] == "my_prefix", (
|
||||||
|
f"Expected 'my_prefix', got {payload['ha_state_topic_prefix']!r}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Tests for M6-T06: MqttManager subscribe + on_message dispatch.
|
||||||
|
|
||||||
|
All paho interaction is mocked — no real broker is required.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- subscribe() registers topic → handler in the internal registry.
|
||||||
|
- on_message dispatches the payload to the correct handler.
|
||||||
|
- A handler that raises does NOT propagate the exception out of on_message.
|
||||||
|
- Existing publish() behaviour is not regressed.
|
||||||
|
- When already connected, subscribe() calls client.subscribe() immediately.
|
||||||
|
- on_connect re-subscribes to all registered topics after (re-)connect.
|
||||||
|
- subscribe() before connect: topic is subscribed when connect fires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.integrations.mqtt import MqttManager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_settings(
|
||||||
|
*,
|
||||||
|
mqtt_enabled: bool = True,
|
||||||
|
mqtt_broker_host: str = "broker.test",
|
||||||
|
mqtt_broker_port: int = 1883,
|
||||||
|
mqtt_username: str = "",
|
||||||
|
mqtt_password: str = "",
|
||||||
|
mqtt_tls_enabled: bool = False,
|
||||||
|
):
|
||||||
|
s = MagicMock()
|
||||||
|
s.mqtt_enabled = mqtt_enabled
|
||||||
|
s.mqtt_broker_host = mqtt_broker_host
|
||||||
|
s.mqtt_broker_port = mqtt_broker_port
|
||||||
|
s.mqtt_username = mqtt_username
|
||||||
|
s.mqtt_password = mqtt_password
|
||||||
|
s.mqtt_tls_enabled = mqtt_tls_enabled
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mqtt_message(topic: str, payload: bytes) -> MagicMock:
|
||||||
|
"""Construct a minimal mock of a paho MQTTMessage."""
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.topic = topic
|
||||||
|
msg.payload = payload
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# subscribe() — registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_registers_handler() -> None:
|
||||||
|
"""subscribe() must store the handler in the internal _subscriptions dict."""
|
||||||
|
manager = MqttManager()
|
||||||
|
handler = MagicMock()
|
||||||
|
|
||||||
|
manager.subscribe("test/topic", handler)
|
||||||
|
|
||||||
|
assert "test/topic" in manager._subscriptions
|
||||||
|
assert manager._subscriptions["test/topic"] is handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_overwrites_previous_handler() -> None:
|
||||||
|
"""Subscribing to the same topic twice replaces the handler."""
|
||||||
|
manager = MqttManager()
|
||||||
|
h1 = MagicMock()
|
||||||
|
h2 = MagicMock()
|
||||||
|
|
||||||
|
manager.subscribe("test/topic", h1)
|
||||||
|
manager.subscribe("test/topic", h2)
|
||||||
|
|
||||||
|
assert manager._subscriptions["test/topic"] is h2
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_when_not_connected_does_not_call_paho_subscribe() -> None:
|
||||||
|
"""subscribe() before connect must not attempt to call client.subscribe()."""
|
||||||
|
real_manager = MqttManager()
|
||||||
|
handler = MagicMock()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
real_manager._client = None
|
||||||
|
real_manager._connected = False
|
||||||
|
|
||||||
|
real_manager.subscribe("dsmr/json", handler)
|
||||||
|
|
||||||
|
# No client → subscribe should not have been called on paho
|
||||||
|
mock_client.subscribe.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_when_connected_calls_paho_subscribe_immediately() -> None:
|
||||||
|
"""subscribe() while connected must call client.subscribe(topic) right away."""
|
||||||
|
manager = MqttManager()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
manager._client = mock_client
|
||||||
|
manager._connected = True
|
||||||
|
|
||||||
|
handler = MagicMock()
|
||||||
|
manager.subscribe("dsmr/json", handler)
|
||||||
|
|
||||||
|
mock_client.subscribe.assert_called_once_with("dsmr/json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_handler_from_registry() -> None:
|
||||||
|
"""unsubscribe() must drop the handler so it is not re-subscribed on reconnect."""
|
||||||
|
manager = MqttManager()
|
||||||
|
manager.subscribe("dsmr/json", MagicMock())
|
||||||
|
|
||||||
|
manager.unsubscribe("dsmr/json")
|
||||||
|
|
||||||
|
assert "dsmr/json" not in manager._subscriptions
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribe_when_connected_calls_paho_unsubscribe() -> None:
|
||||||
|
"""unsubscribe() while connected must call client.unsubscribe(topic)."""
|
||||||
|
manager = MqttManager()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
manager._client = mock_client
|
||||||
|
manager._connected = True
|
||||||
|
manager.subscribe("dsmr/json", MagicMock())
|
||||||
|
|
||||||
|
manager.unsubscribe("dsmr/json")
|
||||||
|
|
||||||
|
mock_client.unsubscribe.assert_called_once_with("dsmr/json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribe_unknown_topic_is_noop() -> None:
|
||||||
|
"""unsubscribe() on a topic that was never registered must not raise."""
|
||||||
|
manager = MqttManager()
|
||||||
|
manager.unsubscribe("never/registered") # must not raise
|
||||||
|
assert "never/registered" not in manager._subscriptions
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# on_message dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_on_message(manager: MqttManager, settings) -> object:
|
||||||
|
"""Connect with a mocked paho client and return the on_message callback that
|
||||||
|
was registered on the client mock."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
# The callback was assigned as: client.on_message = _on_message
|
||||||
|
return mock_client.on_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_message_dispatches_to_registered_handler() -> None:
|
||||||
|
"""on_message must invoke the handler registered for the message's topic."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
received_payloads: list[bytes] = []
|
||||||
|
|
||||||
|
def handler(payload: bytes) -> None:
|
||||||
|
received_payloads.append(payload)
|
||||||
|
|
||||||
|
manager.subscribe("dsmr/json", handler)
|
||||||
|
|
||||||
|
on_message = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
|
msg = _make_mqtt_message("dsmr/json", b'{"id": 1}')
|
||||||
|
on_message(MagicMock(), None, msg) # simulate paho calling the callback
|
||||||
|
|
||||||
|
assert received_payloads == [b'{"id": 1}']
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_message_ignores_unknown_topic() -> None:
|
||||||
|
"""on_message for an unregistered topic must not raise and must not call any handler."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
handler = MagicMock()
|
||||||
|
manager.subscribe("other/topic", handler)
|
||||||
|
|
||||||
|
on_message = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
|
msg = _make_mqtt_message("unknown/topic", b"data")
|
||||||
|
# Must not raise
|
||||||
|
on_message(MagicMock(), None, msg)
|
||||||
|
|
||||||
|
handler.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_message_swallows_handler_exception() -> None:
|
||||||
|
"""If the handler raises, on_message must NOT propagate the exception."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
def bad_handler(payload: bytes) -> None:
|
||||||
|
raise RuntimeError("handler exploded")
|
||||||
|
|
||||||
|
manager.subscribe("dsmr/json", bad_handler)
|
||||||
|
on_message = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
|
msg = _make_mqtt_message("dsmr/json", b"{}")
|
||||||
|
# This must not raise
|
||||||
|
on_message(MagicMock(), None, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None:
|
||||||
|
"""After a handler exception the manager remains functional for subsequent messages."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def flaky_handler(payload: bytes) -> None:
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
raise ValueError("first call fails")
|
||||||
|
|
||||||
|
manager.subscribe("dsmr/json", flaky_handler)
|
||||||
|
on_message = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
|
msg1 = _make_mqtt_message("dsmr/json", b"first")
|
||||||
|
msg2 = _make_mqtt_message("dsmr/json", b"second")
|
||||||
|
|
||||||
|
on_message(MagicMock(), None, msg1) # should not raise despite exception in handler
|
||||||
|
on_message(MagicMock(), None, msg2) # handler called again
|
||||||
|
|
||||||
|
assert call_count[0] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# on_connect re-subscribes registered topics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_connect_subscribes_all_registered_topics() -> None:
|
||||||
|
"""When connect fires successfully, _on_connect must subscribe every registered topic."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
manager.subscribe("topic/a", MagicMock())
|
||||||
|
manager.subscribe("topic/b", MagicMock())
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
# Capture the on_connect callback that was registered on the mock client
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
on_connect_cb = mock_client.on_connect
|
||||||
|
|
||||||
|
# Simulate broker accepting the connection (reason_code.is_failure == False)
|
||||||
|
reason_code = MagicMock()
|
||||||
|
reason_code.is_failure = False
|
||||||
|
on_connect_cb(mock_client, None, MagicMock(), reason_code, None)
|
||||||
|
|
||||||
|
# client.subscribe should have been called once per topic during on_connect
|
||||||
|
subscribe_calls = [c[0][0] for c in mock_client.subscribe.call_args_list]
|
||||||
|
assert "topic/a" in subscribe_calls
|
||||||
|
assert "topic/b" in subscribe_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_connect_does_not_subscribe_on_failure() -> None:
|
||||||
|
"""When the broker rejects the connection, _on_connect must NOT subscribe."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
manager.subscribe("topic/a", MagicMock())
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
on_connect_cb = mock_client.on_connect
|
||||||
|
reason_code = MagicMock()
|
||||||
|
reason_code.is_failure = True
|
||||||
|
|
||||||
|
mock_client.subscribe.reset_mock()
|
||||||
|
on_connect_cb(mock_client, None, MagicMock(), reason_code, None)
|
||||||
|
|
||||||
|
mock_client.subscribe.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Existing publish() is not regressed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_still_works_after_subscribe_registered() -> None:
|
||||||
|
"""Adding a subscription must not break publish() behaviour."""
|
||||||
|
manager = MqttManager()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
manager._client = mock_client
|
||||||
|
manager._connected = True
|
||||||
|
|
||||||
|
manager.subscribe("dsmr/json", MagicMock())
|
||||||
|
manager.publish("home/sensor", b"data", retain=True, qos=1)
|
||||||
|
|
||||||
|
mock_client.publish.assert_called_once_with(
|
||||||
|
"home/sensor", payload=b"data", qos=1, retain=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_skipped_when_not_connected_regression() -> None:
|
||||||
|
"""publish() must remain a no-op when not connected, even with subscriptions."""
|
||||||
|
manager = MqttManager()
|
||||||
|
manager.subscribe("dsmr/json", MagicMock())
|
||||||
|
|
||||||
|
# No client set up — publish should be silent
|
||||||
|
manager.publish("topic", b"payload") # must not raise
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""Tests for app/integrations/pricing/profiles.py.
|
||||||
|
|
||||||
|
Acceptance criteria covered
|
||||||
|
----------------------------
|
||||||
|
1. ``load_profile("manual")`` succeeds and returns a valid ``ManualProfile``.
|
||||||
|
2. ``load_profile("tibber")`` succeeds and returns a valid ``TibberProfile``.
|
||||||
|
3. Missing profile file raises ``ProfileNotFoundError``.
|
||||||
|
4. Malformed YAML (missing required fields) raises ``ProfileValidationError``.
|
||||||
|
5. Wrong kind in YAML raises ``ProfileValidationError``.
|
||||||
|
6. ``validate_values`` accepts conforming values for both kinds.
|
||||||
|
7. ``validate_values`` fills in default values (``ode``, ``sell_adjust``,
|
||||||
|
tibber ``management_fee``) when absent.
|
||||||
|
8. ``validate_values`` raises ``ProfileValidationError`` for missing required
|
||||||
|
fields (no default).
|
||||||
|
9. ``validate_values`` raises ``ProfileValidationError`` for wrong-typed fields.
|
||||||
|
10. ``list_profiles()`` returns both profiles in a list of dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.integrations.pricing.profiles import (
|
||||||
|
ManualProfile,
|
||||||
|
ProfileNotFoundError,
|
||||||
|
ProfileValidationError,
|
||||||
|
TibberProfile,
|
||||||
|
list_profiles,
|
||||||
|
load_profile,
|
||||||
|
validate_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1-2: load_profile happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadProfileManual:
|
||||||
|
"""Validate that the shipped manual.yaml loads and validates correctly."""
|
||||||
|
|
||||||
|
def test_returns_manual_profile_instance(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert isinstance(profile, ManualProfile)
|
||||||
|
|
||||||
|
def test_kind_is_manual(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.kind == "manual"
|
||||||
|
|
||||||
|
def test_has_label(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert isinstance(profile.label, str) and profile.label
|
||||||
|
|
||||||
|
def test_dual_tariff_is_true(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.energy.dual_tariff is True
|
||||||
|
|
||||||
|
def test_energy_buy_has_normal_and_dal(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.energy.buy.normal.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.buy.dal.unit == "EUR/kWh"
|
||||||
|
|
||||||
|
def test_energy_sell_has_normal_and_dal(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.energy.sell.normal.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.sell.dal.unit == "EUR/kWh"
|
||||||
|
|
||||||
|
def test_energy_tax_unit(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.energy.energy_tax.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.energy_tax.default is None # required field, no default
|
||||||
|
|
||||||
|
def test_ode_has_default_zero(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.energy.ode.default == 0
|
||||||
|
|
||||||
|
def test_standing_fields(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.standing.network_fee.unit == "EUR/month"
|
||||||
|
assert profile.standing.management_fee.unit == "EUR/month"
|
||||||
|
|
||||||
|
def test_credits_heffingskorting(self) -> None:
|
||||||
|
profile = load_profile("manual")
|
||||||
|
assert profile.credits.heffingskorting.unit == "EUR/year"
|
||||||
|
|
||||||
|
def test_no_concrete_values_in_profile(self) -> None:
|
||||||
|
"""Profile YAML must not contain any concrete price numbers."""
|
||||||
|
profile_path = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "app/integrations/pricing/profiles/manual.yaml"
|
||||||
|
)
|
||||||
|
with profile_path.open() as fh:
|
||||||
|
raw = yaml.safe_load(fh)
|
||||||
|
# Leaf nodes should only have 'unit' and optionally 'default: 0',
|
||||||
|
# not actual price values like 0.133 or 0.127.
|
||||||
|
energy = raw.get("energy", {})
|
||||||
|
buy = energy.get("buy", {})
|
||||||
|
# Leaf buy nodes: only 'unit' key, no numeric value key.
|
||||||
|
assert set(buy["normal"].keys()) == {"unit"}, (
|
||||||
|
"buy.normal leaf must only have 'unit'"
|
||||||
|
)
|
||||||
|
assert set(buy["dal"].keys()) == {"unit"}, (
|
||||||
|
"buy.dal leaf must only have 'unit'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadProfileTibber:
|
||||||
|
"""Validate that the shipped tibber.yaml loads and validates correctly."""
|
||||||
|
|
||||||
|
def test_returns_tibber_profile_instance(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert isinstance(profile, TibberProfile)
|
||||||
|
|
||||||
|
def test_kind_is_tibber(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.kind == "tibber"
|
||||||
|
|
||||||
|
def test_has_label(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert isinstance(profile.label, str) and profile.label
|
||||||
|
|
||||||
|
def test_energy_source_is_tibber_api(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.energy.source == "tibber_api"
|
||||||
|
|
||||||
|
def test_energy_tax_unit(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.energy.energy_tax.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.energy_tax.default is None # required
|
||||||
|
|
||||||
|
def test_sell_adjust_has_default_zero(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.energy.sell_adjust.default == 0
|
||||||
|
|
||||||
|
def test_management_fee_has_default(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.standing.management_fee.default is not None
|
||||||
|
assert isinstance(profile.standing.management_fee.default, float)
|
||||||
|
|
||||||
|
def test_network_fee_unit(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.standing.network_fee.unit == "EUR/month"
|
||||||
|
|
||||||
|
def test_credits_heffingskorting(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.credits.heffingskorting.unit == "EUR/year"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3-5: load_profile error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadProfileErrors:
|
||||||
|
def test_missing_profile_raises_not_found(self) -> None:
|
||||||
|
with pytest.raises(ProfileNotFoundError, match="nonexistent"):
|
||||||
|
load_profile("nonexistent")
|
||||||
|
|
||||||
|
def test_missing_required_fields_raises_validation_error(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A YAML missing required fields raises ProfileValidationError."""
|
||||||
|
bad_yaml = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
kind: manual
|
||||||
|
label: Bad manual profile
|
||||||
|
# missing energy / standing / credits sections entirely
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
bad_path = tmp_path / "manual.yaml"
|
||||||
|
bad_path.write_text(bad_yaml)
|
||||||
|
|
||||||
|
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
|
||||||
|
with pytest.raises(ProfileValidationError, match="manual"):
|
||||||
|
load_profile("manual")
|
||||||
|
|
||||||
|
def test_wrong_type_raises_validation_error(self, tmp_path: Path) -> None:
|
||||||
|
"""A YAML with a wrong type (string where dict is expected) raises ProfileValidationError."""
|
||||||
|
bad_yaml = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
kind: tibber
|
||||||
|
label: Wrong type profile
|
||||||
|
energy: "should be a mapping not a string"
|
||||||
|
standing:
|
||||||
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year }
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
bad_path = tmp_path / "tibber.yaml"
|
||||||
|
bad_path.write_text(bad_yaml)
|
||||||
|
|
||||||
|
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
|
||||||
|
with pytest.raises(ProfileValidationError, match="tibber"):
|
||||||
|
load_profile("tibber")
|
||||||
|
|
||||||
|
def test_unknown_kind_raises_validation_error(self, tmp_path: Path) -> None:
|
||||||
|
"""A YAML with an unknown kind raises ProfileValidationError."""
|
||||||
|
bad_yaml = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
kind: unknown_kind
|
||||||
|
label: Unknown kind profile
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
bad_path = tmp_path / "unknown_kind.yaml"
|
||||||
|
bad_path.write_text(bad_yaml)
|
||||||
|
|
||||||
|
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
|
||||||
|
with pytest.raises(ProfileValidationError, match="unknown_kind"):
|
||||||
|
load_profile("unknown_kind")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6-9: validate_values
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# A complete, conforming set of manual contract values.
|
||||||
|
_VALID_MANUAL_VALUES = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {
|
||||||
|
"network_fee": 9.87,
|
||||||
|
"management_fee": 9.87,
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"heffingskorting": 600.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# A complete, conforming set of tibber contract values.
|
||||||
|
_VALID_TIBBER_VALUES = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_adjust": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {
|
||||||
|
"management_fee": 5.99,
|
||||||
|
"network_fee": 9.87,
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"heffingskorting": 600.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateValuesManual:
|
||||||
|
def test_valid_values_accepted(self) -> None:
|
||||||
|
filled = validate_values("manual", dict(_VALID_MANUAL_VALUES))
|
||||||
|
# Should not raise and should return a dict.
|
||||||
|
assert isinstance(filled, dict)
|
||||||
|
|
||||||
|
def test_ode_default_applied_when_absent(self) -> None:
|
||||||
|
"""When 'ode' is not in values, the default (0) should be inserted."""
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
# ode is absent
|
||||||
|
},
|
||||||
|
"standing": {
|
||||||
|
"network_fee": 9.87,
|
||||||
|
"management_fee": 9.87,
|
||||||
|
},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
filled = validate_values("manual", values)
|
||||||
|
assert filled["energy"]["ode"] == 0
|
||||||
|
|
||||||
|
def test_missing_energy_tax_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
# energy_tax absent — no default
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 9.87, "management_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="energy_tax"):
|
||||||
|
validate_values("manual", values)
|
||||||
|
|
||||||
|
def test_missing_buy_normal_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"dal": 0.127}, # normal absent
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 9.87, "management_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="normal"):
|
||||||
|
validate_values("manual", values)
|
||||||
|
|
||||||
|
def test_wrong_type_for_energy_tax_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": "not_a_number", # wrong type
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 9.87, "management_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="energy_tax"):
|
||||||
|
validate_values("manual", values)
|
||||||
|
|
||||||
|
def test_missing_heffingskorting_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 9.87, "management_fee": 9.87},
|
||||||
|
"credits": {}, # heffingskorting absent — no default
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="heffingskorting"):
|
||||||
|
validate_values("manual", values)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateValuesTibber:
|
||||||
|
def test_valid_values_accepted(self) -> None:
|
||||||
|
filled = validate_values("tibber", dict(_VALID_TIBBER_VALUES))
|
||||||
|
assert isinstance(filled, dict)
|
||||||
|
|
||||||
|
def test_sell_adjust_default_applied_when_absent(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
# sell_adjust absent — has default 0
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
filled = validate_values("tibber", values)
|
||||||
|
assert filled["energy"]["sell_adjust"] == 0
|
||||||
|
|
||||||
|
def test_management_fee_default_applied_when_absent(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
||||||
|
"standing": {
|
||||||
|
"network_fee": 9.87,
|
||||||
|
# management_fee absent — has a default
|
||||||
|
},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
filled = validate_values("tibber", values)
|
||||||
|
assert "management_fee" in filled["standing"]
|
||||||
|
assert isinstance(filled["standing"]["management_fee"], float)
|
||||||
|
|
||||||
|
def test_missing_energy_tax_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {"sell_adjust": 0.0}, # energy_tax absent — no default
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="energy_tax"):
|
||||||
|
validate_values("tibber", values)
|
||||||
|
|
||||||
|
def test_missing_network_fee_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
||||||
|
"standing": {"management_fee": 5.99}, # network_fee absent — no default
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="network_fee"):
|
||||||
|
validate_values("tibber", values)
|
||||||
|
|
||||||
|
def test_wrong_type_for_sell_adjust_raises(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.1108, "sell_adjust": "zero"}, # wrong type
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(ProfileValidationError, match="sell_adjust"):
|
||||||
|
validate_values("tibber", values)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10: list_profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListProfiles:
|
||||||
|
def test_returns_list_of_dicts(self) -> None:
|
||||||
|
profiles = list_profiles()
|
||||||
|
assert isinstance(profiles, list)
|
||||||
|
for item in profiles:
|
||||||
|
assert isinstance(item, dict)
|
||||||
|
|
||||||
|
def test_contains_manual_and_tibber(self) -> None:
|
||||||
|
profiles = list_profiles()
|
||||||
|
kinds = {p["kind"] for p in profiles}
|
||||||
|
assert "manual" in kinds
|
||||||
|
assert "tibber" in kinds
|
||||||
|
|
||||||
|
def test_each_entry_has_label(self) -> None:
|
||||||
|
profiles = list_profiles()
|
||||||
|
for p in profiles:
|
||||||
|
assert "label" in p and isinstance(p["label"], str)
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
"""Tests for app/integrations/pricing/strategies.py.
|
||||||
|
|
||||||
|
Acceptance criteria covered
|
||||||
|
----------------------------
|
||||||
|
1. ``get_strategy("manual")`` / ``get_strategy("tibber")`` return registered callables.
|
||||||
|
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
||||||
|
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
||||||
|
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
||||||
|
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_adjust.
|
||||||
|
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
||||||
|
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
||||||
|
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
||||||
|
9. ``get_strategy`` raises KeyError for unknown kinds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.integrations.pricing.strategies import (
|
||||||
|
PeriodDeltas,
|
||||||
|
TibberPriceNotFoundError,
|
||||||
|
get_strategy,
|
||||||
|
register_strategy,
|
||||||
|
)
|
||||||
|
from app.models.energy import TibberPrice
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures: in-memory / temp-file SQLite with energy tables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app_alembic_config(database_url: str) -> Config:
|
||||||
|
cfg = Config("alembic_app.ini")
|
||||||
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def tibber_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to head (has tibber_price table)."""
|
||||||
|
db_path = tmp_path / "tibber_strategy_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(hour: int, minute: int = 0) -> datetime:
|
||||||
|
"""Return a UTC datetime on 2026-06-23 at the given hour:minute."""
|
||||||
|
return datetime(2026, 6, 23, hour, minute, 0, tzinfo=_UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_tibber_price(
|
||||||
|
session: Session,
|
||||||
|
starts_at: datetime,
|
||||||
|
total: float,
|
||||||
|
energy: float = 0.08,
|
||||||
|
tax: float | None = None,
|
||||||
|
currency: str = "EUR",
|
||||||
|
) -> TibberPrice:
|
||||||
|
"""Insert and flush a TibberPrice row; return the ORM instance."""
|
||||||
|
if tax is None:
|
||||||
|
tax = round(total - energy, 6)
|
||||||
|
row = TibberPrice(
|
||||||
|
starts_at=starts_at,
|
||||||
|
resolution="QUARTER_HOURLY",
|
||||||
|
energy=energy,
|
||||||
|
tax=tax,
|
||||||
|
total=total,
|
||||||
|
level="NORMAL",
|
||||||
|
currency=currency,
|
||||||
|
fetched_at=datetime.now(_UTC),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Registry round-trip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistry:
|
||||||
|
def test_manual_strategy_registered(self) -> None:
|
||||||
|
fn = get_strategy("manual")
|
||||||
|
assert callable(fn)
|
||||||
|
|
||||||
|
def test_tibber_strategy_registered(self) -> None:
|
||||||
|
fn = get_strategy("tibber")
|
||||||
|
assert callable(fn)
|
||||||
|
|
||||||
|
def test_unknown_kind_raises_key_error(self) -> None:
|
||||||
|
with pytest.raises(KeyError, match="no_such_kind"):
|
||||||
|
get_strategy("no_such_kind")
|
||||||
|
|
||||||
|
def test_register_and_retrieve_custom_strategy(self) -> None:
|
||||||
|
def _my_fn(deltas, t0, values, session):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
register_strategy("_test_custom", _my_fn)
|
||||||
|
assert get_strategy("_test_custom") is _my_fn
|
||||||
|
# Cleanup to avoid polluting other tests.
|
||||||
|
from app.integrations.pricing.strategies import _REGISTRY
|
||||||
|
_REGISTRY.pop("_test_custom", None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2-3. Manual strategy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Dummy session (manual strategy does not use the session).
|
||||||
|
_NO_SESSION = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualStrategy:
|
||||||
|
"""Verify manual dual-tariff price calculations."""
|
||||||
|
|
||||||
|
# Contract values for all manual tests.
|
||||||
|
_VALUES = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.11,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 9.87, "management_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _call(self, deltas: PeriodDeltas) -> dict:
|
||||||
|
fn = get_strategy("manual")
|
||||||
|
return fn(deltas, _ts(10), self._VALUES, _NO_SESSION)
|
||||||
|
|
||||||
|
# --- hand-calculated expected values ---
|
||||||
|
# buy_dal = 0.127 + 0.11 + 0.0 = 0.237
|
||||||
|
# buy_normal = 0.133 + 0.11 + 0.0 = 0.243
|
||||||
|
# sell_dal = 0.05
|
||||||
|
# sell_normal = 0.05
|
||||||
|
#
|
||||||
|
# With Δd1=2, Δd2=3, Δr1=1, Δr2=4:
|
||||||
|
# import_cost = 2×0.237 + 3×0.243 = 0.474 + 0.729 = 1.203
|
||||||
|
# export_revenue = 1×0.05 + 4×0.05 = 0.05 + 0.20 = 0.25
|
||||||
|
# net_cost = 1.203 − 0.25 = 0.953
|
||||||
|
|
||||||
|
def test_import_cost_dual_tariff(self) -> None:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("2"), d2=Decimal("3"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("4"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
expected = Decimal("2") * Decimal("0.237") + Decimal("3") * Decimal("0.243")
|
||||||
|
assert result["import_cost"] == expected
|
||||||
|
|
||||||
|
def test_export_revenue_dual_tariff(self) -> None:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("2"), d2=Decimal("3"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("4"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
expected = Decimal("1") * Decimal("0.05") + Decimal("4") * Decimal("0.05")
|
||||||
|
assert result["export_revenue"] == expected
|
||||||
|
|
||||||
|
def test_net_cost_equals_import_minus_export(self) -> None:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("2"), d2=Decimal("3"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("4"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
assert result["net_cost"] == result["import_cost"] - result["export_revenue"]
|
||||||
|
|
||||||
|
def test_hand_calculated_values(self) -> None:
|
||||||
|
"""Verify exact hand-calculated result for the reference deltas."""
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("2"), d2=Decimal("3"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("4"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
assert result["import_cost"] == Decimal("1.203")
|
||||||
|
assert result["export_revenue"] == Decimal("0.25")
|
||||||
|
assert result["net_cost"] == Decimal("0.953")
|
||||||
|
|
||||||
|
def test_zero_deltas_yields_zero_costs(self) -> None:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
assert result["import_cost"] == Decimal("0")
|
||||||
|
assert result["export_revenue"] == Decimal("0")
|
||||||
|
assert result["net_cost"] == Decimal("0")
|
||||||
|
|
||||||
|
def test_ode_included_in_buy_price(self) -> None:
|
||||||
|
"""When ode > 0 it is added to the effective buy price."""
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.10, "dal": 0.10},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.10,
|
||||||
|
"ode": 0.01, # non-zero ode
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 0.0, "management_fee": 0.0},
|
||||||
|
"credits": {"heffingskorting": 0.0},
|
||||||
|
}
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
fn = get_strategy("manual")
|
||||||
|
result = fn(deltas, _ts(10), values, _NO_SESSION)
|
||||||
|
# buy_dal = 0.10 + 0.10 + 0.01 = 0.21; import_cost = 1 × 0.21 = 0.21
|
||||||
|
assert result["import_cost"] == Decimal("0.21")
|
||||||
|
|
||||||
|
def test_import_export_kept_separate(self) -> None:
|
||||||
|
"""import_cost and export_revenue must not be netted before assignment."""
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("5"), d2=Decimal("5"),
|
||||||
|
r1=Decimal("3"), r2=Decimal("3"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
# Both must be individually non-zero.
|
||||||
|
assert result["import_cost"] > 0
|
||||||
|
assert result["export_revenue"] > 0
|
||||||
|
|
||||||
|
def test_pricing_snapshot_present(self) -> None:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("1"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas)
|
||||||
|
snapshot = result["pricing"]
|
||||||
|
assert snapshot["kind"] == "manual"
|
||||||
|
assert "buy_dal" in snapshot
|
||||||
|
assert "buy_normal" in snapshot
|
||||||
|
assert "sell_dal" in snapshot
|
||||||
|
assert "sell_normal" in snapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Decimal precision — float-error exposure test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualDecimalPrecision:
|
||||||
|
"""Use values known to produce binary float errors if float arithmetic is used."""
|
||||||
|
|
||||||
|
def test_no_float_rounding_error(self) -> None:
|
||||||
|
"""0.1 + 0.2 in float gives 0.30000000000000004; Decimal must be exact."""
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.1, "dal": 0.2},
|
||||||
|
"sell": {"normal": 0.1, "dal": 0.1},
|
||||||
|
"energy_tax": 0.0,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 0.0, "management_fee": 0.0},
|
||||||
|
"credits": {"heffingskorting": 0.0},
|
||||||
|
}
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("1"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
fn = get_strategy("manual")
|
||||||
|
result = fn(deltas, _ts(10), values, _NO_SESSION)
|
||||||
|
# import_cost = 1×0.2 + 1×0.1 = 0.3 exactly
|
||||||
|
assert result["import_cost"] == Decimal("0.3"), (
|
||||||
|
f"Expected Decimal('0.3'), got {result['import_cost']!r} — "
|
||||||
|
"float arithmetic leaking in?"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_result_values_are_decimal_type(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.133, "dal": 0.127},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.11,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 9.87, "management_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("1"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
fn = get_strategy("manual")
|
||||||
|
result = fn(deltas, _ts(10), values, _NO_SESSION)
|
||||||
|
assert isinstance(result["import_cost"], Decimal)
|
||||||
|
assert isinstance(result["export_revenue"], Decimal)
|
||||||
|
assert isinstance(result["net_cost"], Decimal)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4-7. Tibber strategy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTibberStrategy:
|
||||||
|
"""Verify Tibber price-lookup and billing calculations."""
|
||||||
|
|
||||||
|
_VALUES = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.10,
|
||||||
|
"sell_adjust": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _call(
|
||||||
|
self,
|
||||||
|
deltas: PeriodDeltas,
|
||||||
|
t0: datetime,
|
||||||
|
session: Session,
|
||||||
|
values: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
fn = get_strategy("tibber")
|
||||||
|
return fn(deltas, t0, values or self._VALUES, session)
|
||||||
|
|
||||||
|
def test_buy_equals_total(self, tibber_db) -> None:
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("1"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
# buy = total = 0.25; import_cost = 2 × 0.25 = 0.50
|
||||||
|
assert result["import_cost"] == Decimal("2") * Decimal("0.25")
|
||||||
|
|
||||||
|
def test_sell_equals_total_minus_energy_tax_minus_adjust(self, tibber_db) -> None:
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.10, "sell_adjust": 0.02},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
||||||
|
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
||||||
|
|
||||||
|
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
||||||
|
"""Correct row: starts_at ≤ t0, most recent wins."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
# Older price (should NOT be used)
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 0), total=0.10)
|
||||||
|
# Closer price (starts_at ≤ t0, should be used)
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.30)
|
||||||
|
# Future price (starts_at > t0, must NOT be used)
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(10, 15), total=0.99)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
# Only the 09:45 price (total=0.30) should be used.
|
||||||
|
snapshot = result["pricing"]
|
||||||
|
assert Decimal(snapshot["total"]) == Decimal("0.30")
|
||||||
|
|
||||||
|
def test_tibber_sums_both_tariff_registers(self, tibber_db) -> None:
|
||||||
|
"""Tibber does not split dal/normal; import_cost = (d1+d2) × buy."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("3"), d2=Decimal("2"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
# import_cost = (3+2) × 0.20 = 1.00
|
||||||
|
assert result["import_cost"] == Decimal("1.00")
|
||||||
|
|
||||||
|
def test_negative_total_gives_negative_export_revenue(self, tibber_db) -> None:
|
||||||
|
"""When total is negative, selling electricity costs money (correct behaviour)."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
# total = -0.05; sell = -0.05 - 0.10 - 0.0 = -0.15
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=-0.05)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("2"), r2=Decimal("2"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
# sell = -0.05 - 0.10 - 0.0 = -0.15; export_revenue = 4 × (-0.15) = -0.60
|
||||||
|
assert result["export_revenue"] < 0
|
||||||
|
assert result["export_revenue"] == Decimal("4") * (
|
||||||
|
Decimal("-0.05") - Decimal("0.10") - Decimal("0.0")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_negative_total_exact_calculation(self, tibber_db) -> None:
|
||||||
|
"""Full hand-calculation for negative-total scenario."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
total = Decimal("-0.05")
|
||||||
|
energy_tax = Decimal("0.10")
|
||||||
|
sell_adjust = Decimal("0.0")
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=float(total))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("1"), # delivered = 2 kWh
|
||||||
|
r1=Decimal("1"), r2=Decimal("1"), # returned = 2 kWh
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
|
||||||
|
buy = total # -0.05
|
||||||
|
sell = total - energy_tax - sell_adjust # -0.15
|
||||||
|
expected_import = Decimal("2") * buy # -0.10
|
||||||
|
expected_export = Decimal("2") * sell # -0.30
|
||||||
|
expected_net = expected_import - expected_export # 0.20
|
||||||
|
|
||||||
|
assert result["import_cost"] == expected_import
|
||||||
|
assert result["export_revenue"] == expected_export
|
||||||
|
assert result["net_cost"] == expected_net
|
||||||
|
|
||||||
|
def test_no_price_before_t0_raises(self, tibber_db) -> None:
|
||||||
|
"""When no TibberPrice exists with starts_at ≤ t0, TibberPriceNotFoundError is raised."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
# Only a future price — starts_at > t0.
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(10, 15), total=0.25)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
fn = get_strategy("tibber")
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
with pytest.raises(TibberPriceNotFoundError):
|
||||||
|
fn(deltas, t0, self._VALUES, session)
|
||||||
|
|
||||||
|
def test_empty_tibber_table_raises(self, tibber_db) -> None:
|
||||||
|
"""Empty tibber_price table raises TibberPriceNotFoundError."""
|
||||||
|
fn = get_strategy("tibber")
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
with pytest.raises(TibberPriceNotFoundError):
|
||||||
|
fn(deltas, _ts(10), self._VALUES, session)
|
||||||
|
|
||||||
|
def test_pricing_snapshot_contains_expected_keys(self, tibber_db) -> None:
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
snapshot = result["pricing"]
|
||||||
|
assert snapshot["kind"] == "tibber"
|
||||||
|
assert "tibber_price_starts_at" in snapshot
|
||||||
|
assert "buy" in snapshot
|
||||||
|
assert "sell" in snapshot
|
||||||
|
|
||||||
|
def test_result_values_are_decimal_type(self, tibber_db) -> None:
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("1"), d2=Decimal("1"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session)
|
||||||
|
assert isinstance(result["import_cost"], Decimal)
|
||||||
|
assert isinstance(result["export_revenue"], Decimal)
|
||||||
|
assert isinstance(result["net_cost"], Decimal)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tibber precision test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTibberDecimalPrecision:
|
||||||
|
"""Ensure Tibber arithmetic is exact Decimal (no float leakage)."""
|
||||||
|
|
||||||
|
def test_no_float_rounding_for_tricky_values(self, tibber_db) -> None:
|
||||||
|
"""total=0.1, energy_tax=0.2 — float gives 0.1+0.2 error; Decimal must be exact."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.1, "sell_adjust": 0.2},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
fn = get_strategy("tibber")
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("1"), r2=Decimal("0"),
|
||||||
|
)
|
||||||
|
result = fn(deltas, t0, values, session)
|
||||||
|
# sell = 0.3 - 0.1 - 0.2 = 0.0 exactly (float gives ~2.8e-17)
|
||||||
|
assert result["export_revenue"] == Decimal("0"), (
|
||||||
|
f"Expected Decimal('0'), got {result['export_revenue']!r} — "
|
||||||
|
"float arithmetic leaking in?"
|
||||||
|
)
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"""Tests for M6-T05: Tibber API client (app/integrations/tibber/client.py).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. ``fetch_price_range``: happy path parses nodes into PricePoint objects with
|
||||||
|
UTC-aware starts_at; does not assume a fixed number of nodes (tested with 3).
|
||||||
|
2. ``fetch_price_range``: HTTP 401 → TibberAuthError.
|
||||||
|
3. ``fetch_price_range``: HTTP 403 → TibberAuthError.
|
||||||
|
4. ``fetch_price_range``: httpx.TimeoutException → TibberError.
|
||||||
|
5. ``fetch_price_range``: generic HTTP error → TibberError.
|
||||||
|
6. Token does not appear in any raised exception's string representation.
|
||||||
|
7. ``fetch_price_range``: home_id selection (picks correct home).
|
||||||
|
8. ``fetch_current_price``: happy path returns a single PricePoint.
|
||||||
|
9. ``fetch_current_price``: HTTP 401 → TibberAuthError.
|
||||||
|
10. GraphQL-level errors in response body → TibberError.
|
||||||
|
|
||||||
|
All HTTP calls are intercepted via ``httpx.MockTransport`` (httpx built-in) so
|
||||||
|
no network access is required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.integrations.tibber.client import (
|
||||||
|
PricePoint,
|
||||||
|
TibberAuthError,
|
||||||
|
TibberError,
|
||||||
|
fetch_current_price,
|
||||||
|
fetch_price_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers / fixture builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FAKE_TOKEN = "fake-secret-token-do-not-log"
|
||||||
|
|
||||||
|
# A minimal set of 3 price nodes to verify that the parser does not assume a
|
||||||
|
# fixed number of nodes (e.g. 96).
|
||||||
|
_THREE_NODES = [
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-23T00:00:00.000+02:00",
|
||||||
|
"total": 0.25,
|
||||||
|
"energy": 0.20,
|
||||||
|
"tax": 0.05,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "NORMAL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-23T00:15:00.000+02:00",
|
||||||
|
"total": 0.22,
|
||||||
|
"energy": 0.18,
|
||||||
|
"tax": 0.04,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "CHEAP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-23T00:30:00.000+02:00",
|
||||||
|
"total": 0.30,
|
||||||
|
"energy": 0.24,
|
||||||
|
"tax": 0.06,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRICE_RANGE_RESPONSE = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfoRange": {
|
||||||
|
"nodes": _THREE_NODES,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_CURRENT_PRICE_RESPONSE = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfo": {
|
||||||
|
"current": {
|
||||||
|
"startsAt": "2026-06-23T10:00:00.000+02:00",
|
||||||
|
"total": 0.28,
|
||||||
|
"energy": 0.22,
|
||||||
|
"tax": 0.06,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "EXPENSIVE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_transport(status_code: int, body: dict | None = None, raise_timeout: bool = False):
|
||||||
|
"""Create an ``httpx.MockTransport`` that returns the given response.
|
||||||
|
|
||||||
|
If *raise_timeout* is True, the transport raises ``httpx.TimeoutException``
|
||||||
|
instead of returning a response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if raise_timeout:
|
||||||
|
raise httpx.TimeoutException("timed out", request=request)
|
||||||
|
payload = json.dumps(body or {}).encode()
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=status_code,
|
||||||
|
content=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return httpx.MockTransport(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_price_range — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_parses_nodes(monkeypatch):
|
||||||
|
"""Successful response with 3 nodes is parsed into 3 PricePoint objects."""
|
||||||
|
transport = _make_transport(200, _PRICE_RANGE_RESPONSE)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
assert len(points) == 3
|
||||||
|
|
||||||
|
# All starts_at must be UTC-aware.
|
||||||
|
for p in points:
|
||||||
|
assert isinstance(p, PricePoint)
|
||||||
|
assert p.starts_at.tzinfo is not None
|
||||||
|
assert p.starts_at.tzinfo == UTC or p.starts_at.utcoffset() == timedelta(0)
|
||||||
|
|
||||||
|
# First node: 2026-06-23T00:00:00+02:00 → 2026-06-22T22:00:00Z
|
||||||
|
first = points[0]
|
||||||
|
assert first.starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
|
||||||
|
assert first.total == pytest.approx(0.25)
|
||||||
|
assert first.energy == pytest.approx(0.20)
|
||||||
|
assert first.tax == pytest.approx(0.05)
|
||||||
|
assert first.currency == "EUR"
|
||||||
|
assert first.level == "NORMAL"
|
||||||
|
assert first.resolution == "QUARTER_HOURLY"
|
||||||
|
|
||||||
|
# Third node has level=None in the raw API response.
|
||||||
|
third = points[2]
|
||||||
|
assert third.level is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
||||||
|
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
||||||
|
# Build a response with a single node only.
|
||||||
|
one_node_response = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfoRange": {
|
||||||
|
"nodes": [_THREE_NODES[0]],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, one_node_response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert len(points) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_home_id_selection(monkeypatch):
|
||||||
|
"""When home_id is specified, the matching home is selected."""
|
||||||
|
two_homes_response = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-first",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfoRange": {
|
||||||
|
"nodes": [_THREE_NODES[0]],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "home-id-second",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfoRange": {
|
||||||
|
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, two_homes_response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
# Selecting the second home should return 2 nodes.
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN, home_id="home-id-second")
|
||||||
|
assert len(points) == 2
|
||||||
|
|
||||||
|
# Selecting the first home (default when home_id=None) returns 1 node.
|
||||||
|
points_default = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert len(points_default) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_price_range — authentication errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [401, 403])
|
||||||
|
def test_fetch_price_range_auth_error(monkeypatch, status_code):
|
||||||
|
"""HTTP 401 or 403 raises TibberAuthError (subclass of TibberError)."""
|
||||||
|
transport = _make_transport(status_code, {})
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberAuthError):
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_auth_error_is_tibber_error(monkeypatch):
|
||||||
|
"""TibberAuthError is a subclass of TibberError for broad catches."""
|
||||||
|
transport = _make_transport(401, {})
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError):
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_price_range — network / timeout errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_timeout_raises_tibber_error(monkeypatch):
|
||||||
|
"""httpx.TimeoutException is wrapped in TibberError."""
|
||||||
|
transport = _make_transport(200, raise_timeout=True)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError):
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_timeout_not_auth_error(monkeypatch):
|
||||||
|
"""A timeout should raise TibberError but NOT TibberAuthError."""
|
||||||
|
transport = _make_transport(200, raise_timeout=True)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError) as exc_info:
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert not isinstance(exc_info.value, TibberAuthError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_http_500_raises_tibber_error(monkeypatch):
|
||||||
|
"""An unexpected 5xx response raises TibberError."""
|
||||||
|
transport = _make_transport(500, {"error": "internal"})
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError):
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token safety — token must not appear in exception messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_not_in_auth_error_message(monkeypatch):
|
||||||
|
"""The API token must not appear in TibberAuthError's string representation."""
|
||||||
|
transport = _make_transport(401, {})
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberAuthError) as exc_info:
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
# The token must not appear anywhere in the exception string.
|
||||||
|
assert _FAKE_TOKEN not in str(exc_info.value)
|
||||||
|
assert _FAKE_TOKEN not in repr(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_not_in_timeout_error_message(monkeypatch):
|
||||||
|
"""The API token must not appear in TibberError's string representation on timeout."""
|
||||||
|
transport = _make_transport(200, raise_timeout=True)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError) as exc_info:
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
assert _FAKE_TOKEN not in str(exc_info.value)
|
||||||
|
assert _FAKE_TOKEN not in repr(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GraphQL errors in response body
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_errors_raise_tibber_error(monkeypatch):
|
||||||
|
"""A 200 response that contains a GraphQL 'errors' field raises TibberError."""
|
||||||
|
graphql_error_body = {
|
||||||
|
"errors": [{"message": "Some GraphQL error"}],
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, graphql_error_body)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError):
|
||||||
|
fetch_price_range(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_current_price — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_current_price_returns_single_price_point(monkeypatch):
|
||||||
|
"""Successful response returns a single UTC-aware PricePoint."""
|
||||||
|
transport = _make_transport(200, _CURRENT_PRICE_RESPONSE)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
point = fetch_current_price(_FAKE_TOKEN)
|
||||||
|
|
||||||
|
assert isinstance(point, PricePoint)
|
||||||
|
# 2026-06-23T10:00:00+02:00 → 2026-06-23T08:00:00Z
|
||||||
|
assert point.starts_at == datetime(2026, 6, 23, 8, 0, 0, tzinfo=UTC)
|
||||||
|
assert point.total == pytest.approx(0.28)
|
||||||
|
assert point.energy == pytest.approx(0.22)
|
||||||
|
assert point.tax == pytest.approx(0.06)
|
||||||
|
assert point.currency == "EUR"
|
||||||
|
assert point.level == "EXPENSIVE"
|
||||||
|
assert point.resolution == "QUARTER_HOURLY"
|
||||||
|
# UTC-aware check
|
||||||
|
assert point.starts_at.tzinfo is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_current_price — authentication errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_current_price_auth_error(monkeypatch):
|
||||||
|
"""HTTP 401 on current-price query raises TibberAuthError."""
|
||||||
|
transport = _make_transport(401, {})
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
with pytest.raises(TibberAuthError):
|
||||||
|
fetch_current_price(_FAKE_TOKEN)
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""Tests for M6-T05: Tibber price refresh service (app/services/tibber_prices.py).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. With active tibber contract + non-empty token: ``refresh_prices`` calls the
|
||||||
|
client, upserts returned price points, and returns the count.
|
||||||
|
2. Idempotency: running ``refresh_prices`` twice does not double-insert rows;
|
||||||
|
rows are updated in-place (``starts_at`` remains unique).
|
||||||
|
3. No active contract → no-op (returns 0, no DB writes).
|
||||||
|
4. Active contract with kind="manual" (not tibber) → no-op.
|
||||||
|
5. Token is empty string → no-op.
|
||||||
|
6. Token is whitespace-only → no-op.
|
||||||
|
7. Client errors propagate (not swallowed by the service).
|
||||||
|
|
||||||
|
All Tibber API calls are intercepted via monkeypatching ``fetch_price_range``
|
||||||
|
so no real network access is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.integrations.tibber.client import PricePoint, TibberError
|
||||||
|
from app.models.energy import EnergyContract, EnergyContractVersion, TibberPrice
|
||||||
|
from app.services.tibber_prices import refresh_prices
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app_alembic_config(database_url: str) -> Config:
|
||||||
|
cfg = Config("alembic_app.ini")
|
||||||
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def energy_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to head (has all energy tables)."""
|
||||||
|
db_path = tmp_path / "tibber_prices_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
session = Session(engine)
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UTC = UTC
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(hour: int, minute: int = 0) -> datetime:
|
||||||
|
"""Return a UTC datetime on 2026-06-23 at the given hour:minute."""
|
||||||
|
return datetime(2026, 6, 23, hour, minute, 0, tzinfo=_UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_price_points(count: int = 3) -> list[PricePoint]:
|
||||||
|
"""Build a list of *count* fake PricePoint objects 15 minutes apart."""
|
||||||
|
base = _ts(10, 0)
|
||||||
|
points = []
|
||||||
|
for i in range(count):
|
||||||
|
starts_at = base + timedelta(minutes=15 * i)
|
||||||
|
points.append(
|
||||||
|
PricePoint(
|
||||||
|
starts_at=starts_at,
|
||||||
|
total=0.20 + i * 0.01,
|
||||||
|
energy=0.16 + i * 0.01,
|
||||||
|
tax=0.04,
|
||||||
|
currency="EUR",
|
||||||
|
level="NORMAL",
|
||||||
|
resolution="QUARTER_HOURLY",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSettings:
|
||||||
|
"""Minimal settings stand-in with Tibber fields."""
|
||||||
|
|
||||||
|
def __init__(self, token: str = "valid-token", home_id: str = ""):
|
||||||
|
self.tibber_api_token = token
|
||||||
|
self.tibber_home_id = home_id
|
||||||
|
|
||||||
|
|
||||||
|
def _create_active_tibber_contract(session: Session) -> EnergyContract:
|
||||||
|
"""Insert and commit an active tibber contract + one open version."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Tibber Test",
|
||||||
|
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,
|
||||||
|
effective_to=None,
|
||||||
|
values={"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, "standing": {}, "credits": {}},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def _create_active_manual_contract(session: Session) -> EnergyContract:
|
||||||
|
"""Insert and commit an active manual contract."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Manual Test",
|
||||||
|
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,
|
||||||
|
effective_to=None,
|
||||||
|
values={},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def _count_tibber_price_rows(session: Session) -> int:
|
||||||
|
"""Return the current number of rows in tibber_price."""
|
||||||
|
return len(session.execute(select(TibberPrice)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test: happy path (active tibber contract + valid token)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_upserts_price_points(energy_db, monkeypatch):
|
||||||
|
"""Active tibber contract + token → price points are upserted and count returned."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_tibber_contract(session)
|
||||||
|
points = _make_price_points(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.tibber_prices.fetch_price_range",
|
||||||
|
lambda token, home_id_or_none: points,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
assert count == 3
|
||||||
|
rows = session.execute(select(TibberPrice).order_by(TibberPrice.starts_at)).scalars().all()
|
||||||
|
assert len(rows) == 3
|
||||||
|
|
||||||
|
for row, point in zip(rows, points):
|
||||||
|
# starts_at is stored UTC-aware; SQLite may return naive — normalise.
|
||||||
|
stored_starts_at = row.starts_at
|
||||||
|
if stored_starts_at.tzinfo is None:
|
||||||
|
stored_starts_at = stored_starts_at.replace(tzinfo=UTC)
|
||||||
|
assert stored_starts_at == point.starts_at
|
||||||
|
assert row.total == pytest.approx(point.total)
|
||||||
|
assert row.energy == pytest.approx(point.energy)
|
||||||
|
assert row.tax == pytest.approx(point.tax)
|
||||||
|
assert row.currency == point.currency
|
||||||
|
assert row.resolution == "QUARTER_HOURLY"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test: idempotency (running twice must not double-insert)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_is_idempotent(energy_db, monkeypatch):
|
||||||
|
"""Running refresh_prices twice does not increase the row count."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_tibber_contract(session)
|
||||||
|
points = _make_price_points(4)
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def _fake_fetch(token, home_id_or_none):
|
||||||
|
call_count["n"] += 1
|
||||||
|
return points
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
|
||||||
|
|
||||||
|
count1 = refresh_prices(session, _FakeSettings())
|
||||||
|
count2 = refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
assert call_count["n"] == 2 # fetch was called twice
|
||||||
|
assert count1 == 4
|
||||||
|
assert count2 == 4 # same number of "upserted" (all updated in-place)
|
||||||
|
|
||||||
|
# DB must still have exactly 4 rows (not 8).
|
||||||
|
assert _count_tibber_price_rows(session) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_idempotent_updates_fields(energy_db, monkeypatch):
|
||||||
|
"""On second run, existing rows are updated (not duplicated)."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_tibber_contract(session)
|
||||||
|
points = _make_price_points(2)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.tibber_prices.fetch_price_range",
|
||||||
|
lambda token, home_id_or_none: points,
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
# Modify the points to simulate Tibber updating a price.
|
||||||
|
updated_points = [
|
||||||
|
PricePoint(
|
||||||
|
starts_at=p.starts_at,
|
||||||
|
total=p.total + 0.10, # price changed
|
||||||
|
energy=p.energy + 0.08,
|
||||||
|
tax=p.tax + 0.02,
|
||||||
|
currency=p.currency,
|
||||||
|
level="EXPENSIVE",
|
||||||
|
resolution=p.resolution,
|
||||||
|
)
|
||||||
|
for p in points
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.tibber_prices.fetch_price_range",
|
||||||
|
lambda token, home_id_or_none: updated_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
rows = session.execute(select(TibberPrice).order_by(TibberPrice.starts_at)).scalars().all()
|
||||||
|
assert len(rows) == 2 # still 2, not 4
|
||||||
|
|
||||||
|
for row, up in zip(rows, updated_points):
|
||||||
|
assert row.total == pytest.approx(up.total)
|
||||||
|
assert row.level == "EXPENSIVE"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test: no-op conditions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_noop_no_active_contract(energy_db, monkeypatch):
|
||||||
|
"""No active contract at all → no-op (returns 0, no DB writes)."""
|
||||||
|
session = energy_db
|
||||||
|
fetch_called = {"called": False}
|
||||||
|
|
||||||
|
def _fake_fetch(token, home_id_or_none):
|
||||||
|
fetch_called["called"] = True
|
||||||
|
return _make_price_points(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
|
||||||
|
|
||||||
|
count = refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert not fetch_called["called"]
|
||||||
|
assert _count_tibber_price_rows(session) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_noop_manual_contract(energy_db, monkeypatch):
|
||||||
|
"""Active manual contract (not tibber) → no-op."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_manual_contract(session)
|
||||||
|
fetch_called = {"called": False}
|
||||||
|
|
||||||
|
def _fake_fetch(token, home_id_or_none):
|
||||||
|
fetch_called["called"] = True
|
||||||
|
return _make_price_points(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
|
||||||
|
|
||||||
|
count = refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert not fetch_called["called"]
|
||||||
|
assert _count_tibber_price_rows(session) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_noop_empty_token(energy_db, monkeypatch):
|
||||||
|
"""Empty token → no-op even if tibber contract is active."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_tibber_contract(session)
|
||||||
|
fetch_called = {"called": False}
|
||||||
|
|
||||||
|
def _fake_fetch(token, home_id_or_none):
|
||||||
|
fetch_called["called"] = True
|
||||||
|
return _make_price_points(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
|
||||||
|
|
||||||
|
count = refresh_prices(session, _FakeSettings(token=""))
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert not fetch_called["called"]
|
||||||
|
assert _count_tibber_price_rows(session) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_noop_whitespace_token(energy_db, monkeypatch):
|
||||||
|
"""Whitespace-only token → no-op (treated as not configured)."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_tibber_contract(session)
|
||||||
|
fetch_called = {"called": False}
|
||||||
|
|
||||||
|
def _fake_fetch(token, home_id_or_none):
|
||||||
|
fetch_called["called"] = True
|
||||||
|
return _make_price_points(2)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
|
||||||
|
|
||||||
|
count = refresh_prices(session, _FakeSettings(token=" "))
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert not fetch_called["called"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test: inactive tibber contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_noop_inactive_tibber_contract(energy_db, monkeypatch):
|
||||||
|
"""Inactive tibber contract → no-op (active flag is False)."""
|
||||||
|
session = energy_db
|
||||||
|
|
||||||
|
# Create a tibber contract but do NOT activate it.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Inactive Tibber",
|
||||||
|
kind="tibber",
|
||||||
|
active=False, # <-- inactive
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
fetch_called = {"called": False}
|
||||||
|
|
||||||
|
def _fake_fetch(token, home_id_or_none):
|
||||||
|
fetch_called["called"] = True
|
||||||
|
return _make_price_points(2)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
|
||||||
|
|
||||||
|
count = refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert not fetch_called["called"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test: client errors propagate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_prices_propagates_client_error(energy_db, monkeypatch):
|
||||||
|
"""TibberError from the client is NOT swallowed by the service."""
|
||||||
|
session = energy_db
|
||||||
|
_create_active_tibber_contract(session)
|
||||||
|
|
||||||
|
def _failing_fetch(token, home_id_or_none):
|
||||||
|
raise TibberError("simulated network failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _failing_fetch)
|
||||||
|
|
||||||
|
with pytest.raises(TibberError):
|
||||||
|
refresh_prices(session, _FakeSettings())
|
||||||
|
|
||||||
|
# No rows should have been written.
|
||||||
|
assert _count_tibber_price_rows(session) == 0
|
||||||
Reference in New Issue
Block a user