Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ffdaebbbd | ||
|
|
c64693b8e5 | ||
|
|
c89d083942 | ||
|
|
6090b98ab0 | ||
|
|
489cd7df3d | ||
|
|
35cdc7f22a | ||
|
|
75d685f04d | ||
|
|
935e68846c | ||
|
|
4767a7af60 | ||
|
|
45d87f36f7 | ||
|
|
35b95f5c88 | ||
|
|
9d05f5ea62 | ||
|
|
360cddc05b | ||
|
|
14b846549e | ||
|
|
68165f0e01 | ||
|
|
2dc469274a | ||
|
|
702a1cec00 | ||
|
|
49d15d8ffe | ||
|
|
c89cea4953 | ||
|
|
d9c82e39fb | ||
|
|
eac7860b51 | ||
|
|
da7d0f9d62 |
@@ -28,3 +28,20 @@ TICKTICK_CLIENT_ID=
|
|||||||
TICKTICK_CLIENT_SECRET=
|
TICKTICK_CLIENT_SECRET=
|
||||||
TICKTICK_TOKEN=
|
TICKTICK_TOKEN=
|
||||||
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
|
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
|
||||||
|
|
||||||
|
# Optional: Modbus polling (global kill-switch; default false — opt-in).
|
||||||
|
# MODBUS_POLLING_ENABLED=false
|
||||||
|
|
||||||
|
# Optional: MQTT broker connection.
|
||||||
|
# Leave MQTT_ENABLED=false (or unset) when MQTT is not needed.
|
||||||
|
MQTT_ENABLED=false
|
||||||
|
MQTT_BROKER_HOST=
|
||||||
|
MQTT_BROKER_PORT=1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
MQTT_TLS_ENABLED=false
|
||||||
|
|
||||||
|
# Optional: Home Assistant MQTT Discovery.
|
||||||
|
# Requires MQTT_ENABLED=true and a running MQTT broker.
|
||||||
|
HA_DISCOVERY_ENABLED=false
|
||||||
|
HA_DISCOVERY_PREFIX=homeassistant
|
||||||
|
|||||||
@@ -12,8 +12,11 @@
|
|||||||
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
|
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
|
||||||
- location recorder
|
- location recorder
|
||||||
- poo recorder
|
- poo recorder
|
||||||
- Home Assistant inbound / outbound integration
|
- Home Assistant inbound / outbound integration(REST 通道)
|
||||||
- TickTick OAuth 与 action task 集成
|
- TickTick OAuth 与 action task 集成
|
||||||
|
- **Modbus 设备采集**:通过 YAML profile(首个:SDM120 电表)按设备周期轮询 Modbus-TCP 网关,解码工程量并落通用读数表(`modbus_device` + `modbus_reading`)
|
||||||
|
- **MQTT + Home Assistant Discovery**:以可勾选方式把 Modbus 设备/工程量注册为 HA device/entity(含 binary_sensor online),state 周期发布;配置变更可重连重发
|
||||||
|
- **前端侧边栏 + Energy 视图**:侧边导航替换顶栏;Energy 页管理 Modbus 设备、展示最新读数与 Recharts 走势图;Config 页 Accordion 分区展开;Expose 设置勾选 HA 可暴露实体
|
||||||
- pytest 测试与 OpenAPI 导出脚本
|
- pytest 测试与 OpenAPI 导出脚本
|
||||||
- Docker / Compose 部署入口
|
- Docker / Compose 部署入口
|
||||||
|
|
||||||
@@ -30,6 +33,9 @@
|
|||||||
- public IPv4 当前状态与变化历史
|
- public IPv4 当前状态与变化历史
|
||||||
- location 记录(`location` 表)
|
- location 记录(`location` 表)
|
||||||
- poo 记录(`poo_records` 表)
|
- poo 记录(`poo_records` 表)
|
||||||
|
- Modbus 设备定义(`modbus_device` 表)
|
||||||
|
- Modbus 通用读数(`modbus_reading` 表,JSON payload)
|
||||||
|
- HA 实体暴露开关(`exposed_entity_toggle` 表)
|
||||||
|
|
||||||
配置层只保留一个数据库环境变量:
|
配置层只保留一个数据库环境变量:
|
||||||
|
|
||||||
@@ -41,7 +47,7 @@
|
|||||||
python -m scripts.run_migrations
|
python -m scripts.run_migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含 `location` / `poo_records` 表)。
|
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含全部表,包括 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`)。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
@@ -49,7 +55,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 环境(同时管理 `location` / `poo_records` 表)
|
- `alembic_app/`: App DB 的 Alembic migration 环境(管理所有表,含 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`)
|
||||||
- `tests/`: pytest 测试
|
- `tests/`: pytest 测试
|
||||||
- `docs/`: 当前系统说明文档
|
- `docs/`: 当前系统说明文档
|
||||||
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
||||||
@@ -128,6 +134,7 @@ M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托
|
|||||||
- **Vite + React + TypeScript + Mantine**(组件库)
|
- **Vite + React + TypeScript + Mantine**(组件库)
|
||||||
- **TanStack Query**(数据请求/缓存)
|
- **TanStack Query**(数据请求/缓存)
|
||||||
- **Leaflet / react-leaflet**(地图与热力图)
|
- **Leaflet / react-leaflet**(地图与热力图)
|
||||||
|
- **Recharts**(Energy 视图走势图,M5 引入)
|
||||||
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
|
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
|
||||||
|
|
||||||
### 本地开发(前端)
|
### 本地开发(前端)
|
||||||
@@ -177,7 +184,7 @@ npm run build # 构建,确认产出 dist
|
|||||||
- App DB:`sqlite:///./data/app.db`
|
- App DB:`sqlite:///./data/app.db`
|
||||||
- 数据目录:`./data/`
|
- 数据目录:`./data/`
|
||||||
|
|
||||||
所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
所有模型(auth / config / public_ip / location / poo / modbus / expose)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
||||||
|
|
||||||
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||||
- 统一 migration job:`python -m scripts.run_migrations`
|
- 统一 migration job:`python -m scripts.run_migrations`
|
||||||
@@ -281,6 +288,84 @@ admin 可在 React SPA 设置页(`/config`)自选启用 RFC 6238 TOTP:
|
|||||||
|
|
||||||
TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环境变量配置(`.env` 部署级),默认回退 `app_name`。
|
TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环境变量配置(`.env` 部署级),默认回退 `app_name`。
|
||||||
|
|
||||||
|
## M5 Modbus 设备采集 / Energy / MQTT + HA Discovery
|
||||||
|
|
||||||
|
M5 给后端接入家庭 IoT 生态,新增通用 Modbus 采集链路(首个领域:能耗)、MQTT + Home Assistant Discovery 发布,以及前端侧边栏与 Energy 视图。
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
后端新增:
|
||||||
|
- `pymodbus`:Modbus-TCP 客户端(轮询电表等 slave 设备)
|
||||||
|
- `paho-mqtt`:MQTT 客户端(HA Discovery 与 state 发布)
|
||||||
|
- `pyyaml`:YAML profile 加载(设备协议声明式描述)
|
||||||
|
|
||||||
|
前端新增:
|
||||||
|
- `recharts`:Energy 视图走势图
|
||||||
|
|
||||||
|
### Modbus 设备采集
|
||||||
|
|
||||||
|
采集链路采用两层分离:**YAML profile**(协议知识,随代码走)+ **`modbus_device` 数据库行**(部署/可配置信息)+ **`modbus_reading` 通用读数表**(JSON payload 遥测)。
|
||||||
|
|
||||||
|
- **profile**(如 `sdm120.yaml`)描述:读哪些寄存器(FC04 块读)、每个量的 key/unit/device_class/ha_component。纯协议知识,不含 unit_id / friendly_name 等部署项。
|
||||||
|
- **`modbus_device` 行**:friendly_name、网关 host/port、Modbus slave `unit_id`(电表 Meter ID,设备面板可改故落 DB)、选用哪个 profile、采样周期、是否启用。
|
||||||
|
- **`modbus_reading` 行**:device_id FK、recorded_at、payload(JSON,如 `{"voltage": 230.2, "current": 1.3, ...}`)。
|
||||||
|
- 多设备可共享同一 profile(如两块 SDM120 共用 `sdm120` profile,各自独立 unit_id 和 friendly_name)。
|
||||||
|
- APScheduler 后台 job 周期轮询所有 `enabled` 设备,更新 `last_poll_at` / `last_poll_ok`。全局开关 `MODBUS_POLLING_ENABLED`(CONFIG_FIELDS)。
|
||||||
|
|
||||||
|
**手工命令行试读**(不依赖 DB,最快验证网关连通性):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 按 profile 解码读一次(验证整套解码链路)
|
||||||
|
python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120
|
||||||
|
|
||||||
|
# 手工指定请求内容(first-contact 验证,不依赖 profile)
|
||||||
|
python -m scripts.modbus_cli probe --host <网关IP> --port 502 --unit 1 --fc 4 --address 0x0000 --count 2 --decode float32
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI 工具为受控手工验证而设(设备需接市电),仅暴露读功能码(FC03/04),无写寄存器子命令。
|
||||||
|
|
||||||
|
### MQTT + Home Assistant Discovery
|
||||||
|
|
||||||
|
后端作为 MQTT 发布方,把 Modbus 设备/工程量以 HA Discovery 协议自动注册为 device/entity:
|
||||||
|
|
||||||
|
- 每个 Modbus 设备 = 一个 HA device(`unique_id` 锚定于设备 `uuid`,不随改名变)
|
||||||
|
- 各工程量 = sensor entity(device_class/unit 取自 YAML profile);另有 binary_sensor `online`(取 `last_poll_ok`)
|
||||||
|
- 每次轮询成功后推 state topic;连接成功或勾选变更时重发 retained discovery config
|
||||||
|
- `POST /api/config/mqtt/test`:试连 broker 并发布一条测试消息(可用 MQTT Explorer 验证链路)
|
||||||
|
|
||||||
|
**Config 页配置流程**:
|
||||||
|
1. 在 `/config` 的「MQTT」section 填写 broker host/port/username/password,启用 `MQTT_ENABLED`
|
||||||
|
2. 点「发送测试消息」确认 broker 链路通
|
||||||
|
3. 启用 `HA_DISCOVERY_ENABLED`
|
||||||
|
4. 在「Home Assistant Expose」面板勾选要暴露的实体,点「重新发布 discovery」
|
||||||
|
5. 在 Home Assistant 确认对应 device/entity 出现
|
||||||
|
|
||||||
|
### API 端点(M5 新增)
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/modbus/devices` | 列出 Modbus 设备 |
|
||||||
|
| `POST /api/modbus/devices` | 新建设备 |
|
||||||
|
| `GET /api/modbus/devices/{uuid}` | 单个设备 |
|
||||||
|
| `PATCH /api/modbus/devices/{uuid}` | 修改设备(含 enable/disable)|
|
||||||
|
| `DELETE /api/modbus/devices/{uuid}` | 删除设备;有读数时 409 |
|
||||||
|
| `GET /api/modbus/devices/{uuid}/metrics` | 该设备 profile 的量目录(key/unit/device_class)|
|
||||||
|
| `GET /api/modbus/devices/{uuid}/latest` | 最新一条读数 payload |
|
||||||
|
| `GET /api/modbus/devices/{uuid}/readings` | 时间范围读数(start/end/limit),供走势图 |
|
||||||
|
| `POST /api/modbus/devices/{uuid}/test` | 即时试读(不落库) |
|
||||||
|
| `GET /api/modbus/profiles` | 列出可用 profile 名 + 描述 |
|
||||||
|
| `GET /api/expose` | 可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 |
|
||||||
|
| `PUT /api/expose` | 设置逐 key 暴露开关 |
|
||||||
|
| `POST /api/expose/republish` | 手动重发 discovery |
|
||||||
|
| `POST /api/config/mqtt/test` | 试连 broker 并发布测试消息 |
|
||||||
|
|
||||||
|
### 前端视图(M5 新增)
|
||||||
|
|
||||||
|
- **侧边栏**:把顶栏改为侧边导航(Home / Records / Energy / Config + 主题切换 + 注销),当前路由高亮,移动端可折叠。
|
||||||
|
- **`/energy`(Energy 视图)**:设备 CRUD(新建/编辑/删除,删除有二次确认;有读数时引导改用禁用);最新读数卡片(字段标签/单位取自 profile metrics);时间序列走势图(Recharts,支持电压/电流/功率/电能,带时间范围选择)。
|
||||||
|
- **Config 页 Accordion**:各大 config section 可独立折叠/展开;「Home Assistant Expose」面板按设备分组勾选可暴露实体、显示 MQTT/Discovery 连接状态、「重新发布 discovery」按钮。
|
||||||
|
- SPA 路由新增 `/energy`。
|
||||||
|
|
||||||
## Config 持久化
|
## Config 持久化
|
||||||
|
|
||||||
当前 config 页面不会把修改写回 `.env`。
|
当前 config 页面不会把修改写回 `.env`。
|
||||||
@@ -306,6 +391,9 @@ TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环
|
|||||||
- SMTP 基础配置
|
- SMTP 基础配置
|
||||||
- TickTick OAuth 配置
|
- TickTick OAuth 配置
|
||||||
- Home Assistant 配置
|
- Home Assistant 配置
|
||||||
|
- MQTT broker 配置(`MQTT_ENABLED`、`MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD`、`MQTT_TLS_ENABLED`)
|
||||||
|
- Home Assistant Discovery 配置(`HA_DISCOVERY_ENABLED`、`HA_DISCOVERY_PREFIX`)
|
||||||
|
- Modbus 采集配置(`MODBUS_POLLING_ENABLED`)
|
||||||
|
|
||||||
其中 SMTP password 与其他 secret 字段一致:
|
其中 SMTP password 与其他 secret 字段一致:
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from app.models.auth_throttle import LoginThrottle # noqa: F401
|
|||||||
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||||
from app.models.location import Location # noqa: F401
|
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.expose import ExposedEntityToggle # noqa: F401
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add modbus_device and modbus_reading tables
|
||||||
|
|
||||||
|
Revision ID: 20260622_09_modbus_tables
|
||||||
|
Revises: 20260621_08_totp
|
||||||
|
Create Date: 2026-06-22 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260622_09_modbus_tables"
|
||||||
|
down_revision: Union[str, None] = "20260621_08_totp"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# modbus_device — deployment/configurable metadata for each polled device.
|
||||||
|
op.create_table(
|
||||||
|
"modbus_device",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("uuid", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("friendly_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("transport", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("host", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("unit_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("profile", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("poll_interval_s", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("last_poll_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_poll_ok", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("uuid", name="uq_modbus_device_uuid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# modbus_reading — generic telemetry, one row per device per poll cycle.
|
||||||
|
op.create_table(
|
||||||
|
"modbus_reading",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("device_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["device_id"],
|
||||||
|
["modbus_device.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Individual index on recorded_at (from the ORM-level index=True).
|
||||||
|
op.create_index(
|
||||||
|
"ix_modbus_reading_recorded_at",
|
||||||
|
"modbus_reading",
|
||||||
|
["recorded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Composite index for efficient time-range queries per device.
|
||||||
|
op.create_index(
|
||||||
|
"ix_modbus_reading_device_recorded",
|
||||||
|
"modbus_reading",
|
||||||
|
["device_id", "recorded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop the reading table first (it has a FK referencing modbus_device).
|
||||||
|
op.drop_index("ix_modbus_reading_device_recorded", table_name="modbus_reading")
|
||||||
|
op.drop_index("ix_modbus_reading_recorded_at", table_name="modbus_reading")
|
||||||
|
op.drop_table("modbus_reading")
|
||||||
|
|
||||||
|
# Drop the device table.
|
||||||
|
op.drop_table("modbus_device")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""add exposed_entity_toggle table
|
||||||
|
|
||||||
|
Revision ID: 20260622_10_exposed_entities
|
||||||
|
Revises: 20260622_09_modbus_tables
|
||||||
|
Create Date: 2026-06-22 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260622_10_exposed_entities"
|
||||||
|
down_revision: Union[str, None] = "20260622_09_modbus_tables"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# exposed_entity_toggle — per-entity on/off switch for MQTT / HA Discovery.
|
||||||
|
op.create_table(
|
||||||
|
"exposed_entity_toggle",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("key", name="uq_exposed_entity_toggle_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index on key for fast single-key lookups (toggle by key).
|
||||||
|
op.create_index(
|
||||||
|
"ix_exposed_entity_toggle_key",
|
||||||
|
"exposed_entity_toggle",
|
||||||
|
["key"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index then table — only removes what this revision created.
|
||||||
|
op.drop_index("ix_exposed_entity_toggle_key", table_name="exposed_entity_toggle")
|
||||||
|
op.drop_table("exposed_entity_toggle")
|
||||||
@@ -9,12 +9,14 @@ 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
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.dependencies import get_app_settings, get_db
|
from app.dependencies import get_app_settings, get_db
|
||||||
|
from app.integrations.mqtt import MQTT_SETTINGS_KEYS, mqtt_manager
|
||||||
from app.schemas.config import (
|
from app.schemas.config import (
|
||||||
ConfigField,
|
ConfigField,
|
||||||
ConfigResponse,
|
ConfigResponse,
|
||||||
ConfigSection,
|
ConfigSection,
|
||||||
ConfigUpdateRequest,
|
ConfigUpdateRequest,
|
||||||
ConfigUpdateResponse,
|
ConfigUpdateResponse,
|
||||||
|
MqttTestResponse,
|
||||||
SmtpTestResponse,
|
SmtpTestResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import AuthenticatedSession
|
from app.services.auth import AuthenticatedSession
|
||||||
@@ -58,7 +60,12 @@ def put_config(
|
|||||||
|
|
||||||
- Blank secret value keeps the existing stored value (no change).
|
- Blank secret value keeps the existing stored value (no change).
|
||||||
- Invalid values return 422 and nothing is written to the database.
|
- Invalid values return 422 and nothing is written to the database.
|
||||||
|
- If MQTT-related settings changed, the MQTT client reconnects automatically.
|
||||||
"""
|
"""
|
||||||
|
# Detect whether any MQTT-related key is being submitted (non-secret change
|
||||||
|
# or non-blank secret change) so we know to reconnect after saving.
|
||||||
|
mqtt_keys_submitted = any(k.lower() in MQTT_SETTINGS_KEYS for k in body.updates)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
save_config_updates(db, body.updates, settings)
|
save_config_updates(db, body.updates, settings)
|
||||||
except ConfigSaveError as exc:
|
except ConfigSaveError as exc:
|
||||||
@@ -68,8 +75,17 @@ def put_config(
|
|||||||
detail="invalid config submission",
|
detail="invalid config submission",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Re-read settings after save (save_config_updates clears the settings cache)
|
# Re-read settings after save (save_config_updates clears the settings cache).
|
||||||
refreshed_settings = get_settings()
|
# Use build_runtime_settings so the reconnect picks up DB-stored values, not
|
||||||
|
# just the bootstrap env (otherwise a broker configured via the UI is ignored).
|
||||||
|
from app.services.config_page import build_runtime_settings
|
||||||
|
refreshed_settings = build_runtime_settings(db, get_settings())
|
||||||
|
|
||||||
|
# Reconnect MQTT client if any MQTT setting was updated.
|
||||||
|
if mqtt_keys_submitted:
|
||||||
|
logger.info("MQTT settings changed — triggering reconnect.")
|
||||||
|
mqtt_manager.reconnect(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))
|
||||||
|
|
||||||
@@ -117,3 +133,174 @@ def post_smtp_test(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
content={"result": "success", "message": "Test email sent successfully."},
|
content={"result": "success", "message": "Test email sent successfully."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/config/mqtt/test — M5-T10
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _MqttConfigurationError(ValueError):
|
||||||
|
"""Raised when MQTT settings are incomplete or disabled."""
|
||||||
|
|
||||||
|
|
||||||
|
class _MqttConnectionError(RuntimeError):
|
||||||
|
"""Raised when MQTT broker connection or publish fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/config/mqtt/test",
|
||||||
|
responses={
|
||||||
|
200: {"model": MqttTestResponse},
|
||||||
|
400: {"model": MqttTestResponse},
|
||||||
|
502: {"model": MqttTestResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post_mqtt_test(
|
||||||
|
settings: Settings = Depends(get_app_settings),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Test MQTT broker connectivity by attempting to connect and publishing a
|
||||||
|
test message to ``<ha_discovery_prefix>/home-automation/test``.
|
||||||
|
|
||||||
|
The message is visible in MQTT Explorer (or any subscriber) so users can
|
||||||
|
confirm the full broker publish path is working.
|
||||||
|
|
||||||
|
Three possible outcomes:
|
||||||
|
- 200 { "result": "success", "message": ... }
|
||||||
|
- 400 { "result": "config-error", "message": ... } (not configured)
|
||||||
|
- 502 { "result": "failed", "message": ... } (connection/publish error)
|
||||||
|
|
||||||
|
MQTT credentials are never echoed in the response.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_run_mqtt_test(settings)
|
||||||
|
except _MqttConfigurationError as exc:
|
||||||
|
logger.warning("MQTT test rejected due to configuration: %s", exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content={"result": "config-error", "message": str(exc)},
|
||||||
|
)
|
||||||
|
except _MqttConnectionError as exc:
|
||||||
|
logger.warning("MQTT test connection/publish failed: %s", exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
content={"result": "failed", "message": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
content={
|
||||||
|
"result": "success",
|
||||||
|
"message": (
|
||||||
|
f"Test message published to "
|
||||||
|
f"{settings.ha_discovery_prefix}/home-automation/test."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_mqtt_test(settings: Settings) -> None:
|
||||||
|
"""Attempt a transient MQTT connection and publish a test message.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
_MqttConfigurationError
|
||||||
|
When MQTT is not enabled or broker host is not configured.
|
||||||
|
_MqttConnectionError
|
||||||
|
When the broker is unreachable or the publish fails.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
if not settings.mqtt_broker_host:
|
||||||
|
raise _MqttConfigurationError("MQTT broker host is not configured.")
|
||||||
|
|
||||||
|
test_topic = f"{settings.ha_discovery_prefix}/home-automation/test"
|
||||||
|
test_payload = '{"source": "home-automation", "event": "mqtt_test"}'
|
||||||
|
|
||||||
|
connected_event = __import__("threading").Event()
|
||||||
|
published_event = __import__("threading").Event()
|
||||||
|
connect_error: list[str] = []
|
||||||
|
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id="home-automation-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_connect(
|
||||||
|
_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
_flags: mqtt.ConnectFlags,
|
||||||
|
reason_code: mqtt.ReasonCode,
|
||||||
|
_properties: mqtt.Properties | None,
|
||||||
|
) -> None:
|
||||||
|
if reason_code.is_failure:
|
||||||
|
connect_error.append(f"Broker refused connection: {reason_code}")
|
||||||
|
connected_event.set()
|
||||||
|
|
||||||
|
def _on_publish(
|
||||||
|
_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
_mid: int,
|
||||||
|
_reason_code: mqtt.ReasonCode,
|
||||||
|
_properties: mqtt.Properties | None,
|
||||||
|
) -> None:
|
||||||
|
published_event.set()
|
||||||
|
|
||||||
|
client.on_connect = _on_connect
|
||||||
|
client.on_publish = _on_publish
|
||||||
|
|
||||||
|
if settings.mqtt_tls_enabled:
|
||||||
|
try:
|
||||||
|
client.tls_set()
|
||||||
|
except Exception as exc:
|
||||||
|
raise _MqttConnectionError(f"TLS setup failed: {exc}") from exc
|
||||||
|
|
||||||
|
if settings.mqtt_username:
|
||||||
|
client.username_pw_set(
|
||||||
|
username=settings.mqtt_username,
|
||||||
|
password=settings.mqtt_password or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.loop_start()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
host=settings.mqtt_broker_host,
|
||||||
|
port=settings.mqtt_broker_port,
|
||||||
|
keepalive=10,
|
||||||
|
)
|
||||||
|
except (OSError, socket.error) as exc:
|
||||||
|
# Sanitise: ensure password never leaks into the error message.
|
||||||
|
msg = _sanitize_mqtt_error(str(exc), settings.mqtt_password)
|
||||||
|
raise _MqttConnectionError(f"Cannot reach broker: {msg}") from exc
|
||||||
|
|
||||||
|
# Wait up to 5 s for connection acknowledgement.
|
||||||
|
if not connected_event.wait(timeout=5):
|
||||||
|
raise _MqttConnectionError("Broker connection timed out (5 s).")
|
||||||
|
|
||||||
|
if connect_error:
|
||||||
|
raise _MqttConnectionError(connect_error[0])
|
||||||
|
|
||||||
|
# Publish test message.
|
||||||
|
client.publish(test_topic, payload=test_payload, qos=1, retain=False)
|
||||||
|
# Wait up to 5 s for the publish ACK (QoS 1).
|
||||||
|
if not published_event.wait(timeout=5):
|
||||||
|
raise _MqttConnectionError("Publish timed out (5 s) — broker reachable but no ACK.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
client.loop_stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mqtt_error(message: str, password: str | None) -> str:
|
||||||
|
"""Replace *password* in *message* with ``[redacted]``."""
|
||||||
|
if password:
|
||||||
|
return message.replace(password, "[redacted]")
|
||||||
|
return message
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Expose API routes (M5-T12).
|
||||||
|
|
||||||
|
Three endpoints:
|
||||||
|
GET /api/expose — Return catalog of exposable entities, per-key
|
||||||
|
toggle state, and MQTT/Discovery connection status.
|
||||||
|
PUT /api/expose — Set per-key toggle state (map key → bool);
|
||||||
|
triggers a HA Discovery re-publish on success.
|
||||||
|
POST /api/expose/republish — Manually trigger a full HA Discovery re-publish.
|
||||||
|
|
||||||
|
Auth model:
|
||||||
|
- GET: session required (no CSRF — read-only).
|
||||||
|
- PUT, POST: session + CSRF required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
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.config import get_settings
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.services.config_page import build_runtime_settings
|
||||||
|
from app.integrations.expose import CatalogEntry, build_catalog
|
||||||
|
from app.integrations.mqtt import mqtt_manager
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
from app.schemas.expose import (
|
||||||
|
CatalogEntrySchema,
|
||||||
|
DeviceInfoSchema,
|
||||||
|
ExposeResponse,
|
||||||
|
ExposeUpdateRequest,
|
||||||
|
ExposeUpdateResponse,
|
||||||
|
ExposableEntitySchema,
|
||||||
|
MqttStatusSchema,
|
||||||
|
RepublishResponse,
|
||||||
|
)
|
||||||
|
from app.services.auth import AuthenticatedSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["api-expose"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mqtt_status(db: Session) -> MqttStatusSchema:
|
||||||
|
"""Build MQTT/Discovery status from live runtime state (DB-merged settings)."""
|
||||||
|
settings = build_runtime_settings(db, get_settings())
|
||||||
|
return MqttStatusSchema(
|
||||||
|
mqtt_configured=mqtt_manager.is_configured(settings),
|
||||||
|
mqtt_connected=mqtt_manager.is_connected,
|
||||||
|
discovery_enabled=bool(settings.ha_discovery_enabled),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_to_schema(entry: CatalogEntry) -> CatalogEntrySchema:
|
||||||
|
"""Convert a CatalogEntry to its Pydantic schema form.
|
||||||
|
|
||||||
|
Excludes ``value_getter`` because it is a non-serialisable callable.
|
||||||
|
"""
|
||||||
|
entity = entry.entity
|
||||||
|
return CatalogEntrySchema(
|
||||||
|
entity=ExposableEntitySchema(
|
||||||
|
key=entity.key,
|
||||||
|
component=entity.component,
|
||||||
|
device=DeviceInfoSchema(
|
||||||
|
identifiers=list(entity.device.identifiers),
|
||||||
|
name=entity.device.name,
|
||||||
|
),
|
||||||
|
device_class=entity.device_class,
|
||||||
|
unit=entity.unit,
|
||||||
|
name=entity.name,
|
||||||
|
state_class=entity.state_class,
|
||||||
|
),
|
||||||
|
enabled=entry.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_response_data(
|
||||||
|
session: Session,
|
||||||
|
) -> tuple[list[CatalogEntrySchema], MqttStatusSchema]:
|
||||||
|
"""Return (catalog_entries, mqtt_status) for building GET / PUT responses."""
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
catalog_schema = [_entry_to_schema(e) for e in catalog]
|
||||||
|
mqtt_status = _get_mqtt_status(session)
|
||||||
|
return catalog_schema, mqtt_status
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/expose
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/expose", response_model=ExposeResponse)
|
||||||
|
def get_expose(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ExposeResponse:
|
||||||
|
"""Return the full exposable-entity catalog with toggle states and MQTT status.
|
||||||
|
|
||||||
|
The catalog is computed dynamically from registered providers (e.g. the
|
||||||
|
Modbus provider enumerates all enabled devices and their metric entities).
|
||||||
|
Toggle states come from the ``exposed_entity_toggle`` table; entities with
|
||||||
|
no row default to ``enabled=False``.
|
||||||
|
"""
|
||||||
|
catalog_schema, mqtt_status = _build_response_data(db)
|
||||||
|
return ExposeResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PUT /api/expose
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/expose", response_model=ExposeUpdateResponse)
|
||||||
|
def put_expose(
|
||||||
|
body: ExposeUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ExposeUpdateResponse:
|
||||||
|
"""Set per-entity toggle state.
|
||||||
|
|
||||||
|
Accepts a map of ``{key: bool}`` and upserts rows in the
|
||||||
|
``exposed_entity_toggle`` table. Only keys present in ``body.toggles``
|
||||||
|
are touched; other entities' toggles are left unchanged.
|
||||||
|
|
||||||
|
After writing the toggles, triggers a HA Discovery re-publish so any
|
||||||
|
changes (enabled ↔ disabled) are reflected in Home Assistant immediately.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
for key, enabled in body.toggles.items():
|
||||||
|
existing = (
|
||||||
|
db.query(ExposedEntityToggle)
|
||||||
|
.filter(ExposedEntityToggle.key == key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
existing.enabled = enabled
|
||||||
|
existing.updated_at = now
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
ExposedEntityToggle(
|
||||||
|
key=key,
|
||||||
|
enabled=enabled,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Trigger discovery re-publish after toggle change.
|
||||||
|
_trigger_republish(db)
|
||||||
|
|
||||||
|
catalog_schema, mqtt_status = _build_response_data(db)
|
||||||
|
return ExposeUpdateResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/expose/republish
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/expose/republish", response_model=RepublishResponse)
|
||||||
|
def post_expose_republish(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> RepublishResponse:
|
||||||
|
"""Manually trigger a full HA Discovery re-publish.
|
||||||
|
|
||||||
|
Calls ``publish_discovery(session)`` from the HA discovery service (M5-T11).
|
||||||
|
Returns a status indicating whether the publish was attempted (or skipped
|
||||||
|
because MQTT / discovery is not enabled / connected).
|
||||||
|
"""
|
||||||
|
settings = build_runtime_settings(db, get_settings())
|
||||||
|
if not (settings.mqtt_enabled and settings.ha_discovery_enabled and mqtt_manager.is_connected):
|
||||||
|
return RepublishResponse(
|
||||||
|
ok=False,
|
||||||
|
message="MQTT / HA Discovery not enabled or broker not connected.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_trigger_republish(db)
|
||||||
|
return RepublishResponse(
|
||||||
|
ok=True,
|
||||||
|
message="HA Discovery re-published successfully.",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("post_expose_republish: unexpected error during publish")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Discovery re-publish failed.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: trigger discovery re-publish
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_republish(session: Session) -> None:
|
||||||
|
"""Call publish_discovery from the HA discovery service.
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or broker is not connected
|
||||||
|
(publish_discovery guards internally). All errors are swallowed to avoid
|
||||||
|
breaking the API response.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.ha_discovery import publish_discovery
|
||||||
|
|
||||||
|
publish_discovery(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_trigger_republish: publish_discovery raised an error")
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
"""Modbus device CRUD, readings, metrics, and test-read API (M5-T05).
|
||||||
|
|
||||||
|
All endpoints are under /api/modbus, require an authenticated session, and
|
||||||
|
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
|
||||||
|
non-empty X-CSRF-Token header.
|
||||||
|
|
||||||
|
Deletion safety (red-line):
|
||||||
|
DELETE /api/modbus/devices/{uuid} explicitly queries the application
|
||||||
|
layer for existing modbus_reading rows before deleting. If any exist
|
||||||
|
the endpoint returns 409 and suggests disabling the device instead.
|
||||||
|
We do NOT rely on the SQLite FK RESTRICT constraint because SQLite does
|
||||||
|
not enforce foreign-key constraints at runtime unless
|
||||||
|
PRAGMA foreign_keys=ON is set per connection (and it is not in this
|
||||||
|
project — see M5-T02 review note OBS-1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
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.modbus import driver as modbus_driver
|
||||||
|
from app.integrations.modbus.driver import ModbusDriverError
|
||||||
|
from app.integrations.modbus.profiles import (
|
||||||
|
ProfileNotFoundError,
|
||||||
|
decode as decode_profile,
|
||||||
|
list_profiles,
|
||||||
|
load_profile,
|
||||||
|
)
|
||||||
|
from app.models.modbus import ModbusDevice, ModbusReading
|
||||||
|
from app.schemas.modbus import (
|
||||||
|
MetricInfo,
|
||||||
|
ModbusDeviceCreate,
|
||||||
|
ModbusDeviceListResponse,
|
||||||
|
ModbusDeviceResponse,
|
||||||
|
ModbusDeviceUpdate,
|
||||||
|
ModbusLatestResponse,
|
||||||
|
ModbusMetricsResponse,
|
||||||
|
ModbusProfilesResponse,
|
||||||
|
ModbusReadingResponse,
|
||||||
|
ModbusReadingsResponse,
|
||||||
|
ModbusTestReadResponse,
|
||||||
|
ProfileSummary,
|
||||||
|
)
|
||||||
|
from app.services.auth import AuthenticatedSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/modbus", tags=["api-modbus"])
|
||||||
|
|
||||||
|
# Maximum number of readings that can be returned per request.
|
||||||
|
_READINGS_LIMIT_MAX = 5000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device_or_404(db: Session, uuid: str) -> ModbusDevice:
|
||||||
|
"""Return the device with the given UUID or raise 404."""
|
||||||
|
device = db.execute(
|
||||||
|
select(ModbusDevice).where(ModbusDevice.uuid == uuid)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Device '{uuid}' not found.",
|
||||||
|
)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def _label_from_key(key: str) -> str:
|
||||||
|
"""Derive a human-readable label from a metric key.
|
||||||
|
|
||||||
|
Example: ``"active_power"`` → ``"Active Power"``
|
||||||
|
"""
|
||||||
|
return key.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles", response_model=ModbusProfilesResponse)
|
||||||
|
def get_profiles(
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ModbusProfilesResponse:
|
||||||
|
"""List all available Modbus YAML profiles (name + description).
|
||||||
|
|
||||||
|
Intended for the front-end's device-creation profile drop-down.
|
||||||
|
"""
|
||||||
|
pairs = list_profiles()
|
||||||
|
return ModbusProfilesResponse(
|
||||||
|
profiles=[ProfileSummary(name=name, description=desc) for name, desc in pairs]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices", response_model=ModbusDeviceListResponse)
|
||||||
|
def list_devices(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ModbusDeviceListResponse:
|
||||||
|
"""Return all Modbus devices (no pagination — device counts stay small)."""
|
||||||
|
devices = db.execute(select(ModbusDevice)).scalars().all()
|
||||||
|
items = [ModbusDeviceResponse.model_validate(d) for d in devices]
|
||||||
|
return ModbusDeviceListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/devices",
|
||||||
|
response_model=ModbusDeviceResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_device(
|
||||||
|
body: ModbusDeviceCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ModbusDeviceResponse:
|
||||||
|
"""Create a new Modbus device.
|
||||||
|
|
||||||
|
- Validates that the referenced ``profile`` exists; returns 422 if not.
|
||||||
|
- Returns 201 with the created device on success.
|
||||||
|
"""
|
||||||
|
# Validate profile exists (422 if not)
|
||||||
|
try:
|
||||||
|
load_profile(body.profile)
|
||||||
|
except ProfileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Profile '{body.profile}' does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name=body.friendly_name,
|
||||||
|
transport=body.transport,
|
||||||
|
host=body.host,
|
||||||
|
port=body.port,
|
||||||
|
unit_id=body.unit_id,
|
||||||
|
profile=body.profile,
|
||||||
|
poll_interval_s=body.poll_interval_s,
|
||||||
|
enabled=body.enabled,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(device)
|
||||||
|
logger.info("Created Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
|
||||||
|
return ModbusDeviceResponse.model_validate(device)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{uuid}", response_model=ModbusDeviceResponse)
|
||||||
|
def get_device(
|
||||||
|
uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ModbusDeviceResponse:
|
||||||
|
"""Return a single Modbus device by UUID."""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
return ModbusDeviceResponse.model_validate(device)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/devices/{uuid}", response_model=ModbusDeviceResponse)
|
||||||
|
def patch_device(
|
||||||
|
uuid: str,
|
||||||
|
body: ModbusDeviceUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ModbusDeviceResponse:
|
||||||
|
"""Partially update a Modbus device (including enable/disable).
|
||||||
|
|
||||||
|
Only fields explicitly provided in the request body are updated.
|
||||||
|
Providing ``profile`` triggers a profile-existence check (422 if unknown).
|
||||||
|
"""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# If profile is being changed, validate it exists.
|
||||||
|
if "profile" in update_data:
|
||||||
|
try:
|
||||||
|
load_profile(update_data["profile"])
|
||||||
|
except ProfileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Profile '{update_data['profile']}' does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(device, field, value)
|
||||||
|
|
||||||
|
device.updated_at = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(device)
|
||||||
|
logger.info("Updated Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
|
||||||
|
return ModbusDeviceResponse.model_validate(device)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/devices/{uuid}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
response_model=None,
|
||||||
|
)
|
||||||
|
def delete_device(
|
||||||
|
uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a Modbus device.
|
||||||
|
|
||||||
|
Returns 409 Conflict if the device has any associated readings; use
|
||||||
|
``enabled=false`` to disable it instead.
|
||||||
|
|
||||||
|
**Application-layer safety**: the check is performed via an explicit
|
||||||
|
SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
|
||||||
|
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
|
||||||
|
"""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if reading_count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
f"Device '{uuid}' has {reading_count} associated reading(s) and cannot be "
|
||||||
|
"deleted. Disable the device (set enabled=false) instead to stop polling "
|
||||||
|
"without losing historical data."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
|
||||||
|
db.delete(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Readings endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{uuid}/latest", response_model=ModbusLatestResponse)
|
||||||
|
def get_latest_reading(
|
||||||
|
uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ModbusLatestResponse:
|
||||||
|
"""Return the most recent reading for a device.
|
||||||
|
|
||||||
|
If no readings exist yet, returns ``{"found": false, "recorded_at": null,
|
||||||
|
"payload": null}`` (200, not 404) so the front-end can distinguish
|
||||||
|
"device exists but has no data" from "device not found".
|
||||||
|
"""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
select(ModbusReading)
|
||||||
|
.where(ModbusReading.device_id == device.id)
|
||||||
|
.order_by(ModbusReading.recorded_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return ModbusLatestResponse(found=False, recorded_at=None, payload=None)
|
||||||
|
|
||||||
|
return ModbusLatestResponse(
|
||||||
|
found=True,
|
||||||
|
recorded_at=row.recorded_at,
|
||||||
|
payload=row.payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{uuid}/readings", response_model=ModbusReadingsResponse)
|
||||||
|
def get_readings(
|
||||||
|
uuid: str,
|
||||||
|
start: datetime | None = Query(default=None),
|
||||||
|
end: datetime | None = Query(default=None),
|
||||||
|
limit: int = Query(default=500, ge=1, le=_READINGS_LIMIT_MAX),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ModbusReadingsResponse:
|
||||||
|
"""Return time-range readings for a device.
|
||||||
|
|
||||||
|
When the window contains more rows than ``limit``, the **most recent** N rows
|
||||||
|
are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to
|
||||||
|
ascending order before being sent to the client. This ensures that for long
|
||||||
|
time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data
|
||||||
|
rather than the oldest segment of the window.
|
||||||
|
|
||||||
|
When the window has fewer rows than ``limit`` the full window is returned in
|
||||||
|
ascending order — behaviour is identical to a plain ascending query.
|
||||||
|
|
||||||
|
The response schema is unchanged: items are always ``recorded_at`` ascending.
|
||||||
|
|
||||||
|
The query uses the ``(device_id, recorded_at)`` composite index for
|
||||||
|
efficient time-window scans.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- ``start``: inclusive lower bound (ISO8601 datetime)
|
||||||
|
- ``end``: inclusive upper bound (ISO8601 datetime)
|
||||||
|
- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
|
||||||
|
"""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(ModbusReading)
|
||||||
|
.where(ModbusReading.device_id == device.id)
|
||||||
|
.order_by(ModbusReading.recorded_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
if start is not None:
|
||||||
|
stmt = stmt.where(ModbusReading.recorded_at >= start)
|
||||||
|
if end is not None:
|
||||||
|
stmt = stmt.where(ModbusReading.recorded_at <= end)
|
||||||
|
|
||||||
|
# Fetch most-recent N rows, then reverse to restore ascending order for the response.
|
||||||
|
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||||
|
items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows]
|
||||||
|
return ModbusReadingsResponse(items=items)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metrics endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{uuid}/metrics", response_model=ModbusMetricsResponse)
|
||||||
|
def get_metrics(
|
||||||
|
uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> ModbusMetricsResponse:
|
||||||
|
"""Return the metric catalogue for a device's profile.
|
||||||
|
|
||||||
|
Each entry has ``key``, ``label`` (derived from key if the profile has
|
||||||
|
none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
|
||||||
|
This is the authoritative metadata source for front-end card labels and
|
||||||
|
chart axis labels.
|
||||||
|
"""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = load_profile(device.profile)
|
||||||
|
except ProfileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Device profile '{device.profile}' could not be loaded.",
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = [
|
||||||
|
MetricInfo(
|
||||||
|
key=m.key,
|
||||||
|
label=_label_from_key(m.key),
|
||||||
|
unit=m.unit,
|
||||||
|
device_class=m.device_class,
|
||||||
|
)
|
||||||
|
for m in profile.metrics
|
||||||
|
]
|
||||||
|
|
||||||
|
return ModbusMetricsResponse(profile=profile.name, metrics=metrics)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test-read endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{uuid}/test", response_model=ModbusTestReadResponse)
|
||||||
|
def test_read(
|
||||||
|
uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> ModbusTestReadResponse:
|
||||||
|
"""Immediately read and decode the device once without persisting any data.
|
||||||
|
|
||||||
|
Useful for validating gateway connectivity and Modbus address settings
|
||||||
|
before relying on the background polling job.
|
||||||
|
|
||||||
|
This is a write-class endpoint (it initiates network I/O on demand) and
|
||||||
|
therefore requires a CSRF token. The response payload is never stored.
|
||||||
|
"""
|
||||||
|
device = _get_device_or_404(db, uuid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = load_profile(device.profile)
|
||||||
|
except ProfileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Device profile '{device.profile}' could not be loaded.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
registers = modbus_driver.read_blocks(
|
||||||
|
device.host,
|
||||||
|
device.port,
|
||||||
|
device.unit_id,
|
||||||
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = decode_profile(profile, registers)
|
||||||
|
return ModbusTestReadResponse(ok=True, payload=payload)
|
||||||
|
|
||||||
|
except ModbusDriverError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Test read failed for device %r (uuid=%s): %s",
|
||||||
|
device.friendly_name,
|
||||||
|
uuid,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return ModbusTestReadResponse(ok=False, error=str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected error during test read for device %r (uuid=%s): %s",
|
||||||
|
device.friendly_name,
|
||||||
|
uuid,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return ModbusTestReadResponse(ok=False, error=f"Unexpected error: {exc}")
|
||||||
@@ -41,6 +41,23 @@ class Settings(BaseSettings):
|
|||||||
auth_trust_forwarded_for: bool = False
|
auth_trust_forwarded_for: bool = False
|
||||||
auth_totp_issuer: str = "" # defaults to app_name when empty
|
auth_totp_issuer: str = "" # defaults to app_name when empty
|
||||||
|
|
||||||
|
# Modbus polling — global kill-switch (CONFIG_FIELDS registered in T08).
|
||||||
|
# Off by default: polling is opt-in so a fresh deploy writes no modbus_reading
|
||||||
|
# rows until the admin explicitly enables it (matches mqtt/discovery defaults).
|
||||||
|
modbus_polling_enabled: bool = False
|
||||||
|
|
||||||
|
# MQTT broker connection (T08 wires into CONFIG_FIELDS/UI; T10 builds the client).
|
||||||
|
mqtt_enabled: bool = False
|
||||||
|
mqtt_broker_host: str = ""
|
||||||
|
mqtt_broker_port: int = 1883
|
||||||
|
mqtt_username: str = ""
|
||||||
|
mqtt_password: str = ""
|
||||||
|
mqtt_tls_enabled: bool = False
|
||||||
|
|
||||||
|
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
||||||
|
ha_discovery_enabled: bool = False
|
||||||
|
ha_discovery_prefix: str = "homeassistant"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ def _get_engine(database_url: str) -> Engine:
|
|||||||
def _enable_sqlite_wal(dbapi_connection, _connection_record):
|
def _enable_sqlite_wal(dbapi_connection, _connection_record):
|
||||||
cursor = dbapi_connection.cursor()
|
cursor = dbapi_connection.cursor()
|
||||||
cursor.execute("PRAGMA journal_mode=WAL")
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
return engine
|
return engine
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""Expose framework: ExposableEntity, provider protocol, and build_catalog.
|
||||||
|
|
||||||
|
This module defines:
|
||||||
|
|
||||||
|
- ``ExposableEntity`` — a dataclass describing one publishable MQTT/HA entity.
|
||||||
|
- Provider protocol — a simple callable ``(session) -> list[ExposableEntity]``.
|
||||||
|
- A provider registry and registration helper.
|
||||||
|
- ``build_catalog(session)`` — merges all registered providers' entities with
|
||||||
|
per-key toggle state from the ``exposed_entity_toggle`` table.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **No over-engineering**: providers are plain callables, not abstract base
|
||||||
|
classes. This project is personal / single-user; keep it flat.
|
||||||
|
- **Key stability**: entity keys MUST be derived from device uuid + metric key
|
||||||
|
(e.g. ``"modbus.<uuid>.voltage"``), never from auto-increment IDs. Stable
|
||||||
|
keys mean the toggle table survives device removal/re-addition without drift.
|
||||||
|
- **Metadata from profile**: the modbus provider reads ``device_class``,
|
||||||
|
``unit``, and ``component`` directly from the YAML profile's ``metrics[]``
|
||||||
|
rather than maintaining a separate mapping.
|
||||||
|
- **value_getter**: a callable ``() -> object | None`` or ``None`` if not yet
|
||||||
|
implemented (T11 will wire real getters). Presence of the field lets T11
|
||||||
|
call it without changing the dataclass interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Optional, Protocol
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExposableEntity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceInfo:
|
||||||
|
"""Grouping metadata that maps an entity to a logical HA device.
|
||||||
|
|
||||||
|
``identifiers`` corresponds to ``device.identifiers`` in HA Discovery
|
||||||
|
config — a stable tuple used to group entities under one device card.
|
||||||
|
``name`` is the human-readable device name (friendly_name).
|
||||||
|
"""
|
||||||
|
|
||||||
|
identifiers: tuple[str, ...]
|
||||||
|
"""Stable identifiers for HA device grouping (e.g. ``("modbus", "<uuid>")``).
|
||||||
|
|
||||||
|
These must not change over the device lifetime; they anchor the HA device
|
||||||
|
card even when friendly_name changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""Human-readable device name (may change; triggers HA discovery re-publish)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExposableEntity:
|
||||||
|
"""One publishable entity in the MQTT / HA Discovery expose framework.
|
||||||
|
|
||||||
|
Fields
|
||||||
|
------
|
||||||
|
key
|
||||||
|
Stable unique identifier for this entity. Used as the toggle table
|
||||||
|
key and as part of the HA Discovery ``unique_id`` derivation.
|
||||||
|
Convention: ``"<provider>.<device_uuid>.<metric_key>"``,
|
||||||
|
e.g. ``"modbus.abc123.voltage"`` or ``"modbus.abc123.online"``.
|
||||||
|
|
||||||
|
component
|
||||||
|
HA MQTT component type: ``"sensor"``, ``"binary_sensor"``, ``"switch"``, etc.
|
||||||
|
|
||||||
|
device
|
||||||
|
Grouping info (DeviceInfo) that determines which HA device card this
|
||||||
|
entity belongs to.
|
||||||
|
|
||||||
|
device_class
|
||||||
|
HA ``device_class`` string (e.g. ``"voltage"``, ``"power"``, ``"None"``).
|
||||||
|
Empty string or ``None`` means no device_class (e.g. for the online sensor).
|
||||||
|
|
||||||
|
unit
|
||||||
|
Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``).
|
||||||
|
Empty string for dimensionless.
|
||||||
|
|
||||||
|
name
|
||||||
|
Human-readable entity label (friendly display name, may include device
|
||||||
|
context, e.g. ``"SDM120 Voltage"``).
|
||||||
|
|
||||||
|
value_getter
|
||||||
|
Optional callable ``(session) -> object | None`` that returns the current
|
||||||
|
entity value given an open SQLAlchemy session. ``None`` means "not yet
|
||||||
|
wired". T11 calls this to obtain the current state before publishing.
|
||||||
|
Signature: ``Callable[[Session], Any]``.
|
||||||
|
|
||||||
|
state_class
|
||||||
|
HA ``state_class`` string (e.g. ``"measurement"``, ``"total_increasing"``).
|
||||||
|
``None`` means not set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
component: str
|
||||||
|
device: DeviceInfo
|
||||||
|
device_class: Optional[str]
|
||||||
|
unit: str
|
||||||
|
name: str
|
||||||
|
value_getter: Optional[Callable[["Session"], Any]] = field(default=None, repr=False)
|
||||||
|
state_class: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider protocol
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class EntityProvider(Protocol):
|
||||||
|
"""Protocol for entity provider callables.
|
||||||
|
|
||||||
|
A provider is any callable that accepts a ``Session`` and returns a list
|
||||||
|
of ``ExposableEntity`` objects. Using a Protocol (not ABC) keeps the
|
||||||
|
implementation lightweight — any matching callable qualifies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __call__(self, session: Session) -> list[ExposableEntity]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_REGISTRY: list[EntityProvider] = []
|
||||||
|
|
||||||
|
|
||||||
|
def register_provider(provider: EntityProvider) -> EntityProvider:
|
||||||
|
"""Register an entity provider.
|
||||||
|
|
||||||
|
Can be used as a decorator::
|
||||||
|
|
||||||
|
@register_provider
|
||||||
|
def my_provider(session: Session) -> list[ExposableEntity]:
|
||||||
|
...
|
||||||
|
|
||||||
|
Or called directly::
|
||||||
|
|
||||||
|
register_provider(my_provider)
|
||||||
|
|
||||||
|
Returns the provider unchanged (for decorator compatibility).
|
||||||
|
"""
|
||||||
|
_REGISTRY.append(provider)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def get_providers() -> list[EntityProvider]:
|
||||||
|
"""Return a snapshot of the currently registered providers."""
|
||||||
|
return list(_REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catalog builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CatalogEntry:
|
||||||
|
"""An entity from the catalog, enriched with its current toggle state."""
|
||||||
|
|
||||||
|
entity: ExposableEntity
|
||||||
|
enabled: bool
|
||||||
|
"""True if this entity is currently enabled in the toggle table."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_catalog(session: Session) -> list[CatalogEntry]:
|
||||||
|
"""Enumerate all entities from registered providers and attach toggle state.
|
||||||
|
|
||||||
|
For each entity key, the toggle state is looked up in ``exposed_entity_toggle``.
|
||||||
|
Entities with no toggle row default to ``enabled=False``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Active SQLAlchemy session for DB access.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[CatalogEntry]
|
||||||
|
All entities from all registered providers, each paired with its
|
||||||
|
current ``enabled`` state.
|
||||||
|
"""
|
||||||
|
from app.models.expose import ExposedEntityToggle # local import to avoid circular
|
||||||
|
|
||||||
|
# Collect all entities from all providers.
|
||||||
|
all_entities: list[ExposableEntity] = []
|
||||||
|
for provider in _REGISTRY:
|
||||||
|
try:
|
||||||
|
entities = provider(session)
|
||||||
|
all_entities.extend(entities)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Provider %r raised an error; skipping its entities", provider)
|
||||||
|
|
||||||
|
if not all_entities:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Load all toggle rows in one query for efficiency.
|
||||||
|
keys = [e.key for e in all_entities]
|
||||||
|
toggle_rows = session.query(ExposedEntityToggle).filter(
|
||||||
|
ExposedEntityToggle.key.in_(keys)
|
||||||
|
).all()
|
||||||
|
toggle_map: dict[str, bool] = {row.key: row.enabled for row in toggle_rows}
|
||||||
|
|
||||||
|
return [
|
||||||
|
CatalogEntry(entity=entity, enabled=toggle_map.get(entity.key, False))
|
||||||
|
for entity in all_entities
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Modbus provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _modbus_provider(session: Session) -> list[ExposableEntity]:
|
||||||
|
"""Enumerate ExposableEntity objects for all enabled Modbus devices.
|
||||||
|
|
||||||
|
For each enabled ``ModbusDevice``:
|
||||||
|
- Loads its YAML profile.
|
||||||
|
- Produces one ``sensor`` entity per metric in ``profile.metrics``
|
||||||
|
(device_class / unit / component taken from the metric spec).
|
||||||
|
- Produces one ``binary_sensor`` entity named "online" (derived from
|
||||||
|
``device.last_poll_ok``).
|
||||||
|
|
||||||
|
Entity keys follow the pattern ``"modbus.<uuid>.<metric_key>"``, which is
|
||||||
|
stable across device renames and DB rebuilds.
|
||||||
|
"""
|
||||||
|
from app.models.modbus import ModbusDevice # local import
|
||||||
|
from app.integrations.modbus.profiles import (
|
||||||
|
load_profile,
|
||||||
|
ProfileNotFoundError,
|
||||||
|
ProfileValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
devices: list[ModbusDevice] = session.query(ModbusDevice).filter(
|
||||||
|
ModbusDevice.enabled.is_(True)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
entities: list[ExposableEntity] = []
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers=("modbus", device.uuid),
|
||||||
|
name=device.friendly_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the profile to get metric metadata.
|
||||||
|
try:
|
||||||
|
profile = load_profile(device.profile)
|
||||||
|
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping device %r (uuid=%s): cannot load profile %r: %s",
|
||||||
|
device.friendly_name,
|
||||||
|
device.uuid,
|
||||||
|
device.profile,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# One sensor entity per profile metric.
|
||||||
|
for metric in profile.metrics:
|
||||||
|
entity_key = f"modbus.{device.uuid}.{metric.key}"
|
||||||
|
|
||||||
|
# Capture device id and metric key for the value_getter closure.
|
||||||
|
# The getter queries the most recent ModbusReading for this device
|
||||||
|
# and extracts the metric value from its payload.
|
||||||
|
_device_id = device.id
|
||||||
|
_metric_key = metric.key
|
||||||
|
|
||||||
|
def _make_value_getter(
|
||||||
|
dev_id: int, m_key: str
|
||||||
|
) -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter that fetches the latest reading value from DB."""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from app.models.modbus import ModbusReading
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
reading = (
|
||||||
|
sess.query(ModbusReading)
|
||||||
|
.filter(ModbusReading.device_id == dev_id)
|
||||||
|
.order_by(desc(ModbusReading.recorded_at))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if reading is None:
|
||||||
|
return None
|
||||||
|
return reading.payload.get(m_key)
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
entities.append(
|
||||||
|
ExposableEntity(
|
||||||
|
key=entity_key,
|
||||||
|
component=metric.ha_component,
|
||||||
|
device=device_info,
|
||||||
|
device_class=metric.device_class or None,
|
||||||
|
unit=metric.unit,
|
||||||
|
name=f"{device.friendly_name} {metric.key.replace('_', ' ').title()}",
|
||||||
|
value_getter=_make_value_getter(_device_id, _metric_key),
|
||||||
|
state_class=metric.state_class,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# One binary_sensor entity for device "online" status.
|
||||||
|
online_key = f"modbus.{device.uuid}.online"
|
||||||
|
_dev_id_online = device.id
|
||||||
|
|
||||||
|
def _make_online_getter(dev_id: int) -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter that reports the device online status from DB."""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from app.models.modbus import ModbusDevice
|
||||||
|
|
||||||
|
dev = sess.query(ModbusDevice).filter(
|
||||||
|
ModbusDevice.id == dev_id
|
||||||
|
).first()
|
||||||
|
if dev is None:
|
||||||
|
return None
|
||||||
|
# Map last_poll_ok to HA binary_sensor payload strings.
|
||||||
|
if dev.last_poll_ok is True:
|
||||||
|
return "ON"
|
||||||
|
return "OFF"
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
entities.append(
|
||||||
|
ExposableEntity(
|
||||||
|
key=online_key,
|
||||||
|
component="binary_sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="connectivity",
|
||||||
|
unit="",
|
||||||
|
name=f"{device.friendly_name} Online",
|
||||||
|
value_getter=_make_online_getter(_dev_id_online),
|
||||||
|
state_class=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
|
||||||
|
# Register the modbus provider at module load time.
|
||||||
|
register_provider(_modbus_provider)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Modbus integration package — driver, profile loader, and decoder."""
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Modbus TCP driver — thin wrapper around pymodbus.
|
||||||
|
|
||||||
|
This module provides a single public function ``read_blocks`` that performs
|
||||||
|
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
|
||||||
|
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
|
||||||
|
|
||||||
|
Design decisions
|
||||||
|
----------------
|
||||||
|
- **Only read function codes** (FC03/FC04) are exposed. There is no write path.
|
||||||
|
- float32 decoding uses ``struct.unpack('>f', ...)`` directly rather than the
|
||||||
|
pymodbus ``BinaryPayloadDecoder`` helper, which has had API churn across 3.x
|
||||||
|
releases. Big-endian word order + big-endian byte order means the 4 raw bytes
|
||||||
|
are already in standard network (big-endian) order.
|
||||||
|
- ``ModbusTcpClient`` is used in synchronous mode (``connect()`` / ``close()``).
|
||||||
|
The client is created fresh per call; this keeps the driver stateless and
|
||||||
|
avoids threading concerns at the cost of one TCP handshake per read cycle.
|
||||||
|
APScheduler jobs call this from a thread pool, so stateless is safer.
|
||||||
|
- pymodbus 3.13.x uses ``device_id=`` as the slave-address keyword argument
|
||||||
|
(renamed from ``slave=`` in earlier 3.x releases).
|
||||||
|
|
||||||
|
Exceptions
|
||||||
|
----------
|
||||||
|
``ModbusDriverError``
|
||||||
|
Base exception for all driver-level errors.
|
||||||
|
``ModbusConnectionError``
|
||||||
|
Raised when the TCP connection to the gateway cannot be established or is
|
||||||
|
lost during the read.
|
||||||
|
``ModbusResponseError``
|
||||||
|
Raised when the gateway responds with a Modbus exception frame or when the
|
||||||
|
returned register count does not match the requested count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pymodbus.client import ModbusTcpClient
|
||||||
|
from pymodbus.exceptions import ConnectionException, ModbusException
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDriverError(RuntimeError):
|
||||||
|
"""Base class for all Modbus driver errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusConnectionError(ModbusDriverError):
|
||||||
|
"""Cannot connect to (or lost connection with) the Modbus TCP gateway."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusResponseError(ModbusDriverError):
|
||||||
|
"""Modbus gateway returned an exception frame or an unexpected response."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Block definition type alias
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: A single read block: ``{"start": int, "count": int}``.
|
||||||
|
Block = dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def read_blocks(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
unit_id: int,
|
||||||
|
blocks: Sequence[Block],
|
||||||
|
*,
|
||||||
|
timeout: float = 3.0,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Read one or more contiguous register blocks via FC04 (input registers).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
host:
|
||||||
|
Hostname or IP address of the Modbus TCP gateway.
|
||||||
|
port:
|
||||||
|
TCP port (typically 502).
|
||||||
|
unit_id:
|
||||||
|
Modbus slave / unit address (the device's Meter ID for SDM120).
|
||||||
|
blocks:
|
||||||
|
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
||||||
|
contiguous register ranges to read. ``count`` is the number of
|
||||||
|
16-bit registers (not bytes).
|
||||||
|
timeout:
|
||||||
|
TCP connect/read timeout in seconds (default 3 s).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict[int, int]
|
||||||
|
Flat mapping of ``{register_address: 16-bit_register_value}`` for
|
||||||
|
every register returned across all blocks.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ModbusConnectionError
|
||||||
|
If the TCP connection to the gateway fails.
|
||||||
|
ModbusResponseError
|
||||||
|
If the gateway returns a Modbus exception frame or an unexpected
|
||||||
|
number of registers.
|
||||||
|
"""
|
||||||
|
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||||
|
try:
|
||||||
|
connected = client.connect()
|
||||||
|
if not connected:
|
||||||
|
raise ModbusConnectionError(
|
||||||
|
f"Could not connect to Modbus gateway at {host}:{port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
registers: dict[int, int] = {}
|
||||||
|
for block in blocks:
|
||||||
|
start: int = block["start"]
|
||||||
|
count: int = block["count"]
|
||||||
|
_read_block(client, unit_id, start, count, registers)
|
||||||
|
|
||||||
|
return registers
|
||||||
|
|
||||||
|
except ConnectionException as exc:
|
||||||
|
raise ModbusConnectionError(
|
||||||
|
f"Connection to Modbus gateway {host}:{port} failed: {exc}"
|
||||||
|
) from exc
|
||||||
|
except ModbusException as exc:
|
||||||
|
raise ModbusResponseError(f"Modbus protocol error: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_block(
|
||||||
|
client: ModbusTcpClient,
|
||||||
|
unit_id: int,
|
||||||
|
start: int,
|
||||||
|
count: int,
|
||||||
|
result: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
"""Read one block and merge into *result*. Raises on any error."""
|
||||||
|
try:
|
||||||
|
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||||
|
except ConnectionException as exc:
|
||||||
|
raise ModbusConnectionError(
|
||||||
|
f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.isError():
|
||||||
|
raise ModbusResponseError(
|
||||||
|
f"Modbus exception reading registers 0x{start:04X}+{count}: {response}"
|
||||||
|
)
|
||||||
|
|
||||||
|
regs = response.registers
|
||||||
|
if len(regs) != count:
|
||||||
|
raise ModbusResponseError(
|
||||||
|
f"Expected {count} registers from 0x{start:04X}, got {len(regs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for offset, value in enumerate(regs):
|
||||||
|
result[start + offset] = value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Decoding helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def registers_to_float(hi_reg: int, lo_reg: int) -> float:
|
||||||
|
"""Decode two 16-bit registers to a big-endian IEEE-754 float32.
|
||||||
|
|
||||||
|
The SDM120 (and most Modbus float devices) use big-endian word order:
|
||||||
|
the high 16-bit register comes first, then the low register. Combined
|
||||||
|
with big-endian byte order within each register, the raw 4-byte sequence
|
||||||
|
is standard network-byte-order (big-endian) float32.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
hi_reg:
|
||||||
|
The first (higher-address-range, most-significant) 16-bit register.
|
||||||
|
lo_reg:
|
||||||
|
The second (lower-address-range, least-significant) 16-bit register.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float
|
||||||
|
The decoded IEEE-754 single-precision floating-point value.
|
||||||
|
|
||||||
|
Example
|
||||||
|
-------
|
||||||
|
>>> registers_to_float(0x4366, 0x3334)
|
||||||
|
230.20001220703125
|
||||||
|
"""
|
||||||
|
raw = struct.pack(">HH", hi_reg, lo_reg)
|
||||||
|
return struct.unpack(">f", raw)[0]
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Modbus profile loader, validator, and decoder.
|
||||||
|
|
||||||
|
A *profile* is a YAML file that describes the protocol-level knowledge for
|
||||||
|
a particular Modbus device model:
|
||||||
|
|
||||||
|
- Which registers to read (``blocks`` for efficient bulk reads).
|
||||||
|
- How to decode each quantity (``metrics``: register address, type, unit, …).
|
||||||
|
- Home Assistant metadata per metric (``device_class``, ``ha_component``, …).
|
||||||
|
|
||||||
|
Profiles live in ``app/integrations/modbus/profiles/<name>.yaml`` and are
|
||||||
|
located at runtime relative to *this file* (not CWD), so they work whether
|
||||||
|
the package is installed as a wheel, run in-process, or invoked via
|
||||||
|
``python -m scripts.modbus_cli``.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **Purely data + functions** — no abstract base classes or inheritance.
|
||||||
|
- ``ModbusProfile`` and ``MetricSpec`` are Pydantic models: they validate on
|
||||||
|
construction and raise ``pydantic.ValidationError`` for malformed YAML.
|
||||||
|
- ``decode`` uses ``driver.registers_to_float`` for float32 quantities.
|
||||||
|
Unsupported types are silently skipped with a warning (future-proofing for
|
||||||
|
int16/uint16 etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from app.integrations.modbus.driver import registers_to_float
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Directory containing the YAML profiles, located relative to *this* file.
|
||||||
|
_PROFILES_DIR = Path(__file__).parent / "profiles"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic models for profile validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MetricSpec(BaseModel):
|
||||||
|
"""Specification for a single measurable quantity within a profile."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
"""Stable identifier used as the key in decoded payloads (e.g. ``"voltage"``)."""
|
||||||
|
|
||||||
|
address: int
|
||||||
|
"""Starting Modbus register address (0-based, e.g. ``0x0000`` for voltage)."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
"""Encoding type — currently only ``"float32"`` is supported."""
|
||||||
|
|
||||||
|
unit: str
|
||||||
|
"""Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``). Empty string for dimensionless."""
|
||||||
|
|
||||||
|
device_class: str
|
||||||
|
"""Home Assistant device class (e.g. ``"voltage"``, ``"power"``, ``"energy"``)."""
|
||||||
|
|
||||||
|
ha_component: str
|
||||||
|
"""Home Assistant component type (e.g. ``"sensor"``)."""
|
||||||
|
|
||||||
|
state_class: Optional[str] = None
|
||||||
|
"""Home Assistant state class, if applicable (e.g. ``"total_increasing"``)."""
|
||||||
|
|
||||||
|
|
||||||
|
class BlockSpec(BaseModel):
|
||||||
|
"""A contiguous register block for bulk reading."""
|
||||||
|
|
||||||
|
start: int
|
||||||
|
"""First register address in the block."""
|
||||||
|
|
||||||
|
count: int
|
||||||
|
"""Number of 16-bit registers to read."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusProfile(BaseModel):
|
||||||
|
"""Complete description of a Modbus device's protocol-level knowledge."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""Short identifier matching the YAML file name (e.g. ``"sdm120"``)."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
"""Human-readable description of the device."""
|
||||||
|
|
||||||
|
function_code: int
|
||||||
|
"""Modbus function code for reading measurements (4 = FC04 input registers)."""
|
||||||
|
|
||||||
|
word_order: str
|
||||||
|
"""Word (register) order: ``"big"`` means high register first."""
|
||||||
|
|
||||||
|
byte_order: str
|
||||||
|
"""Byte order within each register: ``"big"`` means standard network order."""
|
||||||
|
|
||||||
|
blocks: list[BlockSpec]
|
||||||
|
"""Register blocks to read in bulk, reducing Modbus transaction count."""
|
||||||
|
|
||||||
|
metrics: list[MetricSpec]
|
||||||
|
"""List of individual measurable quantities and their decoding rules."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileNotFoundError(FileNotFoundError):
|
||||||
|
"""Raised when the requested profile YAML file does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileValidationError(ValueError):
|
||||||
|
"""Raised when a profile YAML file fails Pydantic validation."""
|
||||||
|
|
||||||
|
|
||||||
|
def load_profile(name: str) -> ModbusProfile:
|
||||||
|
"""Load and validate a Modbus device profile by name.
|
||||||
|
|
||||||
|
The profile file is expected at
|
||||||
|
``app/integrations/modbus/profiles/<name>.yaml``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name:
|
||||||
|
Profile name without extension (e.g. ``"sdm120"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ModbusProfile
|
||||||
|
Validated profile model.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ProfileNotFoundError
|
||||||
|
If ``profiles/<name>.yaml`` does not exist.
|
||||||
|
ProfileValidationError
|
||||||
|
If the YAML is syntactically valid but the content fails schema
|
||||||
|
validation (missing required fields, wrong types, etc.).
|
||||||
|
"""
|
||||||
|
path = _PROFILES_DIR / f"{name}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
raise ProfileNotFoundError(
|
||||||
|
f"Modbus profile '{name}' not found (looked for {path})"
|
||||||
|
)
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
raw = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = ModbusProfile.model_validate(raw)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{name}' failed validation: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> list[tuple[str, str]]:
|
||||||
|
"""Return ``(name, description)`` pairs for all available profiles.
|
||||||
|
|
||||||
|
Scans ``app/integrations/modbus/profiles/*.yaml``, loads each, and
|
||||||
|
returns a sorted list of ``(name, description)`` tuples. Profiles that
|
||||||
|
fail to load are skipped with a warning.
|
||||||
|
"""
|
||||||
|
results: list[tuple[str, str]] = []
|
||||||
|
if not _PROFILES_DIR.exists():
|
||||||
|
return results
|
||||||
|
|
||||||
|
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
|
||||||
|
name = path.stem
|
||||||
|
try:
|
||||||
|
profile = load_profile(name)
|
||||||
|
results.append((profile.name, profile.description))
|
||||||
|
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
|
||||||
|
logger.warning("Skipping malformed profile '%s': %s", name, exc)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def decode(profile: ModbusProfile, registers: dict[int, int]) -> dict[str, float]:
|
||||||
|
"""Decode raw register values into a mapping of ``{key: float_value}``.
|
||||||
|
|
||||||
|
For each metric in *profile*, the function looks up the two registers at
|
||||||
|
``address`` and ``address + 1`` in *registers* and decodes them as a
|
||||||
|
big-endian float32 (high register first).
|
||||||
|
|
||||||
|
Metrics whose registers are absent from *registers* are skipped (not
|
||||||
|
an error — this can happen if a block was not requested or a device does
|
||||||
|
not populate that register). Metrics with unsupported ``type`` values
|
||||||
|
are also skipped.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
profile:
|
||||||
|
Validated ``ModbusProfile`` describing the device.
|
||||||
|
registers:
|
||||||
|
Mapping of ``{register_address: 16-bit_value}`` as returned by
|
||||||
|
``driver.read_blocks``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict[str, float]
|
||||||
|
Decoded engineering values keyed by each metric's ``key`` field.
|
||||||
|
"""
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for metric in profile.metrics:
|
||||||
|
if metric.type == "float32":
|
||||||
|
hi_addr = metric.address
|
||||||
|
lo_addr = metric.address + 1
|
||||||
|
if hi_addr not in registers or lo_addr not in registers:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping metric '%s': registers 0x%04X/0x%04X not available",
|
||||||
|
metric.key,
|
||||||
|
hi_addr,
|
||||||
|
lo_addr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
value = registers_to_float(registers[hi_addr], registers[lo_addr])
|
||||||
|
result[metric.key] = value
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Metric '%s' has unsupported type '%s', skipping",
|
||||||
|
metric.key,
|
||||||
|
metric.type,
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: sdm120
|
||||||
|
description: Eastron SDM120 single-phase energy meter
|
||||||
|
function_code: 4 # input registers (FC04)
|
||||||
|
word_order: big # high register first (big-endian word order)
|
||||||
|
byte_order: big # high byte first within each register
|
||||||
|
|
||||||
|
blocks:
|
||||||
|
# Registers 30001–30095 (0x0000–0x005F): voltage through max export demand
|
||||||
|
- { start: 0x0000, count: 0x0060 }
|
||||||
|
# Registers 30343–30346 (0x0156–0x0159): total active/reactive energy
|
||||||
|
- { start: 0x0156, count: 0x0004 }
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
# Core measurements — addresses from SDM120 Modbus Protocol §4 (FC04 input registers)
|
||||||
|
# Each float32 occupies two consecutive 16-bit registers; address = Hex start in §4.
|
||||||
|
|
||||||
|
- key: voltage
|
||||||
|
address: 0x0000 # register 30001 — Voltage (V)
|
||||||
|
type: float32
|
||||||
|
unit: "V"
|
||||||
|
device_class: voltage
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: current
|
||||||
|
address: 0x0006 # register 30007 — Current (A)
|
||||||
|
type: float32
|
||||||
|
unit: "A"
|
||||||
|
device_class: current
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: active_power
|
||||||
|
address: 0x000C # register 30013 — Active power (W)
|
||||||
|
type: float32
|
||||||
|
unit: "W"
|
||||||
|
device_class: power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: power_factor
|
||||||
|
address: 0x001E # register 30031 — Power factor (dimensionless)
|
||||||
|
type: float32
|
||||||
|
unit: ""
|
||||||
|
device_class: power_factor
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: frequency
|
||||||
|
address: 0x0046 # register 30071 — Frequency (Hz)
|
||||||
|
type: float32
|
||||||
|
unit: "Hz"
|
||||||
|
device_class: frequency
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: import_energy
|
||||||
|
address: 0x0048 # register 30073 — Import active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: export_energy
|
||||||
|
address: 0x004A # register 30075 — Export active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: total_energy
|
||||||
|
address: 0x0156 # register 30343 — Total active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""MQTT client integration (paho-mqtt 2.x).
|
||||||
|
|
||||||
|
Provides :class:`MqttManager`: a long-lived MQTT client that runs paho's
|
||||||
|
loop in a background thread. Designed to be started in FastAPI's lifespan
|
||||||
|
and shared across the application as a module-level singleton (``mqtt_manager``).
|
||||||
|
|
||||||
|
Key design decisions
|
||||||
|
--------------------
|
||||||
|
- **paho 2.x API**: ``Client`` requires ``CallbackAPIVersion.VERSION2`` as the
|
||||||
|
first positional argument. VERSION2 on_connect callback receives
|
||||||
|
``(client, userdata, connect_flags, reason_code, properties)`` — not the
|
||||||
|
older RC int.
|
||||||
|
- **Background thread**: ``loop_start()`` spawns a daemon thread; ``loop_stop()``
|
||||||
|
joins it cleanly on shutdown.
|
||||||
|
- **Never crash the process**: all paho operations are wrapped in try/except;
|
||||||
|
connection failure is logged but does not propagate to the caller.
|
||||||
|
- **Password safety**: the MQTT password is never passed to ``logger`` calls.
|
||||||
|
- **Reconnect**: ``reconnect(settings)`` tears down the old client and
|
||||||
|
establishes a fresh connection with the new settings. Callers (e.g. PUT
|
||||||
|
/api/config) call this after saving MQTT-related config values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MQTT settings keys that, when changed, should trigger a reconnect.
|
||||||
|
MQTT_SETTINGS_KEYS = {
|
||||||
|
"mqtt_enabled",
|
||||||
|
"mqtt_broker_host",
|
||||||
|
"mqtt_broker_port",
|
||||||
|
"mqtt_username",
|
||||||
|
"mqtt_password",
|
||||||
|
"mqtt_tls_enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_configured(settings: Settings) -> bool:
|
||||||
|
"""Return True if MQTT is enabled *and* the broker host is set."""
|
||||||
|
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
|
||||||
|
|
||||||
|
|
||||||
|
class MqttManager:
|
||||||
|
"""Long-lived MQTT client wrapper.
|
||||||
|
|
||||||
|
Lifecycle
|
||||||
|
---------
|
||||||
|
1. ``connect(settings)`` — start background loop + connect to broker.
|
||||||
|
2. ``publish(...)`` — publish messages while connected.
|
||||||
|
3. ``disconnect()`` — graceful teardown (called on app shutdown).
|
||||||
|
4. ``reconnect(settings)`` — disconnect then re-connect with new settings
|
||||||
|
(called after saving updated MQTT configuration).
|
||||||
|
|
||||||
|
When MQTT is not enabled or not configured, all methods are no-ops.
|
||||||
|
Connection failures are caught and logged; they do not raise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client: mqtt.Client | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public properties
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""True if the underlying paho client is currently connected."""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_configured(self, settings: Settings) -> bool:
|
||||||
|
"""Return True if MQTT is enabled and broker host is configured."""
|
||||||
|
return _is_configured(settings)
|
||||||
|
|
||||||
|
def connect(self, settings: Settings) -> None:
|
||||||
|
"""Connect to the MQTT broker and start the background loop thread.
|
||||||
|
|
||||||
|
No-op if MQTT is not enabled or broker host is not set.
|
||||||
|
Connection errors are logged but do not raise.
|
||||||
|
"""
|
||||||
|
if not _is_configured(settings):
|
||||||
|
logger.debug("MQTT not configured or not enabled — skipping connect.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._start_client(settings)
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Disconnect from the broker and stop the background loop thread.
|
||||||
|
|
||||||
|
No-op if no client is active.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._stop_client()
|
||||||
|
|
||||||
|
def reconnect(self, settings: Settings) -> None:
|
||||||
|
"""Disconnect the current client (if any) and reconnect with *settings*.
|
||||||
|
|
||||||
|
Call this after saving updated MQTT configuration values.
|
||||||
|
No-op if MQTT is not enabled or broker host is not set.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._stop_client()
|
||||||
|
self.connect(settings)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: str | bytes | None,
|
||||||
|
*,
|
||||||
|
retain: bool = False,
|
||||||
|
qos: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Publish a message to *topic*.
|
||||||
|
|
||||||
|
If the client is not connected the call is silently skipped.
|
||||||
|
Errors are logged but do not raise.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
client = self._client
|
||||||
|
|
||||||
|
if client is None or not self._connected:
|
||||||
|
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.publish(topic, payload=payload, qos=qos, retain=retain)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT publish error (topic=%s).", topic)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers — must be called with self._lock held
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_client(self, settings: Settings) -> None:
|
||||||
|
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id="home-automation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Callbacks — VERSION2 on_connect signature:
|
||||||
|
# (client, userdata, connect_flags, reason_code, properties)
|
||||||
|
def _on_connect(
|
||||||
|
_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
_flags: mqtt.ConnectFlags,
|
||||||
|
reason_code: mqtt.ReasonCode,
|
||||||
|
_properties: mqtt.Properties | None,
|
||||||
|
) -> None:
|
||||||
|
if reason_code.is_failure:
|
||||||
|
logger.warning("MQTT connection refused: %s", reason_code)
|
||||||
|
self._connected = False
|
||||||
|
else:
|
||||||
|
logger.info("MQTT connected (reason_code=%s).", reason_code)
|
||||||
|
self._connected = True
|
||||||
|
|
||||||
|
# VERSION2 on_disconnect signature:
|
||||||
|
# (client, userdata, disconnect_flags, reason_code, properties)
|
||||||
|
def _on_disconnect(
|
||||||
|
_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
_disconnect_flags: mqtt.DisconnectFlags,
|
||||||
|
reason_code: mqtt.ReasonCode,
|
||||||
|
_properties: mqtt.Properties | None,
|
||||||
|
) -> None:
|
||||||
|
self._connected = False
|
||||||
|
if reason_code.is_failure:
|
||||||
|
logger.warning("MQTT disconnected unexpectedly (reason_code=%s).", reason_code)
|
||||||
|
else:
|
||||||
|
logger.info("MQTT disconnected cleanly.")
|
||||||
|
|
||||||
|
client.on_connect = _on_connect
|
||||||
|
client.on_disconnect = _on_disconnect
|
||||||
|
|
||||||
|
# TLS
|
||||||
|
if settings.mqtt_tls_enabled:
|
||||||
|
try:
|
||||||
|
client.tls_set()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT TLS setup failed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Credentials — never log the password
|
||||||
|
if settings.mqtt_username:
|
||||||
|
client.username_pw_set(
|
||||||
|
username=settings.mqtt_username,
|
||||||
|
password=settings.mqtt_password or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start background loop thread *before* connect so paho can handle
|
||||||
|
# the connection handshake asynchronously.
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
host=settings.mqtt_broker_host,
|
||||||
|
port=settings.mqtt_broker_port,
|
||||||
|
keepalive=60,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Log without leaking the password
|
||||||
|
logger.exception(
|
||||||
|
"MQTT connect failed (host=%s, port=%s). Stopping loop.",
|
||||||
|
settings.mqtt_broker_host,
|
||||||
|
settings.mqtt_broker_port,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client.loop_stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
self._client = client
|
||||||
|
logger.info(
|
||||||
|
"MQTT client started (host=%s, port=%s).",
|
||||||
|
settings.mqtt_broker_host,
|
||||||
|
settings.mqtt_broker_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stop_client(self) -> None:
|
||||||
|
"""Disconnect and stop the background loop. Idempotent."""
|
||||||
|
client = self._client
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
self._client = None
|
||||||
|
self._connected = False
|
||||||
|
try:
|
||||||
|
client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("MQTT disconnect raised (ignoring).", exc_info=True)
|
||||||
|
try:
|
||||||
|
client.loop_stop()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("MQTT loop_stop raised (ignoring).", exc_info=True)
|
||||||
|
logger.info("MQTT client stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level singleton — shared across lifespan and route handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mqtt_manager = MqttManager()
|
||||||
+84
-1
@@ -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.expose import router as api_expose_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
|
||||||
from app.api.routes import status
|
from app.api.routes import status
|
||||||
from app.db import get_session_local
|
from app.db import get_session_local
|
||||||
@@ -22,9 +24,12 @@ from app.api.routes.poo import router as poo_router
|
|||||||
from app.api.routes.public_ip import router as public_ip_router
|
from app.api.routes.public_ip import router as public_ip_router
|
||||||
from app.api.routes.ticktick import router as ticktick_router
|
from app.api.routes.ticktick import router as ticktick_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
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 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.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.ha_discovery import publish_discovery, publish_states
|
||||||
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__)
|
||||||
@@ -48,6 +53,48 @@ def _run_scheduled_public_ip_check() -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduled_modbus_poll() -> None:
|
||||||
|
session_local = get_session_local()
|
||||||
|
session: Session = session_local()
|
||||||
|
try:
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=get_settings())
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduled_ha_state_publish() -> None:
|
||||||
|
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
|
||||||
|
|
||||||
|
Runs every 60 seconds. When the MQTT broker is connected:
|
||||||
|
- Publishes (or re-publishes) all HA Discovery configs (retained, idempotent).
|
||||||
|
- Publishes the current state / availability for all enabled entities.
|
||||||
|
|
||||||
|
This also serves as the reliable "publish discovery after connect" mechanism:
|
||||||
|
because paho connects asynchronously, a synchronous call immediately after
|
||||||
|
``mqtt_manager.connect()`` would fire before the TCP handshake completes and
|
||||||
|
be a no-op. Instead, this periodic job picks it up within 60 seconds of the
|
||||||
|
broker becoming available — retained payloads make repeated publishes harmless.
|
||||||
|
|
||||||
|
Additionally, if MQTT is configured in DB but the manager is not yet connected
|
||||||
|
(e.g. MQTT was enabled via UI after startup), this job attempts to connect so
|
||||||
|
the user does not need to restart the server.
|
||||||
|
"""
|
||||||
|
session_local = get_session_local()
|
||||||
|
session: Session = session_local()
|
||||||
|
try:
|
||||||
|
runtime_settings = build_runtime_settings(session, get_settings())
|
||||||
|
# Reconnect if MQTT is configured (in DB) but not yet connected.
|
||||||
|
if mqtt_manager.is_configured(runtime_settings) and not mqtt_manager.is_connected:
|
||||||
|
logger.info("_run_scheduled_ha_state_publish: MQTT configured but not connected — attempting connect.")
|
||||||
|
mqtt_manager.connect(runtime_settings)
|
||||||
|
publish_discovery(session)
|
||||||
|
publish_states(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_run_scheduled_ha_state_publish: unexpected error")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def ensure_auth_db_ready() -> None:
|
def ensure_auth_db_ready() -> None:
|
||||||
session_local = get_session_local()
|
session_local = get_session_local()
|
||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
@@ -83,8 +130,42 @@ async def lifespan(_: FastAPI):
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_scheduled_modbus_poll,
|
||||||
|
trigger=IntervalTrigger(seconds=BASE_POLL_TICK_SECONDS),
|
||||||
|
id="modbus-poll",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
# Periodic HA state / availability publish (60-second fallback sweep).
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_scheduled_ha_state_publish,
|
||||||
|
trigger=IntervalTrigger(seconds=60),
|
||||||
|
id="ha-state-publish",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||||
|
# is picked up on restart (not just from env/bootstrap settings).
|
||||||
|
# Discovery will be published by the first run of _run_scheduled_ha_state_publish
|
||||||
|
# (within 60 s of startup), after the async paho handshake completes.
|
||||||
|
_startup_session_local = get_session_local()
|
||||||
|
_startup_session: Session = _startup_session_local()
|
||||||
|
try:
|
||||||
|
_startup_runtime_settings = build_runtime_settings(_startup_session, get_settings())
|
||||||
|
finally:
|
||||||
|
_startup_session.close()
|
||||||
|
mqtt_manager.connect(_startup_runtime_settings)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
# MQTT: clean shutdown before the process exits.
|
||||||
|
mqtt_manager.disconnect()
|
||||||
|
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +188,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_expose_router)
|
||||||
|
app.include_router(api_modbus_router)
|
||||||
app.include_router(api_session_router)
|
app.include_router(api_session_router)
|
||||||
app.include_router(homeassistant_router)
|
app.include_router(homeassistant_router)
|
||||||
app.include_router(location_router)
|
app.include_router(location_router)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""SQLAlchemy model for the exposed-entity toggle table.
|
||||||
|
|
||||||
|
``ExposedEntityToggle`` stores the per-entity enable/disable state for the
|
||||||
|
MQTT / HA Discovery expose framework. The *catalog* of what *can* be
|
||||||
|
exposed is computed dynamically by provider functions (see
|
||||||
|
``app/integrations/expose.py``); this table only records which entries the
|
||||||
|
user has explicitly enabled.
|
||||||
|
|
||||||
|
Key design decisions
|
||||||
|
--------------------
|
||||||
|
- ``key`` is a stable string identifier derived from device uuid + metric key
|
||||||
|
(e.g. ``"modbus.<uuid>.voltage"``), so it does not drift if rows are
|
||||||
|
deleted and re-inserted.
|
||||||
|
- Default state for an entity not yet in this table is **disabled** (false).
|
||||||
|
``build_catalog`` in expose.py treats a missing row as ``enabled=False``.
|
||||||
|
- Only one row per entity key (``key`` is UNIQUE).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ExposedEntityToggle(Base):
|
||||||
|
"""Per-entity on/off switch for MQTT / HA Discovery publishing.
|
||||||
|
|
||||||
|
Rows are created on demand (first toggle); entities with no row are
|
||||||
|
treated as disabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "exposed_entity_toggle"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Stable entity key, e.g. "modbus.<uuid>.voltage" or "modbus.<uuid>.online".
|
||||||
|
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
|
||||||
|
# Whether this entity should be published via MQTT / HA Discovery.
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Last time this row was created or modified.
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""SQLAlchemy models for Modbus device management and telemetry.
|
||||||
|
|
||||||
|
Two tables:
|
||||||
|
- modbus_device: deployment/configurable metadata for each polled device.
|
||||||
|
- modbus_reading: generic telemetry rows (one per device per poll cycle).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid4_str() -> str:
|
||||||
|
return str(_uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDevice(Base):
|
||||||
|
"""Deployment-layer record for a Modbus slave device reachable via a TCP gateway.
|
||||||
|
|
||||||
|
Protocol knowledge (register map, decoding rules) lives in the YAML profile
|
||||||
|
referenced by ``profile``. Per-device variables (network address, slave ID,
|
||||||
|
display name, poll rate) live here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "modbus_device"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Stable internal identity — used as the API path key and HA Discovery unique_id anchor.
|
||||||
|
uuid: Mapped[str] = mapped_column(
|
||||||
|
String(36), unique=True, nullable=False, default=_uuid4_str
|
||||||
|
)
|
||||||
|
|
||||||
|
# Human-readable label (may be changed; changing it triggers re-publish of HA Discovery).
|
||||||
|
friendly_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Transport protocol — only "tcp" is supported for now.
|
||||||
|
transport: Mapped[str] = mapped_column(String(16), nullable=False, default="tcp")
|
||||||
|
|
||||||
|
# TCP gateway address.
|
||||||
|
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
port: Mapped[int] = mapped_column(Integer, nullable=False, default=502)
|
||||||
|
|
||||||
|
# Modbus slave address (set on the device panel; different meters on the same gateway
|
||||||
|
# must have distinct unit_ids).
|
||||||
|
unit_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
# Which YAML profile to use for register mapping and decoding (e.g. "sdm120").
|
||||||
|
profile: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|
||||||
|
# Polling interval in seconds.
|
||||||
|
poll_interval_s: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||||
|
|
||||||
|
# Whether this device is included in the periodic poll sweep.
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Poll-status fields (updated by the poll service after each attempt).
|
||||||
|
last_poll_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
last_poll_ok: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
# 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 readings (back-reference; not loaded eagerly).
|
||||||
|
readings: Mapped[list["ModbusReading"]] = relationship(
|
||||||
|
back_populates="device", cascade="save-update, merge"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusReading(Base):
|
||||||
|
"""Generic telemetry row: one device, one poll instant, all decoded metrics as JSON.
|
||||||
|
|
||||||
|
``payload`` contains the full dict of engineering values produced by the YAML profile
|
||||||
|
decoder, e.g. ``{"voltage": 230.2, "current": 1.3, ...}``. The profile is the key
|
||||||
|
to interpreting which keys exist and what units they carry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "modbus_reading"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# FK to the device that produced this reading.
|
||||||
|
# ON DELETE RESTRICT: prevents accidental deletion of a device that has historical data.
|
||||||
|
device_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("modbus_device.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# The UTC timestamp of when the sample was taken — real indexed column, NOT embedded in payload.
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Profile-decoded engineering values as a JSON object.
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
device: Mapped["ModbusDevice"] = relationship(back_populates="readings")
|
||||||
|
|
||||||
|
|
||||||
|
# Composite index for efficient time-range queries scoped to a single device.
|
||||||
|
Index("ix_modbus_reading_device_recorded", ModbusReading.device_id, ModbusReading.recorded_at)
|
||||||
@@ -38,3 +38,10 @@ class SmtpTestResponse(BaseModel):
|
|||||||
|
|
||||||
result: Literal["success", "config-error", "failed"]
|
result: Literal["success", "config-error", "failed"]
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MqttTestResponse(BaseModel):
|
||||||
|
"""Response from POST /api/config/mqtt/test."""
|
||||||
|
|
||||||
|
result: Literal["success", "config-error", "failed"]
|
||||||
|
message: str
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Pydantic schemas for the Expose API (M5-T12).
|
||||||
|
|
||||||
|
Three endpoints:
|
||||||
|
GET /api/expose — catalog + toggle state + MQTT/Discovery status
|
||||||
|
PUT /api/expose — set toggles (map key → bool)
|
||||||
|
POST /api/expose/republish — trigger discovery re-publish
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nested schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInfoSchema(BaseModel):
|
||||||
|
"""HA device grouping info for an exposable entity."""
|
||||||
|
|
||||||
|
identifiers: list[str]
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExposableEntitySchema(BaseModel):
|
||||||
|
"""One exposable entity in the catalog.
|
||||||
|
|
||||||
|
``value_getter`` is intentionally excluded — it is a non-serialisable
|
||||||
|
callable and is only used internally by the HA Discovery service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
component: str
|
||||||
|
device: DeviceInfoSchema
|
||||||
|
device_class: str | None
|
||||||
|
unit: str
|
||||||
|
name: str
|
||||||
|
state_class: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogEntrySchema(BaseModel):
|
||||||
|
"""An entity from the catalog with its current toggle state."""
|
||||||
|
|
||||||
|
entity: ExposableEntitySchema
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MqttStatusSchema(BaseModel):
|
||||||
|
"""Connection status for MQTT and HA Discovery."""
|
||||||
|
|
||||||
|
mqtt_configured: bool
|
||||||
|
mqtt_connected: bool
|
||||||
|
discovery_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Response schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ExposeResponse(BaseModel):
|
||||||
|
"""Response for GET /api/expose."""
|
||||||
|
|
||||||
|
catalog: list[CatalogEntrySchema]
|
||||||
|
mqtt_status: MqttStatusSchema
|
||||||
|
|
||||||
|
|
||||||
|
class ExposeUpdateResponse(BaseModel):
|
||||||
|
"""Response for PUT /api/expose (returns updated catalog + status)."""
|
||||||
|
|
||||||
|
catalog: list[CatalogEntrySchema]
|
||||||
|
mqtt_status: MqttStatusSchema
|
||||||
|
|
||||||
|
|
||||||
|
class RepublishResponse(BaseModel):
|
||||||
|
"""Response for POST /api/expose/republish."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ExposeUpdateRequest(BaseModel):
|
||||||
|
"""Request body for PUT /api/expose.
|
||||||
|
|
||||||
|
``toggles`` is a map from entity key to desired enabled state (bool).
|
||||||
|
Only keys present in the map are updated; absent keys are untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
toggles: dict[str, bool]
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Pydantic schemas for the Modbus device CRUD + readings + metrics API (M5-T05)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDeviceCreate(BaseModel):
|
||||||
|
"""Request body for POST /api/modbus/devices."""
|
||||||
|
|
||||||
|
friendly_name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
transport: str = Field(default="tcp", max_length=16)
|
||||||
|
host: str = Field(..., min_length=1, max_length=255)
|
||||||
|
port: int = Field(default=502, ge=1, le=65535)
|
||||||
|
unit_id: int = Field(default=1, ge=0, le=247)
|
||||||
|
profile: str = Field(..., min_length=1, max_length=64)
|
||||||
|
poll_interval_s: int = Field(default=5, ge=1, le=3600)
|
||||||
|
enabled: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDeviceUpdate(BaseModel):
|
||||||
|
"""Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."""
|
||||||
|
|
||||||
|
friendly_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
transport: str | None = Field(default=None, max_length=16)
|
||||||
|
host: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
unit_id: int | None = Field(default=None, ge=0, le=247)
|
||||||
|
profile: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
poll_interval_s: int | None = Field(default=None, ge=1, le=3600)
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDeviceResponse(BaseModel):
|
||||||
|
"""Response schema for a single Modbus device."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
friendly_name: str
|
||||||
|
transport: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
unit_id: int
|
||||||
|
profile: str
|
||||||
|
poll_interval_s: int
|
||||||
|
enabled: bool
|
||||||
|
last_poll_at: datetime | None
|
||||||
|
last_poll_ok: bool | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDeviceListResponse(BaseModel):
|
||||||
|
"""Response schema for listing Modbus devices."""
|
||||||
|
|
||||||
|
items: list[ModbusDeviceResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reading schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusReadingResponse(BaseModel):
|
||||||
|
"""A single reading row: timestamp + decoded payload."""
|
||||||
|
|
||||||
|
recorded_at: datetime
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusReadingsResponse(BaseModel):
|
||||||
|
"""Response schema for the readings time-range endpoint."""
|
||||||
|
|
||||||
|
items: list[ModbusReadingResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusLatestResponse(BaseModel):
|
||||||
|
"""Response for the /latest endpoint.
|
||||||
|
|
||||||
|
``found`` is False and ``recorded_at``/``payload`` are None when the device
|
||||||
|
has no readings yet. Callers should check ``found`` before using the values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
found: bool
|
||||||
|
recorded_at: datetime | None
|
||||||
|
payload: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metrics / profile schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MetricInfo(BaseModel):
|
||||||
|
"""Metadata for a single measurable quantity in a device's profile."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
unit: str
|
||||||
|
device_class: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusMetricsResponse(BaseModel):
|
||||||
|
"""Response schema for GET /api/modbus/devices/{uuid}/metrics."""
|
||||||
|
|
||||||
|
profile: str
|
||||||
|
metrics: list[MetricInfo]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Profile list schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSummary(BaseModel):
|
||||||
|
"""One entry in the GET /api/modbus/profiles response."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusProfilesResponse(BaseModel):
|
||||||
|
"""Response schema for GET /api/modbus/profiles."""
|
||||||
|
|
||||||
|
profiles: list[ProfileSummary]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test-read schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusTestReadResponse(BaseModel):
|
||||||
|
"""Response for POST /api/modbus/devices/{uuid}/test.
|
||||||
|
|
||||||
|
On success ``ok=True`` and ``payload`` contains the decoded values.
|
||||||
|
On failure ``ok=False`` and ``error`` describes the problem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
error: str | None = None
|
||||||
@@ -25,9 +25,9 @@ class ConfigField:
|
|||||||
CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||||
ConfigField("System", "APP_NAME", "app_name", "App Name"),
|
ConfigField("System", "APP_NAME", "app_name", "App Name"),
|
||||||
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
||||||
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
|
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug", input_type="checkbox"),
|
||||||
ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"),
|
ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"),
|
||||||
ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled"),
|
ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled", input_type="checkbox"),
|
||||||
ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"),
|
ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"),
|
||||||
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
|
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
|
||||||
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
|
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
|
||||||
@@ -35,7 +35,7 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
|
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
|
||||||
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
|
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
|
||||||
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
|
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
|
||||||
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"),
|
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS", input_type="checkbox"),
|
||||||
ConfigField(
|
ConfigField(
|
||||||
"Authentication",
|
"Authentication",
|
||||||
"AUTH_SESSION_COOKIE_NAME",
|
"AUTH_SESSION_COOKIE_NAME",
|
||||||
@@ -54,6 +54,7 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
"AUTH_LOGIN_THROTTLE_ENABLED",
|
"AUTH_LOGIN_THROTTLE_ENABLED",
|
||||||
"auth_login_throttle_enabled",
|
"auth_login_throttle_enabled",
|
||||||
"Login Throttle Enabled",
|
"Login Throttle Enabled",
|
||||||
|
input_type="checkbox",
|
||||||
),
|
),
|
||||||
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
|
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
|
||||||
ConfigField(
|
ConfigField(
|
||||||
@@ -102,6 +103,26 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
"home_assistant_action_task_project_id",
|
"home_assistant_action_task_project_id",
|
||||||
"Home Assistant Action Task Project ID",
|
"Home Assistant Action Task Project ID",
|
||||||
),
|
),
|
||||||
|
ConfigField("MQTT", "MQTT_ENABLED", "mqtt_enabled", "MQTT Enabled", input_type="checkbox"),
|
||||||
|
ConfigField("MQTT", "MQTT_BROKER_HOST", "mqtt_broker_host", "MQTT Broker Host"),
|
||||||
|
ConfigField("MQTT", "MQTT_BROKER_PORT", "mqtt_broker_port", "MQTT Broker Port", input_type="number"),
|
||||||
|
ConfigField("MQTT", "MQTT_USERNAME", "mqtt_username", "MQTT Username"),
|
||||||
|
ConfigField("MQTT", "MQTT_PASSWORD", "mqtt_password", "MQTT Password", secret=True),
|
||||||
|
ConfigField("MQTT", "MQTT_TLS_ENABLED", "mqtt_tls_enabled", "MQTT TLS Enabled", input_type="checkbox"),
|
||||||
|
ConfigField(
|
||||||
|
"Home Assistant Discovery",
|
||||||
|
"HA_DISCOVERY_ENABLED",
|
||||||
|
"ha_discovery_enabled",
|
||||||
|
"HA Discovery Enabled",
|
||||||
|
input_type="checkbox",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"Home Assistant Discovery",
|
||||||
|
"HA_DISCOVERY_PREFIX",
|
||||||
|
"ha_discovery_prefix",
|
||||||
|
"HA Discovery Prefix",
|
||||||
|
),
|
||||||
|
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -292,4 +313,13 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
|||||||
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
|
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
|
||||||
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
|
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
|
||||||
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
|
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
|
||||||
|
"modbus_polling_enabled": settings.modbus_polling_enabled,
|
||||||
|
"mqtt_enabled": settings.mqtt_enabled,
|
||||||
|
"mqtt_broker_host": settings.mqtt_broker_host,
|
||||||
|
"mqtt_broker_port": settings.mqtt_broker_port,
|
||||||
|
"mqtt_username": settings.mqtt_username,
|
||||||
|
"mqtt_password": settings.mqtt_password,
|
||||||
|
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
||||||
|
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
||||||
|
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""HA MQTT Discovery service — build and publish discovery configs + state.
|
||||||
|
|
||||||
|
This module handles:
|
||||||
|
|
||||||
|
1. Building HA MQTT Discovery payloads for each ``ExposableEntity``.
|
||||||
|
2. Publishing discovery configs (retained) for enabled entities.
|
||||||
|
3. Clearing (empty-payload) discovery configs for disabled entities.
|
||||||
|
4. Publishing current entity state values.
|
||||||
|
5. A per-device helper for use in the Modbus poll loop.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
- **No-op when MQTT / discovery is not configured**: every public function
|
||||||
|
checks ``ha_discovery_enabled`` and ``mqtt_manager.is_connected`` before
|
||||||
|
doing any work. Callers never need to guard these conditions.
|
||||||
|
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
|
||||||
|
from mutable fields like ``friendly_name`` or auto-increment DB ids.
|
||||||
|
- **Discovery topic format**: ``<prefix>/<component>/<node_id>/<object_id>/config``
|
||||||
|
where ``node_id`` is the device uuid (slugified to be safe) and
|
||||||
|
``object_id`` is a uuid-prefixed stable string.
|
||||||
|
- **State topic format**: ``<prefix>/<component>/<node_id>/<object_id>/state``
|
||||||
|
- **Availability topic**: ``<prefix>/modbus/<node_id>/availability`` (shared
|
||||||
|
across all entities of the same device; publishes "online"/"offline").
|
||||||
|
- **best-effort**: functions catch all exceptions internally so that callers
|
||||||
|
(e.g. the Modbus poll loop) never crash due to MQTT publish errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.integrations.expose import ExposableEntity, build_catalog
|
||||||
|
from app.integrations.mqtt import mqtt_manager
|
||||||
|
from app.services.config_page import build_runtime_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _should_publish(settings: Any) -> bool:
|
||||||
|
"""Return True only if MQTT and HA Discovery are both enabled and connected."""
|
||||||
|
return bool(
|
||||||
|
settings.mqtt_enabled
|
||||||
|
and settings.ha_discovery_enabled
|
||||||
|
and mqtt_manager.is_connected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_id(device_uuid: str) -> str:
|
||||||
|
"""Stable MQTT node_id derived from device uuid (replace hyphens for safety)."""
|
||||||
|
return device_uuid.replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _object_id(entity: ExposableEntity) -> str:
|
||||||
|
"""Stable MQTT object_id derived from entity key (dots + hyphens → underscores)."""
|
||||||
|
return entity.key.replace(".", "_").replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _discovery_topic(entity: ExposableEntity, prefix: str) -> str:
|
||||||
|
"""Build the HA Discovery config topic for *entity*.
|
||||||
|
|
||||||
|
Format: ``<prefix>/<component>/<node_id>/<object_id>/config``
|
||||||
|
"""
|
||||||
|
node = _node_id(entity.device.identifiers[1]) # device uuid
|
||||||
|
obj = _object_id(entity)
|
||||||
|
return f"{prefix}/{entity.component}/{node}/{obj}/config"
|
||||||
|
|
||||||
|
|
||||||
|
def _state_topic(entity: ExposableEntity, prefix: str) -> str:
|
||||||
|
"""Build the state publish topic for *entity*.
|
||||||
|
|
||||||
|
Format: ``<prefix>/<component>/<node_id>/<object_id>/state``
|
||||||
|
"""
|
||||||
|
node = _node_id(entity.device.identifiers[1])
|
||||||
|
obj = _object_id(entity)
|
||||||
|
return f"{prefix}/{entity.component}/{node}/{obj}/state"
|
||||||
|
|
||||||
|
|
||||||
|
def _availability_topic(device_uuid: str, prefix: str) -> str:
|
||||||
|
"""Shared availability topic for all entities of a device.
|
||||||
|
|
||||||
|
Format: ``<prefix>/modbus/<node_id>/availability``
|
||||||
|
"""
|
||||||
|
node = _node_id(device_uuid)
|
||||||
|
return f"{prefix}/modbus/{node}/availability"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_id(entity: ExposableEntity) -> str:
|
||||||
|
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
|
||||||
|
device_uuid = entity.device.identifiers[1]
|
||||||
|
# entity.key is already "modbus.<uuid>.<metric_key>" — use it as the seed
|
||||||
|
return f"{device_uuid}_{entity.key.replace('.', '_')}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public: build discovery payload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_discovery_payload(
|
||||||
|
entity: ExposableEntity,
|
||||||
|
prefix: str,
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Build the HA MQTT Discovery config topic and payload dict for *entity*.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
entity:
|
||||||
|
An ``ExposableEntity`` (from ``build_catalog``).
|
||||||
|
prefix:
|
||||||
|
The discovery prefix (e.g. ``"homeassistant"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple[str, dict]
|
||||||
|
``(topic, config_dict)`` — the config topic and the HA Discovery
|
||||||
|
payload (not yet JSON-serialised).
|
||||||
|
"""
|
||||||
|
device_uuid = entity.device.identifiers[1]
|
||||||
|
avail_topic = _availability_topic(device_uuid, prefix)
|
||||||
|
state_t = _state_topic(entity, prefix)
|
||||||
|
topic = _discovery_topic(entity, prefix)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"unique_id": _unique_id(entity),
|
||||||
|
"name": entity.name,
|
||||||
|
"state_topic": state_t,
|
||||||
|
"availability": [{"topic": avail_topic}],
|
||||||
|
"availability_mode": "all",
|
||||||
|
"device": {
|
||||||
|
"identifiers": list(entity.device.identifiers),
|
||||||
|
"name": entity.device.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Component-specific fields
|
||||||
|
if entity.component == "binary_sensor":
|
||||||
|
# "online" binary sensor: payload ON/OFF
|
||||||
|
config["payload_on"] = "ON"
|
||||||
|
config["payload_off"] = "OFF"
|
||||||
|
if entity.device_class:
|
||||||
|
config["device_class"] = entity.device_class
|
||||||
|
else:
|
||||||
|
# sensor (and others)
|
||||||
|
if entity.device_class:
|
||||||
|
config["device_class"] = entity.device_class
|
||||||
|
if entity.unit:
|
||||||
|
config["unit_of_measurement"] = entity.unit
|
||||||
|
if entity.state_class:
|
||||||
|
config["state_class"] = entity.state_class
|
||||||
|
|
||||||
|
return topic, config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public: publish discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def publish_discovery(session: Session) -> None:
|
||||||
|
"""Publish HA Discovery configs for all entities in the catalog.
|
||||||
|
|
||||||
|
- Enabled entities: publish retained JSON config payload.
|
||||||
|
- Disabled entities: publish empty (b"") payload to clear any previously
|
||||||
|
retained config from HA.
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||||
|
All MQTT errors are caught internally — this function never raises.
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
settings = build_runtime_settings(session, get_settings())
|
||||||
|
if not _should_publish(settings):
|
||||||
|
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = settings.ha_discovery_prefix
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("publish_discovery: failed to build catalog; aborting")
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in catalog:
|
||||||
|
entity = entry.entity
|
||||||
|
try:
|
||||||
|
topic, config = build_discovery_payload(entity, prefix)
|
||||||
|
if entry.enabled:
|
||||||
|
payload = json.dumps(config)
|
||||||
|
mqtt_manager.publish(topic, payload, retain=True)
|
||||||
|
logger.debug(
|
||||||
|
"publish_discovery: published config for %r → %s", entity.key, topic
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Clear retained config for disabled entities.
|
||||||
|
mqtt_manager.publish(topic, b"", retain=True)
|
||||||
|
logger.debug(
|
||||||
|
"publish_discovery: cleared config for disabled entity %r → %s",
|
||||||
|
entity.key,
|
||||||
|
topic,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_discovery: error processing entity %r; continuing", entity.key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public: publish states
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def publish_states(session: Session) -> None:
|
||||||
|
"""Publish current state values for all enabled entities.
|
||||||
|
|
||||||
|
Calls each entity's ``value_getter`` to obtain the current value and
|
||||||
|
publishes it to the entity's state topic. Also publishes availability
|
||||||
|
(online/offline) for each device.
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||||
|
All errors are caught internally — this function never raises.
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
settings = build_runtime_settings(session, get_settings())
|
||||||
|
if not _should_publish(settings):
|
||||||
|
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = settings.ha_discovery_prefix
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("publish_states: failed to build catalog; aborting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Collect enabled entries and publish
|
||||||
|
for entry in catalog:
|
||||||
|
if not entry.enabled:
|
||||||
|
continue
|
||||||
|
entity = entry.entity
|
||||||
|
try:
|
||||||
|
_publish_entity_state(entity, prefix, session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_states: error publishing state for %r; continuing", entity.key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_entity_state(
|
||||||
|
entity: ExposableEntity,
|
||||||
|
prefix: str,
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
"""Publish one entity's current state value to its state topic.
|
||||||
|
|
||||||
|
Also publishes the availability topic for ``binary_sensor`` "online" entities.
|
||||||
|
"""
|
||||||
|
state_t = _state_topic(entity, prefix)
|
||||||
|
device_uuid = entity.device.identifiers[1]
|
||||||
|
|
||||||
|
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||||
|
# The online sensor represents device availability.
|
||||||
|
# Ask the value_getter for the current ON/OFF string, then also publish
|
||||||
|
# the shared availability topic (online/offline).
|
||||||
|
raw_value = None
|
||||||
|
if entity.value_getter is not None:
|
||||||
|
try:
|
||||||
|
raw_value = entity.value_getter(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("value_getter raised for online entity %r", entity.key)
|
||||||
|
# Default to offline when no reading is available.
|
||||||
|
online = (raw_value == "ON")
|
||||||
|
avail_payload = "online" if online else "offline"
|
||||||
|
avail_topic = _availability_topic(device_uuid, prefix)
|
||||||
|
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||||
|
# The state of the binary_sensor itself
|
||||||
|
state_payload = "ON" if online else "OFF"
|
||||||
|
mqtt_manager.publish(state_t, state_payload, retain=False)
|
||||||
|
else:
|
||||||
|
# Regular sensor: call value_getter(session) if available.
|
||||||
|
value = None
|
||||||
|
if entity.value_getter is not None:
|
||||||
|
try:
|
||||||
|
value = entity.value_getter(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("value_getter raised for entity %r", entity.key)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
logger.debug("publish_states: no value for %r — skipping state publish", entity.key)
|
||||||
|
return
|
||||||
|
|
||||||
|
mqtt_manager.publish(state_t, str(value), retain=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public: per-device state push (called by modbus_poll after each poll)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def publish_device_state(session: Session, device: Any) -> None:
|
||||||
|
"""Publish state + availability for all enabled entities of *device*.
|
||||||
|
|
||||||
|
Called by ``modbus_poll.poll_device`` after a successful (or failed) poll.
|
||||||
|
Publishes:
|
||||||
|
- The current availability topic ("online" / "offline") for the device.
|
||||||
|
- The state topic for each **enabled** entity of the device.
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||||
|
All errors are caught internally — never raises.
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
settings = build_runtime_settings(session, get_settings())
|
||||||
|
if not _should_publish(settings):
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = settings.ha_discovery_prefix
|
||||||
|
device_uuid = device.uuid
|
||||||
|
online = bool(device.last_poll_ok)
|
||||||
|
|
||||||
|
# Publish availability topic first.
|
||||||
|
try:
|
||||||
|
avail_topic = _availability_topic(device_uuid, prefix)
|
||||||
|
avail_payload = "online" if online else "offline"
|
||||||
|
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_device_state: failed to publish availability for device uuid=%s", device_uuid
|
||||||
|
)
|
||||||
|
|
||||||
|
if not online:
|
||||||
|
# Device is offline — no point pushing stale state values.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Publish state for each enabled entity of this device.
|
||||||
|
try:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_device_state: failed to build catalog for device uuid=%s", device_uuid
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in catalog:
|
||||||
|
if not entry.enabled:
|
||||||
|
continue
|
||||||
|
entity = entry.entity
|
||||||
|
# Only process entities belonging to this device.
|
||||||
|
if entity.device.identifiers[1] != device_uuid:
|
||||||
|
continue
|
||||||
|
# Skip the online binary_sensor itself (availability handled above).
|
||||||
|
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||||
|
# Publish the binary_sensor state too.
|
||||||
|
try:
|
||||||
|
state_t = _state_topic(entity, prefix)
|
||||||
|
mqtt_manager.publish(state_t, "ON", retain=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_device_state: error publishing online sensor state for %r",
|
||||||
|
entity.key,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regular sensor — call value_getter(session).
|
||||||
|
try:
|
||||||
|
value = None
|
||||||
|
if entity.value_getter is not None:
|
||||||
|
value = entity.value_getter(session)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
state_t = _state_topic(entity, prefix)
|
||||||
|
mqtt_manager.publish(state_t, str(value), retain=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_device_state: error publishing state for entity %r", entity.key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_device_offline(session: Session, device_uuid: str) -> None:
|
||||||
|
"""Publish an "offline" availability payload for *device_uuid*.
|
||||||
|
|
||||||
|
Called by ``modbus_poll.poll_device`` when a poll fails, to immediately
|
||||||
|
reflect the device going offline in HA (before the next full state sweep).
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||||
|
Never raises.
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
settings = build_runtime_settings(session, get_settings())
|
||||||
|
if not _should_publish(settings):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefix = settings.ha_discovery_prefix
|
||||||
|
avail_topic = _availability_topic(device_uuid, prefix)
|
||||||
|
mqtt_manager.publish(avail_topic, "offline", retain=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"publish_device_offline: failed for device uuid=%s", device_uuid
|
||||||
|
)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Modbus polling service — periodic read-and-store for enabled Modbus devices.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- ``poll_device`` loads the device's YAML profile, calls the driver to bulk-read
|
||||||
|
all register blocks in a single TCP connection, decodes the raw registers into
|
||||||
|
an engineering-value dict via the profile, and persists one ``ModbusReading``
|
||||||
|
row. It also stamps ``last_poll_at`` / ``last_poll_ok`` on the device.
|
||||||
|
- All exceptions are caught and logged inside ``poll_device``; the function
|
||||||
|
never re-raises. This prevents a single broken device from aborting the
|
||||||
|
sweep for all other devices.
|
||||||
|
- ``poll_all_enabled_devices`` respects per-device ``poll_interval_s``: it skips
|
||||||
|
a device whose ``last_poll_at`` is recent enough. This lets the APScheduler
|
||||||
|
job run on a short base tick (e.g. 5 s) while each device self-regulates its
|
||||||
|
own cadence.
|
||||||
|
- The global ``modbus_polling_enabled`` flag from ``Settings`` (merged with any
|
||||||
|
DB overrides via ``build_runtime_settings``) gates the entire sweep as a
|
||||||
|
no-op. T08 will expose this flag in CONFIG_FIELDS/UI; for now it is
|
||||||
|
controlled by the ``MODBUS_POLLING_ENABLED`` env var (or the default True).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.integrations.modbus import driver, profiles
|
||||||
|
from app.models.modbus import ModbusDevice, ModbusReading
|
||||||
|
from app.services.config_page import build_runtime_settings
|
||||||
|
from app.services.ha_discovery import publish_device_offline, publish_device_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Base polling tick in seconds (used by APScheduler; each device checks its own interval).
|
||||||
|
BASE_POLL_TICK_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
|
||||||
|
"""Read one enabled Modbus device, decode, and persist a reading row.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Open SQLAlchemy session. The caller is responsible for session
|
||||||
|
lifecycle (open / commit / close).
|
||||||
|
device:
|
||||||
|
``ModbusDevice`` ORM instance to poll.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ModbusReading | None
|
||||||
|
The newly-persisted reading on success, or ``None`` on any failure.
|
||||||
|
Failure is logged and the device's ``last_poll_ok`` is set to ``False``.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
try:
|
||||||
|
profile = profiles.load_profile(device.profile)
|
||||||
|
registers = driver.read_blocks(
|
||||||
|
device.host,
|
||||||
|
device.port,
|
||||||
|
device.unit_id,
|
||||||
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
)
|
||||||
|
payload = profiles.decode(profile, registers)
|
||||||
|
|
||||||
|
reading = ModbusReading(
|
||||||
|
device_id=device.id,
|
||||||
|
recorded_at=now,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
|
||||||
|
device.last_poll_at = now
|
||||||
|
device.last_poll_ok = True
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Polled device %r (id=%d): %d metrics recorded at %s",
|
||||||
|
device.friendly_name,
|
||||||
|
device.id,
|
||||||
|
len(payload),
|
||||||
|
now.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Best-effort: push MQTT state after successful poll.
|
||||||
|
# Must never let MQTT errors crash the poll loop.
|
||||||
|
try:
|
||||||
|
publish_device_state(session, device)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"poll_device: MQTT state publish failed for device %r (id=%d); "
|
||||||
|
"continuing (poll itself succeeded)",
|
||||||
|
device.friendly_name,
|
||||||
|
device.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return reading
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"Failed to poll device %r (id=%d, host=%s:%d, unit_id=%d)",
|
||||||
|
device.friendly_name,
|
||||||
|
device.id,
|
||||||
|
device.host,
|
||||||
|
device.port,
|
||||||
|
device.unit_id,
|
||||||
|
)
|
||||||
|
# Rollback first — if session.add(reading) already ran in the success
|
||||||
|
# branch before commit() failed, that pending ModbusReading must be
|
||||||
|
# discarded. Without rollback it would remain staged and could be
|
||||||
|
# flushed/committed by a subsequent operation (even for a different
|
||||||
|
# device), producing a spurious reading row for a poll that failed.
|
||||||
|
# Rollback also expires all ORM objects; device fields are re-set below.
|
||||||
|
try:
|
||||||
|
session.rollback()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"session.rollback() failed for device id=%d; session may be unusable", device.id
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
device.last_poll_at = now
|
||||||
|
device.last_poll_ok = False
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"Also failed to persist last_poll_ok=False for device id=%d", device.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Best-effort: publish "offline" availability after failed poll.
|
||||||
|
try:
|
||||||
|
publish_device_offline(session, device.uuid)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"poll_device: MQTT offline publish failed for device %r (id=%d)",
|
||||||
|
device.friendly_name,
|
||||||
|
device.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_all_enabled_devices(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
bootstrap_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
"""Sweep all enabled Modbus devices and poll each one whose interval has elapsed.
|
||||||
|
|
||||||
|
Global kill-switch: if ``build_runtime_settings(...).modbus_polling_enabled``
|
||||||
|
is ``False``, this function returns immediately without touching any device.
|
||||||
|
|
||||||
|
Per-device interval: a device is skipped if its ``last_poll_at`` is not yet
|
||||||
|
``poll_interval_s`` seconds in the past. This allows the APScheduler job to
|
||||||
|
run on a short fixed tick while each device self-regulates its own cadence.
|
||||||
|
|
||||||
|
Exceptions from individual devices are swallowed inside ``poll_device``;
|
||||||
|
this function itself will not raise.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
session:
|
||||||
|
Open SQLAlchemy session.
|
||||||
|
bootstrap_settings:
|
||||||
|
The bootstrap ``Settings`` instance (from ``get_settings()``), used as
|
||||||
|
the base for ``build_runtime_settings`` to pick up any DB overrides.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
runtime_settings = build_runtime_settings(session, bootstrap_settings)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("Failed to build runtime settings for Modbus poll; aborting sweep")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not runtime_settings.modbus_polling_enabled:
|
||||||
|
logger.debug("Modbus polling is disabled (modbus_polling_enabled=False); skipping sweep")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = session.execute(
|
||||||
|
select(ModbusDevice).where(ModbusDevice.enabled == True) # noqa: E712
|
||||||
|
).scalars().all()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("Failed to query enabled Modbus devices; aborting sweep")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for device in devices:
|
||||||
|
# Respect per-device poll interval: skip if last poll was too recent.
|
||||||
|
if device.last_poll_at is not None:
|
||||||
|
# SQLite may return a naive datetime despite DateTime(timezone=True) —
|
||||||
|
# treat naive values as UTC so the comparison is safe.
|
||||||
|
last_poll = device.last_poll_at
|
||||||
|
if last_poll.tzinfo is None:
|
||||||
|
last_poll = last_poll.replace(tzinfo=UTC)
|
||||||
|
elapsed = (now - last_poll).total_seconds()
|
||||||
|
if elapsed < device.poll_interval_s:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping device %r (id=%d): %.1fs elapsed < %ds interval",
|
||||||
|
device.friendly_name,
|
||||||
|
device.id,
|
||||||
|
elapsed,
|
||||||
|
device.poll_interval_s,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
poll_device(session, device)
|
||||||
@@ -19,31 +19,36 @@
|
|||||||
|
|
||||||
- `main.py`
|
- `main.py`
|
||||||
- FastAPI app factory
|
- FastAPI app factory
|
||||||
- lifespan
|
- lifespan(APScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布)
|
||||||
- 基础路由注册
|
- 基础路由注册
|
||||||
- `config.py`
|
- `config.py`
|
||||||
- 环境变量驱动的 settings
|
- 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项)
|
||||||
- `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
|
- `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`)
|
||||||
- 裸 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)共用同一个 `Base`,均落在单一 `app.db` 中
|
- 所有模型(auth / config / public_ip / location / poo / modbus / expose)共用同一个 `Base`,均落在单一 `app.db` 中
|
||||||
|
- M5 新增:`ModbusDevice`(设备部署层)、`ModbusReading`(通用遥测,JSON payload)、`ExposedEntityToggle`(HA 实体暴露开关)
|
||||||
- `schemas/`
|
- `schemas/`
|
||||||
- Pydantic schemas
|
- Pydantic schemas(M5 新增 `modbus.py`、`expose.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)
|
||||||
- `integrations/`
|
- `integrations/`
|
||||||
- 外部系统适配层
|
- 外部系统适配层
|
||||||
- 当前已迁入 Home Assistant outbound adapter
|
- Home Assistant outbound adapter(REST 通道,原有)
|
||||||
|
- 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 新增:`expose.py`(通用 expose 框架:`ExposableEntity`、provider 注册表、`build_catalog`;Modbus provider 从 YAML profile 派生 sensor/binary_sensor 实体目录)
|
||||||
- `static/`
|
- `static/`
|
||||||
- 极简静态资源
|
- 极简静态资源
|
||||||
|
|
||||||
@@ -76,6 +81,7 @@ React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 Fast
|
|||||||
- `app_db_adopt.py`:App DB 接管 / 初始化
|
- `app_db_adopt.py`:App DB 接管 / 初始化
|
||||||
- `migrate_legacy_data.py`:一次性历史数据搬迁脚本
|
- `migrate_legacy_data.py`:一次性历史数据搬迁脚本
|
||||||
- `admin_cli.py`:Admin CLI 逃生通道(M4),见下方"登录加固"说明
|
- `admin_cli.py`:Admin CLI 逃生通道(M4),见下方"登录加固"说明
|
||||||
|
- `modbus_cli.py`(M5):Modbus 手工试读 CLI(`read`/`probe` 两个只读子命令),不依赖 DB,供受控手工验证链路连通性
|
||||||
|
|
||||||
### `openapi/`
|
### `openapi/`
|
||||||
|
|
||||||
@@ -93,12 +99,69 @@ M4 在基础 Argon2 + server-side session 鉴权之上叠加了三层防御:
|
|||||||
|
|
||||||
详细说明:[`docs/auth.md`](./auth.md)
|
详细说明:[`docs/auth.md`](./auth.md)
|
||||||
|
|
||||||
|
## M5 — 通用 Modbus 采集链路与 MQTT 通道
|
||||||
|
|
||||||
|
### Modbus 采集链路
|
||||||
|
|
||||||
|
```
|
||||||
|
YAML profile(协议知识) modbus_device 行(部署信息,DB)
|
||||||
|
- 寄存器块/地址/类型 - host / port(网关 IP)
|
||||||
|
- 每量:key/unit/device_class - unit_id(Modbus slave 地址)
|
||||||
|
- ha_component - friendly_name / profile / poll_interval_s / enabled
|
||||||
|
│ │
|
||||||
|
└────────────┬───────────────────────┘
|
||||||
|
│ APScheduler job(轮询所有 enabled 设备)
|
||||||
|
│ driver.py: ModbusTcpClient → FC04 块读 → 大端 float32 解码
|
||||||
|
│ profiles.py: decode(profile, registers) → dict[key → value]
|
||||||
|
▼
|
||||||
|
modbus_reading 行
|
||||||
|
device_id · recorded_at · payload(JSON)
|
||||||
|
{"voltage": 230.2, "current": 1.3, "active_power": 295.0, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计决策**:
|
||||||
|
- 命名分层:存储/采集/API 全部通用 `modbus_*`(`/api/modbus/devices`);面向用户的领域视图叫 **Energy**(第一个)。接入新设备型号只需新增 YAML profile,不改表/不改 API。
|
||||||
|
- 读数为 JSON `payload`(无固定列),SQLite `json_extract` 在 DB 端做 AVG/GROUP BY(被 `(device_id, recorded_at)` 索引圈住)。
|
||||||
|
- FK `ON DELETE RESTRICT`:有读数的设备拒删,引导改为 `enabled=false`。
|
||||||
|
- 设备 `uuid`(uuid4 内部生成)是**稳定身份锚点**:API 路径键、HA Discovery `unique_id` 来源,不随 friendly_name 改变。
|
||||||
|
|
||||||
|
### 第二条 MQTT 通道(HA Discovery 发布)
|
||||||
|
|
||||||
|
与已有 REST 通道(`app/integrations/homeassistant.py`、`POST /homeassistant/publish`)**并行、不冲突**:
|
||||||
|
|
||||||
|
```
|
||||||
|
MQTT 通道(M5 新增):
|
||||||
|
paho-mqtt MqttManager(lifespan 长连接,配置变更可重连)
|
||||||
|
│
|
||||||
|
├─► Discovery config(retained)
|
||||||
|
│ topic: <prefix>/<component>/<node>/<object>/config
|
||||||
|
│ 内容:device 块(identifiers=uuid)、state_topic、unique_id(uuid+key)、
|
||||||
|
│ name(friendly_name)、device_class、unit_of_measurement、availability
|
||||||
|
│ 时机:连接成功时 / 目录或勾选变更时(全量重发);
|
||||||
|
│ 取消勾选时发空 payload(清除 entity)
|
||||||
|
│
|
||||||
|
└─► State / Availability(非 retained)
|
||||||
|
时机:每次轮询成功后推该设备所有 enabled entity 的最新值;
|
||||||
|
周期兜底 job 重推所有 enabled entity + online topic
|
||||||
|
|
||||||
|
expose 框架:
|
||||||
|
provider 动态产出 ExposableEntity 目录(元数据从 YAML profile 派生)
|
||||||
|
ExposedEntityToggle 表:只存逐 key 开关(default=disabled)
|
||||||
|
build_catalog(session) → 目录 + 勾选状态(合并所有 provider)
|
||||||
|
```
|
||||||
|
|
||||||
|
**HA entity 身份模型(Z2M 语义)**:
|
||||||
|
- `unique_id` = `f"{device.uuid}_{metric.key}"`(稳定,改名不变)
|
||||||
|
- `name` = `friendly_name`(改名重发 discovery,HA 显示名跟着变、历史不丢)
|
||||||
|
- 每设备除各 sensor entity 外,另有 `binary_sensor` `online`(取 `last_poll_ok`)——这是"不止 sensor"的体现
|
||||||
|
|
||||||
## 当前约束
|
## 当前约束
|
||||||
|
|
||||||
- 当前数据库继续使用 SQLite
|
- 当前数据库继续使用 SQLite
|
||||||
- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
|
- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
|
||||||
- 当前不设计 Notion 模块
|
- 当前不设计 Notion 模块
|
||||||
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
||||||
|
- Modbus 当前**仅 TCP**(Waveshare RTU↔TCP 网关),**只读**(FC03/04),不写设备寄存器
|
||||||
|
|
||||||
## 关于 Notion
|
## 关于 Notion
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
|
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
|
||||||
|
|
||||||
### M5-T01 — 侧边栏布局重构 `[structural]`
|
### M5-T01 — 侧边栏布局重构 `[structural]`
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
|
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
|
||||||
- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
|
- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -274,7 +274,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
|
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
|
||||||
|
|
||||||
### M5-T01B — Config 页分区折叠(Accordion)
|
### M5-T01B — Config 页分区折叠(Accordion)
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: config 内容会越来越多(M5 还要加 MQTT / HA Discovery / Modbus 配置 + Expose 面板)。把 ConfigPage 从一长串 section 改为 Mantine `Accordion`:进页只见各大类标题,逐类展开编辑。**纯前端、单列内容**——它不是"第二个 app 级侧栏",与主侧栏(T01)无冲突,故 `Depends: none`、可独立先做。
|
- **Context**: config 内容会越来越多(M5 还要加 MQTT / HA Discovery / Modbus 配置 + Expose 面板)。把 ConfigPage 从一长串 section 改为 Mantine `Accordion`:进页只见各大类标题,逐类展开编辑。**纯前端、单列内容**——它不是"第二个 app 级侧栏",与主侧栏(T01)无冲突,故 `Depends: none`、可独立先做。
|
||||||
- **Files**: `modify frontend/src/pages/ConfigPage.tsx`;`modify frontend/src/pages/ConfigPage.test.tsx`(折叠/展开断言)
|
- **Files**: `modify frontend/src/pages/ConfigPage.tsx`;`modify frontend/src/pages/ConfigPage.test.tsx`(折叠/展开断言)
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -290,7 +290,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 仅改 ConfigPage(+其测试),未碰 config 后端或主 layout;保存逻辑无回归;页面内**无第二个 app 级 sidebar**(accordion 是内容、非 chrome)。
|
- **Reviewer checklist**: 仅改 ConfigPage(+其测试),未碰 config 后端或主 layout;保存逻辑无回归;页面内**无第二个 app 级 sidebar**(accordion 是内容、非 chrome)。
|
||||||
|
|
||||||
### M5-T02 — `modbus_device` + `modbus_reading` 表与模型 `[schema]`
|
### M5-T02 — `modbus_device` + `modbus_reading` 表与模型 `[schema]`
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 单库 app 链新增两张**通用**表,建出 §3.2 结构(设备 = 部署层,读数 = JSON payload 通用遥测层)。本任务只建 schema + 模型,不写采集/接口。
|
- **Context**: 单库 app 链新增两张**通用**表,建出 §3.2 结构(设备 = 部署层,读数 = JSON payload 通用遥测层)。本任务只建 schema + 模型,不写采集/接口。
|
||||||
- **Files**: `create app/models/modbus.py`(`ModbusDevice`、`ModbusReading`,继承 `app.db.Base`);`create alembic_app/versions/<date>_07_modbus_tables.py`;`modify alembic_app/env.py`(import 新模型);`modify scripts/app_db_adopt.py`(`APP_BASELINE_REVISION` → 新 head);`create tests/test_modbus_models.py`
|
- **Files**: `create app/models/modbus.py`(`ModbusDevice`、`ModbusReading`,继承 `app.db.Base`);`create alembic_app/versions/<date>_07_modbus_tables.py`;`modify alembic_app/env.py`(import 新模型);`modify scripts/app_db_adopt.py`(`APP_BASELINE_REVISION` → 新 head);`create tests/test_modbus_models.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -306,7 +306,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 列/约束与 §3.2 一致;`payload` 为 JSON 列;`recorded_at` 为真实索引列(非塞进 payload);链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。
|
- **Reviewer checklist**: 列/约束与 §3.2 一致;`payload` 为 JSON 列;`recorded_at` 为真实索引列(非塞进 payload);链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。
|
||||||
|
|
||||||
### M5-T03 — Modbus 驱动 + YAML profile 框架(`sdm120`)
|
### M5-T03 — Modbus 驱动 + YAML profile 框架(`sdm120`)
|
||||||
- **Status**: `todo` · **Depends**: M5-T02
|
- **Status**: `done` · **Depends**: M5-T02
|
||||||
- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 YAML profile 加载/校验/解码框架与 SDM120 profile。纯模块,mock client 单测。
|
- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 YAML profile 加载/校验/解码框架与 SDM120 profile。纯模块,mock client 单测。
|
||||||
- **Files**: `create app/integrations/modbus/__init__.py`、`app/integrations/modbus/driver.py`、`app/integrations/modbus/profiles.py`、`app/integrations/modbus/profiles/sdm120.yaml`、`scripts/modbus_cli.py`;`modify requirements.in`/`requirements.txt`(加 `pymodbus`、`PyYAML`,重新锁定);`create tests/test_modbus_driver.py`、`tests/test_modbus_profiles.py`、`tests/test_modbus_cli.py`
|
- **Files**: `create app/integrations/modbus/__init__.py`、`app/integrations/modbus/driver.py`、`app/integrations/modbus/profiles.py`、`app/integrations/modbus/profiles/sdm120.yaml`、`scripts/modbus_cli.py`;`modify requirements.in`/`requirements.txt`(加 `pymodbus`、`PyYAML`,重新锁定);`create tests/test_modbus_driver.py`、`tests/test_modbus_profiles.py`、`tests/test_modbus_cli.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -329,7 +329,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;**CLI 与 driver 都无任何写寄存器路径(仅 FC03/04)**;profile 纯协议知识、无部署项;`requirements.txt` 已同步锁定 `pymodbus`、`PyYAML`。
|
- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;**CLI 与 driver 都无任何写寄存器路径(仅 FC03/04)**;profile 纯协议知识、无部署项;`requirements.txt` 已同步锁定 `pymodbus`、`PyYAML`。
|
||||||
|
|
||||||
### M5-T04 — 采集 service + APScheduler 轮询
|
### M5-T04 — 采集 service + APScheduler 轮询
|
||||||
- **Status**: `todo` · **Depends**: M5-T03
|
- **Status**: `done` · **Depends**: M5-T03
|
||||||
- **Context**: 周期扫所有 enabled 设备,调用 driver 读+解码,落 `modbus_reading.payload`。仿 public-ip 的同步 job 模式。
|
- **Context**: 周期扫所有 enabled 设备,调用 driver 读+解码,落 `modbus_reading.payload`。仿 public-ip 的同步 job 模式。
|
||||||
- **Files**: `create app/services/modbus_poll.py`;`modify app/main.py`(lifespan 注册 job);`create tests/test_modbus_poll.py`
|
- **Files**: `create app/services/modbus_poll.py`;`modify app/main.py`(lifespan 注册 job);`create tests/test_modbus_poll.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -345,7 +345,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job;payload 为解码后的 dict(非裸寄存器)。
|
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job;payload 为解码后的 dict(非裸寄存器)。
|
||||||
|
|
||||||
### M5-T05 — Modbus JSON API(device CRUD + readings + metrics + test)
|
### M5-T05 — Modbus JSON API(device CRUD + readings + metrics + test)
|
||||||
- **Status**: `todo` · **Depends**: M5-T02
|
- **Status**: `done` · **Depends**: M5-T02
|
||||||
- **Context**: 给前端提供设备 CRUD、最新读数、时间范围读数、量目录、即时试读。
|
- **Context**: 给前端提供设备 CRUD、最新读数、时间范围读数、量目录、即时试读。
|
||||||
- **Files**: `create app/api/routes/api/modbus.py`、`app/schemas/modbus.py`;`modify app/main.py`(注册路由);`create tests/test_api_modbus.py`
|
- **Files**: `create app/api/routes/api/modbus.py`、`app/schemas/modbus.py`;`modify app/main.py`(注册路由);`create tests/test_api_modbus.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -363,7 +363,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。
|
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。
|
||||||
|
|
||||||
### M5-T06 — 前端:设备管理 UI(Energy 视图)
|
### M5-T06 — 前端:设备管理 UI(Energy 视图)
|
||||||
- **Status**: `todo` · **Depends**: M5-T01, M5-T05
|
- **Status**: `done` · **Depends**: M5-T01, M5-T05
|
||||||
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
|
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
|
||||||
- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/DeviceForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx`
|
- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/DeviceForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx`
|
||||||
- **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled)+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
|
- **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled)+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
|
||||||
@@ -375,7 +375,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。
|
- **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。
|
||||||
|
|
||||||
### M5-T07 — 前端:读数展示 + Recharts 走势图
|
### M5-T07 — 前端:读数展示 + Recharts 走势图
|
||||||
- **Status**: `todo` · **Depends**: M5-T05(数据), M5-T06(页面壳)
|
- **Status**: `done` · **Depends**: M5-T05(数据), M5-T06(页面壳)
|
||||||
- **Context**: 在 Energy 页展示每设备最新读数 + 时间序列走势。
|
- **Context**: 在 Energy 页展示每设备最新读数 + 时间序列走势。
|
||||||
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/EnergyCharts.tsx`(封装 Recharts);`modify frontend/package.json`(加 `recharts`,`package-lock.json` 同步);`create` 对应测试
|
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/EnergyCharts.tsx`(封装 Recharts);`modify frontend/package.json`(加 `recharts`,`package-lock.json` 同步);`create` 对应测试
|
||||||
- **Steps**: 最新读数卡片(接 `latest`,字段标签/单位取自 `/metrics`);时间范围选择 + 折线图(电压/电流/功率/电能,从 `payload` 按 key 取序列),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。
|
- **Steps**: 最新读数卡片(接 `latest`,字段标签/单位取自 `/metrics`);时间范围选择 + 折线图(电压/电流/功率/电能,从 `payload` 按 key 取序列),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。
|
||||||
@@ -387,7 +387,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。
|
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。
|
||||||
|
|
||||||
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS)
|
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS)
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。
|
- **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。
|
||||||
- **Files**: `modify app/config.py`(新增 Settings 字段)、`app/services/config_page.py`(追加 CONFIG_FIELDS + `_settings_payload`);`modify .env.example`;`modify tests/test_api_config.py`
|
- **Files**: `modify app/config.py`(新增 Settings 字段)、`app/services/config_page.py`(追加 CONFIG_FIELDS + `_settings_payload`);`modify .env.example`;`modify tests/test_api_config.py`
|
||||||
- **Steps**: 加字段 `mqtt_enabled`、`mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled`、`ha_discovery_enabled`、`ha_discovery_prefix`(默认 `homeassistant`)、`modbus_polling_enabled`;CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Modbus」section;`_settings_payload` 补齐对应行。
|
- **Steps**: 加字段 `mqtt_enabled`、`mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled`、`ha_discovery_enabled`、`ha_discovery_prefix`(默认 `homeassistant`)、`modbus_polling_enabled`;CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Modbus」section;`_settings_payload` 补齐对应行。
|
||||||
@@ -399,7 +399,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。
|
- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。
|
||||||
|
|
||||||
### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]`
|
### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]`
|
||||||
- **Status**: `todo` · **Depends**: M5-T02
|
- **Status**: `done` · **Depends**: M5-T02
|
||||||
- **Context**: 建"可暴露实体目录"的抽象与开关存储;modbus provider 把设备映射成 device/entities,**实体元数据从 profile 派生**。
|
- **Context**: 建"可暴露实体目录"的抽象与开关存储;modbus provider 把设备映射成 device/entities,**实体元数据从 profile 派生**。
|
||||||
- **Files**: `create app/integrations/expose.py`(`ExposableEntity`、provider 协议、注册表、modbus provider);`create app/models/expose.py`(`ExposedEntityToggle`:`key` unique + `enabled` + `updated_at`);`create alembic_app/versions/<date>_08_exposed_entities.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`;`create tests/test_expose_catalog.py`
|
- **Files**: `create app/integrations/expose.py`(`ExposableEntity`、provider 协议、注册表、modbus provider);`create app/models/expose.py`(`ExposedEntityToggle`:`key` unique + `enabled` + `updated_at`);`create alembic_app/versions/<date>_08_exposed_entities.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`;`create tests/test_expose_catalog.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -416,7 +416,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: `key` 稳定(用设备 `uuid` + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。
|
- **Reviewer checklist**: `key` 稳定(用设备 `uuid` + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。
|
||||||
|
|
||||||
### M5-T10 — MQTT 客户端(paho,lifespan 连接 + 重连)
|
### M5-T10 — MQTT 客户端(paho,lifespan 连接 + 重连)
|
||||||
- **Status**: `todo` · **Depends**: M5-T08
|
- **Status**: `done` · **Depends**: M5-T08
|
||||||
- **Context**: 长连接 MQTT 客户端,配置变更可重连;含连接测试端点。
|
- **Context**: 长连接 MQTT 客户端,配置变更可重连;含连接测试端点。
|
||||||
- **Files**: `create app/integrations/mqtt.py`;`modify app/main.py`(lifespan 起/停)、`app/api/routes/api/config.py`(`POST /api/config/mqtt/test`);`modify requirements.in`/`requirements.txt`(加 `paho-mqtt` 重新锁定);`create tests/test_mqtt_client.py`
|
- **Files**: `create app/integrations/mqtt.py`;`modify app/main.py`(lifespan 起/停)、`app/api/routes/api/config.py`(`POST /api/config/mqtt/test`);`modify requirements.in`/`requirements.txt`(加 `paho-mqtt` 重新锁定);`create tests/test_mqtt_client.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -432,7 +432,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。
|
- **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。
|
||||||
|
|
||||||
### M5-T11 — Discovery 发布 + state 发布
|
### M5-T11 — Discovery 发布 + state 发布
|
||||||
- **Status**: `todo` · **Depends**: M5-T09, M5-T10
|
- **Status**: `done` · **Depends**: M5-T09, M5-T10
|
||||||
- **Context**: 把 enabled 实体发成 HA discovery config(retained)并周期推 state;采集轮询后推最新值。
|
- **Context**: 把 enabled 实体发成 HA discovery config(retained)并周期推 state;采集轮询后推最新值。
|
||||||
- **Files**: `create app/services/ha_discovery.py`;`modify app/services/modbus_poll.py`(轮询后推 state)、`app/main.py`(state 周期 job + 连接后/勾选变更后发 discovery)、`app/api/routes/api/...`(`/api/expose/republish` 在 T12 接,本任务提供 service);`create tests/test_ha_discovery.py`
|
- **Files**: `create app/services/ha_discovery.py`;`modify app/services/modbus_poll.py`(轮询后推 state)、`app/main.py`(state 周期 job + 连接后/勾选变更后发 discovery)、`app/api/routes/api/...`(`/api/expose/republish` 在 T12 接,本任务提供 service);`create tests/test_ha_discovery.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -449,7 +449,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。
|
- **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。
|
||||||
|
|
||||||
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
|
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
|
||||||
- **Status**: `todo` · **Depends**: M5-T09, M5-T11
|
- **Status**: `done` · **Depends**: M5-T09, M5-T11
|
||||||
- **Context**: 后端 expose 读写端点 + 设置页勾选界面。
|
- **Context**: 后端 expose 读写端点 + 设置页勾选界面。
|
||||||
- **Files**: `create app/api/routes/api/expose.py`、`app/schemas/expose.py`;`modify app/main.py`;`create tests/test_api_expose.py`;前端 `create frontend/src/pages/.../ExposeSettings.tsx`(或并入 ConfigPage)、`modify` 路由/设置入口;`create` 前端测试
|
- **Files**: `create app/api/routes/api/expose.py`、`app/schemas/expose.py`;`modify app/main.py`;`create tests/test_api_expose.py`;前端 `create frontend/src/pages/.../ExposeSettings.tsx`(或并入 ConfigPage)、`modify` 路由/设置入口;`create` 前端测试
|
||||||
- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`(key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
|
- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`(key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
|
||||||
@@ -461,7 +461,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置;republish 真触发 T11;类型化 client。
|
- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置;republish 真触发 T11;类型化 client。
|
||||||
|
|
||||||
### M5-T13 — 文档 + OpenAPI + roadmap 收尾
|
### M5-T13 — 文档 + OpenAPI + roadmap 收尾
|
||||||
- **Status**: `todo` · **Depends**: 全部
|
- **Status**: `done` · **Depends**: 全部
|
||||||
- **Files**: `modify README.md`(Modbus/Energy/MQTT 段、新依赖)、`docs/roadmap.md`(M5 行 + 把"MQTT/IoT"从"下一阶段"毕业、新增 Modbus 采集方向)、`docs/architecture-overview.md`(新增 MQTT 通道与 Modbus 采集);`modify docs/design/README.md`(列入 m5);`run python scripts/export_openapi.py` 并提交 `openapi/`
|
- **Files**: `modify README.md`(Modbus/Energy/MQTT 段、新依赖)、`docs/roadmap.md`(M5 行 + 把"MQTT/IoT"从"下一阶段"毕业、新增 Modbus 采集方向)、`docs/architecture-overview.md`(新增 MQTT 通道与 Modbus 采集);`modify docs/design/README.md`(列入 m5);`run python scripts/export_openapi.py` 并提交 `openapi/`
|
||||||
- **Acceptance criteria**:
|
- **Acceptance criteria**:
|
||||||
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异。
|
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异。
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
# M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算(含 Tibber 动态 / 固定合同两种 profile)
|
||||||
|
|
||||||
|
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 **M5(MQTT + HA Discovery + expose 框架 + Energy 视图 + Recharts)** 之上,复用其大部分基建,并把 M5 的"两层模型"(YAML profile 定结构 + DB 行存部署值)从设备采集复制到**电价合同**上。
|
||||||
|
> **状态**:架构与原子任务已拆到可开工。少数价格常数(卖价残差、能源税年值、双费率寄存器映射)等真实 Tibber token / 账单确认,已落成 §13 的可调值 + TODO,不阻塞实现。
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
把"电价合同"与"DSMR 实时电表数据"接起来,按 **15 分钟**周期算出**实际买电支出 / 卖电收入**,自己落库留底(不可变、可审计),并通过 MQTT + HA Discovery 反哺 Home Assistant 的 Energy 仪表盘:
|
||||||
|
|
||||||
|
1. **通用电价层(两层模型)**:仓库内**只读 YAML profile 定义合同结构**(`manual` 固定/可变费率、`tibber` 动态电价各一个结构);DB 的 **`EnergyContract`(+ 版本)存可改的数值**(UI 填);**price strategy** 按 `kind` 出价。一次激活一个合同。
|
||||||
|
2. **DSMR 实时数据接入**:订阅 DSMR Reader 的 `dsmr/json`(每秒一条完整 telegram),**整帧存为 JSON blob、按 10 秒降采样**落库(电、气、各相全存,未来加供暖不改表)。
|
||||||
|
3. **实时买卖电费计算(两层)**:每 15 分钟用**电表累计寄存器差值** × 当时合同的买/卖价算计量电费(不可变、快照价);日/月/年**汇总**再加固定费、减能源税抵扣。**不做净计量**(saldering 2027 取消)。
|
||||||
|
4. **反哺 Home Assistant**:复用 M5 expose/Discovery,把当前买/卖价、累计买电支出、累计卖电收入发成 HA 实体(累计用 `total_increasing`)。
|
||||||
|
5. **前端**:Energy 视图下新增合同管理(按 profile 结构生成表单)+ 价格曲线 + 费用走势/明细 + Tibber 测试。
|
||||||
|
|
||||||
|
> **命名分层(延续 M5)**:存储/采集/计算层中性命名(`dsmr_*` / `tibber_price` / `energy_cost_period` / `energy_contract`);面向用户并入既有 **Energy** 视图。
|
||||||
|
|
||||||
|
## 2. 现状(实现者可据此工作,不必通读全仓库)
|
||||||
|
|
||||||
|
**单库 + Alembic(M5 完成态)**
|
||||||
|
- `app/db.py`:`class Base(DeclarativeBase)`,cached engine(已开 `journal_mode=WAL` + `foreign_keys=ON`)。
|
||||||
|
- 单 Alembic 链 `alembic_app/`,**当前 head = `20260622_10_exposed_entities`**;`scripts/app_db_adopt.py` 的 `APP_BASELINE_REVISION` 同。迁移命名 `YYYYMMDD_NN_<desc>.py`,串 `down_revision`;`alembic_app/env.py` 逐个 import 模型。
|
||||||
|
- `modbus_reading` 已确立**通用 JSON payload 遥测**范式(`recorded_at` 真实索引列 + `payload` JSON)——`dsmr_reading` 直接沿用。
|
||||||
|
- M5 两层模型(`app/integrations/modbus/profiles/*.yaml` 定结构 + `modbus_device` 存部署值 + pydantic 启动期校验 + `decode`)是本里程碑电价层的范本。
|
||||||
|
|
||||||
|
**配置系统(扁平 KV,自动渲染)**
|
||||||
|
- `app/config.py` `Settings(BaseSettings)`;`app/services/config_page.py` 的 `CONFIG_FIELDS` 注册表 + `build_runtime_settings(session, bootstrap)`(DB override 合并,**消费方都用它**)+ `_settings_payload`(新字段补一行)。前端 `ConfigPage.tsx` 按 section/`input_type`/`secret` 通用渲染,bool 渲染成开关。
|
||||||
|
- ⚠️ 扁平 KV **装不下"合同清单 + 逐字段数值 + 版本"**——这些走专用表 + 专用 API + 自定义 UI(仿 M5 `modbus_device`)。
|
||||||
|
|
||||||
|
**MQTT(M5,关键:当前只发不收)**
|
||||||
|
- `app/integrations/mqtt.py` `MqttManager`:`is_configured/is_connected/connect/disconnect/reconnect/publish`,paho `loop_start()` 后台线程,VERSION2 回调。**无 `subscribe`/`on_message`**——M6-T06 在此扩订阅端。
|
||||||
|
- `app/integrations/expose.py`:`ExposableEntity`(dataclass,`value_getter: Callable[[Session],Any]`)、`EntityProvider = Protocol(session)->list[ExposableEntity]`、`register_provider` + `build_catalog`。已有 `_modbus_provider`。**M6 加 `_energy_cost_provider` 复用整条 Discovery 链**。
|
||||||
|
- `app/services/ha_discovery.py`:`build_discovery_payload`/`publish_discovery`/`publish_states`/`publish_device_state`。`app/models/expose.py` `ExposedEntityToggle`(默认未勾不暴露)。
|
||||||
|
|
||||||
|
**后台调度(APScheduler,UTC)**
|
||||||
|
- `app/main.py` lifespan:`BackgroundScheduler(timezone="UTC")`,`add_job(..., max_instances=1, coalesce=True)`;同步 wrapper 自管 session、吞异常不崩 job。已有 public-ip / modbus-poll / discovery-state 等 job——**新增 job/订阅不得破坏它们**。
|
||||||
|
|
||||||
|
**依赖**:`httpx`(Tibber GraphQL)、`paho-mqtt`、`pyyaml`、`apscheduler` 均已在 `requirements.in/.txt`。**M6 不新增 Python 依赖**。前端已有 Recharts / TanStack Query / `openapi-fetch` 类型化 client / Mantine。
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
### 3.0 数据流总览
|
||||||
|
|
||||||
|
```
|
||||||
|
EnergyContract(active, 版本带生效日期) DSMR Reader
|
||||||
|
│ kind=manual → 常数双费率 │ MQTT dsmr/json (1s)
|
||||||
|
│ kind=tibber → API 15min ▼
|
||||||
|
│ 订阅 + 10s 降采样 → dsmr_reading(整帧 JSON blob)
|
||||||
|
│ (kind=tibber 时) │
|
||||||
|
▼ │ 累计寄存器=真值
|
||||||
|
Tibber GraphQL → tibber_price(15min, 不可变) │
|
||||||
|
│ ▼
|
||||||
|
└──────────────► price strategy ◄────── 计费引擎(每 15min 边界)
|
||||||
|
(manual/tibber 出价) │ 寄存器差 × 价,快照
|
||||||
|
▼
|
||||||
|
energy_cost_period(每15min 计量电费, 不可变)
|
||||||
|
│
|
||||||
|
┌────────────────────────┼───────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
日/月/年汇总(+固定费 −抵扣) expose provider + HA Discovery /api/energy/* + 前端
|
||||||
|
```
|
||||||
|
|
||||||
|
三条核心原则:
|
||||||
|
1. **算钱用寄存器差值,不用功率积分**:每 15 分钟能量 = 累计寄存器@末 − @初,电表真值、与采样频率无关。瞬时功率只用于显示(且 HA 里已有,后端不专门用它算钱)。
|
||||||
|
2. **结构固定(YAML)、数值可改(UI/DB)、历史不可变(快照 + 版本)**:profile YAML 定合同长什么样;UI 填数值、改价存新版本;算过的周期快照当时的价,永不被回改。
|
||||||
|
3. **两层费用**:每 15 分钟只算**计量电费**;按天/年的**固定费、能源税抵扣**在**汇总层**加,不污染每周期引擎。三相 / 双费率 / 燃气都靠"通用 blob + profile 结构"容纳,不改表。
|
||||||
|
|
||||||
|
### 3.1 两层电价模型(本里程碑地基,仿 M5)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 知识层(固定,随代码走) 部署层(可改,落 DB,UI 填) │
|
||||||
|
│ ── pricing profile YAML ── ── EnergyContract 行 ── │
|
||||||
|
│ • kind=manual / tibber • name(UI 自由填) │
|
||||||
|
│ • 有哪些字段、单位、计费语义 • kind(UI 选) │
|
||||||
|
│ • 哪些分档 / 是否动态源 • active(一次一个) │
|
||||||
|
│ • UI 不可编辑 • currency │
|
||||||
|
│ ── EnergyContractVersion ── │
|
||||||
|
│ • effective_from / to │
|
||||||
|
│ • values(JSON,符合 profile) │
|
||||||
|
│ • 改价=加新版本,旧版本保留 │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│ kind 决定 strategy
|
||||||
|
▼
|
||||||
|
price strategy(代码): manual=常数双费率出价; tibber=读 tibber_price 出价
|
||||||
|
```
|
||||||
|
|
||||||
|
- **profile YAML(仓库内只读,定结构)**:`app/integrations/pricing/profiles/manual.yaml`、`tibber.yaml`,pydantic 启动期校验。声明该 kind **有哪些字段、单位、计费语义、哪些分档**——**不放具体数值**。
|
||||||
|
- **`EnergyContract` 行(部署,UI 填值)**:`name`(自由填,不 hardcode "fixed")、`kind`(UI 选)、`active`、`currency`。
|
||||||
|
- **`EnergyContractVersion`(版本/时段,可审计)**:`effective_from`/`effective_to`(null=开口) + `values`(JSON,符合 profile 结构)。**改价 = 加一个新版本行,旧版本保留**;"2026 上半年一个价、下半年另一个价" = 同合同两个版本。引擎算某周期时挑**覆盖该时刻的版本**。
|
||||||
|
- **price strategy(代码,按 kind)**:`manual` 用版本里的双费率常数出价;`tibber` 读 `tibber_price` + 版本里的 energy_tax/sell_adjust 出价。计费引擎对两者一视同仁。
|
||||||
|
|
||||||
|
**`manual.yaml` 结构(块状、可读;值在 UI 填)**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: manual
|
||||||
|
label: 固定 / 可变费率(NL,双费率)
|
||||||
|
energy:
|
||||||
|
dual_tariff: true # 用 delivered_1/2、returned_1/2 分高低谷
|
||||||
|
buy:
|
||||||
|
normal: { unit: EUR/kWh } # 高价档(_2)
|
||||||
|
dal: { unit: EUR/kWh } # 低价档(_1)
|
||||||
|
sell:
|
||||||
|
normal: { unit: EUR/kWh } # 回送高价
|
||||||
|
dal: { unit: EUR/kWh } # 回送低价(现两档同价,仍分开填)
|
||||||
|
energy_tax: { unit: EUR/kWh } # 能源税,加到买价上(含 VAT)
|
||||||
|
ode: { unit: EUR/kWh, default: 0 } # 现并入能源税
|
||||||
|
standing: # 固定费:UI 按月填,内部摊到每天
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
management_fee: { unit: EUR/month }
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year } # 能源税抵扣,年度,汇总时扣
|
||||||
|
```
|
||||||
|
|
||||||
|
**`tibber.yaml` 结构**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: tibber
|
||||||
|
label: Tibber 动态电价(15 分钟)
|
||||||
|
energy:
|
||||||
|
source: tibber_api # buy = total; sell = total − energy_tax − sell_adjust
|
||||||
|
energy_tax: { unit: EUR/kWh }
|
||||||
|
sell_adjust: { unit: EUR/kWh, default: 0 }
|
||||||
|
standing:
|
||||||
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 所有金额含 VAT(用户口径)。**Tibber token** 不在合同里、走 secret config(凭据归 config)。
|
||||||
|
|
||||||
|
### 3.2 DSMR 接入
|
||||||
|
|
||||||
|
- **数据源**:DSMR Reader 的 **Telegram JSON**(单 topic `dsmr/json`,一条 = 一帧完整 telegram,已是解析后的表寄存器值)。**每秒一条**。
|
||||||
|
- **整帧存 JSON blob**(沿用 `modbus_reading` payload 哲学):**不做字段 allowlist**,整帧存下来(电、气 `extra_device_*`、各相全要)。理由:现有燃气、未来供暖、三相——blob 全装,**不改表**;读取时按 key 取。
|
||||||
|
- **实测 JSON 的坑(已确认)**:数值是 **JSON 字符串**(`"20915.154"`)→ 计费读取时转 **Decimal**;缺测相位是 **`null`** → 视为缺测;`id`(telegram 自增)做幂等去重。
|
||||||
|
- **降采样**:仅当 `timestamp` 秒数落在 `dsmr_sample_interval_s`(默认 10)整数倍时落盘,其余丢弃(量从 60 行/分降到 ~6 行/分;秒=00 在网格上,每个 15 分钟边界都抓得到)。
|
||||||
|
- **订阅**:扩 `MqttManager` 订阅端(`on_connect` 里 subscribe、`topic→handler`、`on_message` 分发,handler 在网络线程内只做"解析+降采样+一次短事务"、吞异常不崩连接)。`dsmr_ingest_enabled`(默认 false,opt-in)。
|
||||||
|
- **进口/出口总量** = `delivered_1+delivered_2` / `returned_1+returned_2`;**双费率分档** = `_1`(dal/低) vs `_2`(normal/高)(NL 惯例,§13 确认别接反)。
|
||||||
|
- **三相 / 燃气 / 供暖**:blob 天然容纳,单相→三相只是多几个 key,不改表(详 §10)。
|
||||||
|
|
||||||
|
### 3.3 Tibber 价格接入(仅 `kind=tibber` 时)
|
||||||
|
|
||||||
|
- **客户端**:`app/integrations/tibber/client.py`,httpx POST `https://api.tibber.com/v1-beta/gql`,`Bearer <token>`。
|
||||||
|
- **15 分钟查询**(introspection + demo 实证):
|
||||||
|
```graphql
|
||||||
|
{ viewer { homes { id currentSubscription {
|
||||||
|
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
||||||
|
nodes { startsAt total energy tax currency level }
|
||||||
|
} } } } }
|
||||||
|
```
|
||||||
|
- `Price` 字段:`total`(=energy+tax,**全包价**,demo 已证 `total==energy+tax`)、`energy`(纯 spot ex-VAT)、`tax`、`startsAt`(含时区偏移)、`currency`、`level`。
|
||||||
|
- 枚举 **`PriceInfoRangeResolution`** 值 `DAILY/HOURLY/QUARTER_HOURLY`(不是 `PriceResolution`);老的 `priceInfo.range` 已废弃。
|
||||||
|
- **已验证(demo key)**:返回 96 节点、间隔 15 分钟、`total==energy+tax`;老数据无真 15min 价时 API 把小时价**重复成 4 个刻钟**,当前 NL 数据则是真 15 分钟。**解析不假设固定间隔/节点数**,按 `startsAt` 逐点存。
|
||||||
|
- **抓取调度**:启动 + 每日 job 拉「今天+明天」,按 `starts_at` **upsert** `tibber_price`(幂等)。次日价约 13:00 CET 发布 → 每日 14:00 CET(12:00 UTC)抓明天,缺失每小时重试。**仅当 active 合同 kind=tibber 且 token 存在时跑**。
|
||||||
|
- **连接测试** `POST /api/energy/tibber/test`:拉一次 current price,三态(success 带当前价 / config-error / failed),仿 M5 `mqtt/test`。
|
||||||
|
|
||||||
|
### 3.4 计费引擎(两层)
|
||||||
|
|
||||||
|
**第一层 — 每 15 分钟计量电费(`energy_cost_period`,不可变、快照价)**
|
||||||
|
- 触发:APScheduler 1 分钟 tick,找"已闭合未算"的 15 分钟周期算(也支持按区间手动重算)。
|
||||||
|
- 单周期 `[t0,t1)`(`t1` 已过):
|
||||||
|
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
||||||
|
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
||||||
|
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
||||||
|
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_adjust`;`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
||||||
|
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
||||||
|
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
||||||
|
|
||||||
|
**第二层 — 汇总(日/月/年,读时计算,不存表)**
|
||||||
|
- `Σ 周期 net_cost`(区间内)**+ 固定费**(`network_fee + management_fee` 按月→按天 × 天数)**− 能源税抵扣**(`heffingskorting` 按年→按天 × 天数)= **实际应付**。
|
||||||
|
- 固定费/抵扣取 active 合同版本的值;UI 按月/按年填,引擎内部摊到每天。
|
||||||
|
|
||||||
|
### 3.5 数据模型(新增 5 张表,单库 app 链)
|
||||||
|
|
||||||
|
> 单一 P1 智能电表,`dsmr_reading` 不设 device 表;将来第二块表加 `source` 列即可。
|
||||||
|
|
||||||
|
| 表 | 关键列 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **`dsmr_reading`** | `recorded_at`(idx)、`source_id`(unique,=telegram id)、`payload`(JSON) | 整帧 telegram blob,10s 降采样 |
|
||||||
|
| **`energy_contract`** | `name`、`kind`(manual/tibber)、`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` | 15min 价缓存,**不可变**;仅 tibber kind |
|
||||||
|
| **`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` | 每15min 计量电费,**不可变**(快照价+来源版本)|
|
||||||
|
|
||||||
|
- `energy_cost_period` 存 per-register 差 + 快照价 → 完全可审计、source-agnostic、可重算覆盖。
|
||||||
|
- FK:`energy_contract_version.contract_id → energy_contract.id`(ON DELETE RESTRICT);`energy_cost_period.contract_version_id → energy_contract_version.id`(RESTRICT,保审计链)。
|
||||||
|
|
||||||
|
### 3.6 不可变 / 审计(贯穿)
|
||||||
|
|
||||||
|
1. **算账快照**:`energy_cost_period` 存死当时实际用的价 + `contract_version_id`;改合同 / 改价**不回改**已算周期——"以前的价就是以前的价"。重算是**显式 opt-in**(`POST /recompute`),不自动覆盖历史。
|
||||||
|
2. **合同版本只增不改**:改价 = 新 `energy_contract_version`(带生效日期),旧版本保留,全程审计链。
|
||||||
|
3. **Tibber 价不可变**:过去某 15 分钟的价不会变。
|
||||||
|
4. 三者叠加 → manual / tibber 都满足审计与不可变。
|
||||||
|
|
||||||
|
### 3.7 MQTT / HA expose(复用 M5)
|
||||||
|
|
||||||
|
加 `app/integrations/expose.py::_energy_cost_provider`(`register_provider`),归一个 HA device(`identifiers="energy-cost"`):
|
||||||
|
|
||||||
|
| key | component | 取值 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `buy_price_now` | sensor | 当前周期/当前档买价(€/kWh,monetary) | HA 当前单价 |
|
||||||
|
| `sell_price_now` | sensor | 当前卖价(€/kWh) | |
|
||||||
|
| `import_cost_total` | sensor | 累计 import_cost(`total_increasing`,monetary,currency) | HA Energy "总成本实体" |
|
||||||
|
| `export_revenue_total` | sensor | 累计 export_revenue(`total_increasing`) | HA Energy 回送补偿 |
|
||||||
|
|
||||||
|
- `value_getter(session)` 从 `energy_cost_period`(累计/当前)取值。沿用 M5 勾选(默认未勾)、`publish_discovery`、`publish_states`(计费 job 后顺带推 + 周期兜底)。
|
||||||
|
|
||||||
|
### 3.8 前端(并入 Energy 视图)
|
||||||
|
|
||||||
|
- **合同管理**:列表 + 新建/编辑(**表单按选中 profile 结构渲染字段**:manual 双费率/能源税/固定费/抵扣;tibber token 提示走 Config)+ 激活(一次一个 active)+ 改价=新版本(带生效日期)+ 版本历史只读展示。
|
||||||
|
- **价格曲线**:今天+明天 15min 买/卖价(tibber)或固定档位(manual)折线(复用 `EnergyCharts`),取 `/api/energy/prices`。
|
||||||
|
- **费用走势 + 明细 + 汇总**:当日/月 import_cost/export_revenue/net 走势 + 每 15min 明细 + 汇总卡片(含固定费/抵扣),取 `/api/energy/costs` + `/costs/summary`。
|
||||||
|
- **Tibber 测试**:Config 页一块「Tibber」,三态(仿 MQTT 测试)。
|
||||||
|
- 全走类型化 client;空/加载/错误态齐全;缺 key 容错。
|
||||||
|
|
||||||
|
## 4. API 契约(M6 要落地的端点)
|
||||||
|
|
||||||
|
> 全部 `/api` 前缀、session + CSRF(写)保护、JSON 进出;schema 经 `scripts/export_openapi.py` 固化入库。
|
||||||
|
|
||||||
|
| 分组 | 端点 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 合同 | `GET /api/energy/contracts` | 列出合同 + active 标记 |
|
||||||
|
| 合同 | `POST /api/energy/contracts` | 新建合同(kind + 首版本值,按 profile 校验)|
|
||||||
|
| 合同 | `GET /api/energy/contracts/{id}` | 单个合同 + 版本历史 |
|
||||||
|
| 合同 | `PATCH /api/energy/contracts/{id}` | 改名 / 激活(一次一个 active)|
|
||||||
|
| 合同 | `POST /api/energy/contracts/{id}/versions` | 加新版本(改价,带生效日期)|
|
||||||
|
| 合同 | `GET /api/energy/profiles` | 列出 pricing profile 结构(前端按它渲染表单)|
|
||||||
|
| 价格 | `GET /api/energy/prices?start&end` | 区间价格点(曲线)|
|
||||||
|
| 费用 | `GET /api/energy/costs?start&end` | 区间 `energy_cost_period`(走势/明细)|
|
||||||
|
| 费用 | `GET /api/energy/costs/summary?start&end` | 区间汇总(计量电费 + 固定费 − 抵扣)|
|
||||||
|
| 费用 | `POST /api/energy/costs/recompute?start&end` | 幂等重算(补数据/改价后,显式)|
|
||||||
|
| DSMR | `GET /api/energy/dsmr/latest` | 最新 `dsmr_reading` |
|
||||||
|
| 测试 | `POST /api/energy/tibber/test` | 试连 Tibber + 拉当前价,三态 |
|
||||||
|
|
||||||
|
> DSMR/Tibber 的**标量配置 + token** 复用现有 `GET/PUT /api/config`(只加 CONFIG_FIELDS)。**价格数值全在合同里**,不进 flat config。
|
||||||
|
|
||||||
|
## 5. 已锁定决策(讨论后拍板)
|
||||||
|
|
||||||
|
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
||||||
|
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
||||||
|
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_adjust`;manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
||||||
|
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
||||||
|
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
||||||
|
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
||||||
|
7. **DSMR 整帧存 JSON blob**(不做字段 allowlist),10s 降采样;数值转 Decimal、null 容错、`id` 幂等。扩 `MqttManager` 订阅端。
|
||||||
|
8. **5 张表**:`dsmr_reading`、`energy_contract`、`energy_contract_version`、`tibber_price`、`energy_cost_period`。单电表不设 device 表。
|
||||||
|
9. **不可变 / 审计**:算账快照价 + 合同版本只增不改 + tibber 价天然不可变;重算显式 opt-in。
|
||||||
|
10. **Tibber 用 httpx + `priceInfoRange(QUARTER_HOURLY)`**,仅 active=tibber 时抓;token 走 secret config。
|
||||||
|
11. **复用 M5 expose/Discovery**:加 `_energy_cost_provider`;累计成本/收入用 `total_increasing` 喂 HA Energy。
|
||||||
|
12. **前端并入 Energy 视图**:合同管理表单按 profile 结构渲染;Recharts 画价/费。
|
||||||
|
13. **opt-in 开关**:`dsmr_ingest_enabled` 默认 false;Tibber 抓取/计费由 active 合同存在性驱动。
|
||||||
|
14. **周期边界用 UTC 刻钟**(NL 偏移整小时,本地/UTC 重合)。**不新增依赖**。
|
||||||
|
|
||||||
|
> 项目定位:个人自用、家庭特化、不开源——按单用户场景简化,不过度抽象。
|
||||||
|
|
||||||
|
## 6. 任务依赖图
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase A(地基)
|
||||||
|
M6-T01 [schema] 5 张表 + 模型 + 迁移
|
||||||
|
M6-T02 flat 配置(dsmr_* + tibber token/home_id) ← 与 T01 并行
|
||||||
|
M6-T03 pricing profile 框架(manual.yaml/tibber.yaml + pydantic + strategy 注册表) ← 纯模块,可早做
|
||||||
|
|
||||||
|
Phase B(合同 + 数据接入)
|
||||||
|
M6-T04 EnergyContract CRUD + 版本 + 按 profile 校验(API) (Depends T01,T03)
|
||||||
|
M6-T05 Tibber 客户端 + 抓价 service + 调度 + tibber/test (Depends T01,T02,T03)
|
||||||
|
M6-T06 MqttManager 扩订阅 + DSMR ingest(blob/10s) (Depends T01,T02)
|
||||||
|
|
||||||
|
Phase C(计算 + 反哺)
|
||||||
|
M6-T07 计费引擎(每15min 计量 + 汇总)+ 周期 job + 重算 (Depends T01,T03,T04,T05,T06)
|
||||||
|
└─► M6-T08 energy_cost expose provider + state 发布 (Depends T07)
|
||||||
|
|
||||||
|
Phase D(API + 前端)
|
||||||
|
M6-T09 Energy 数据 API(prices/costs/summary/dsmr-latest/recompute) (Depends T05,T07)
|
||||||
|
└─► M6-T10 前端:合同管理 + 价格/费用视图 + Tibber 测试 (Depends T04,T09)
|
||||||
|
|
||||||
|
收尾
|
||||||
|
M6-T11 文档 + OpenAPI + roadmap(Depends 全部)
|
||||||
|
```
|
||||||
|
|
||||||
|
`T01`、`T02`、`T03` 无前置可先开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 原子任务(任务卡)
|
||||||
|
|
||||||
|
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库);前端闸门见 §8。**不新增依赖**。纯新增、不删/移文件;改 `app/main.py` lifespan 时不得破坏既有 job/连接。
|
||||||
|
|
||||||
|
### M6-T01 — 5 张表与模型 `[schema]`
|
||||||
|
- **Status**: `todo` · **Depends**: none
|
||||||
|
- **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`
|
||||||
|
- **Steps**:
|
||||||
|
1. 按 §3.5 定义模型(`Mapped[...]`):`DsmrReading`(`recorded_at` idx、`source_id` unique nullable、`payload` JSON);`EnergyContract`(`name`/`kind`/`active`/`currency`/时间戳);`EnergyContractVersion`(`contract_id` FK RESTRICT、`effective_from`/`effective_to` nullable、`values` JSON、`created_at`);`TibberPrice`(`starts_at` unique、`resolution`、`energy/tax/total` float、`level` nullable、`currency`、`fetched_at`);`EnergyCostPeriod`(`period_start` unique、`d1/d2/r1/r2_kwh` float、`import_cost/export_revenue/net_cost` float、`currency`、`pricing` JSON、`contract_version_id` FK RESTRICT nullable、`degraded` bool、`computed_at`)。
|
||||||
|
2. 新 revision:`down_revision="20260622_10_exposed_entities"`;`upgrade()` 建 5 表 + 索引/唯一/FK;`downgrade()` 反向 drop。更新 `APP_BASELINE_REVISION`。
|
||||||
|
- **Out of scope**: 不写客户端/采集/计费/接口;不动其它模型。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 全新临时库 upgrade 到 head 含 5 表及约束;`downgrade -1` 干净回滚;链上单 head。
|
||||||
|
- [ ] FK 为 RESTRICT;唯一/索引正确;`payload`/`values`/`pricing` 为 JSON 列。
|
||||||
|
- [ ] `APP_BASELINE_REVISION` == 新 head。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列;`env.py` 已 import 新模型;迁移 `down_revision` 指当前真实 head;无破坏性操作。
|
||||||
|
|
||||||
|
### M6-T02 — flat 配置(DSMR + Tibber 凭据)
|
||||||
|
- **Status**: `todo` · **Depends**: none
|
||||||
|
- **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`
|
||||||
|
- **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)。
|
||||||
|
- **Out of scope**: 不建客户端/采集;不动既有字段;不放任何价格常数。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 新项在 `GET /api/config` 分 section;token 为 secret(回空/留空保留);非法值 422 不写库。
|
||||||
|
- [ ] 校验闸门全绿(OpenAPI 变化则重导出)。
|
||||||
|
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;`_settings_payload` 无遗漏;默认 off。
|
||||||
|
|
||||||
|
### M6-T03 — pricing profile 框架 + strategy 注册表
|
||||||
|
- **Status**: `todo` · **Depends**: none
|
||||||
|
- **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`
|
||||||
|
- **Steps**:
|
||||||
|
1. `profiles.py`:pydantic 模型描述 §3.1 结构;`load_profile(kind)`(读 YAML,校验失败抛错);`validate_values(kind, values)`(合同数值是否符合结构)。
|
||||||
|
2. `profiles/*.yaml`:按 §3.1 写 manual / tibber 结构。
|
||||||
|
3. `strategies.py`:策略接口 `price_period(deltas, t0, values, session) -> {import_cost, export_revenue, net, pricing_snapshot}`(Decimal):
|
||||||
|
- `manual`:双费率公式(§3.4),`buy_x = energy_buy_x + energy_tax + ode`、`sell_x` 直接用。
|
||||||
|
- `tibber`:取覆盖 t0 的 `tibber_price`,`buy=total`、`sell=total−energy_tax−sell_adjust`,进出口求和。
|
||||||
|
- **Out of scope**: 不连真实 API(tibber 策略只读 `tibber_price` 表,由 T05 填);不写采集/调度/接口。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 单测:`load_profile("manual"/"tibber")` 校验通过;缺字段/类型错抛可识别错;`validate_values` 正确判合规。
|
||||||
|
- [ ] 单测:manual 策略双费率出价正确(`import = Δd1×buy_dal + Δd2×buy_normal` 等);tibber 策略 `buy=total`、`sell=total−energy_tax−sell_adjust`,负 total 出口得负值。
|
||||||
|
- [ ] Decimal 计算无 float 误差。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
|
||||||
|
|
||||||
|
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API)
|
||||||
|
- **Status**: `todo` · **Depends**: M6-T01, M6-T03
|
||||||
|
- **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`
|
||||||
|
- **Steps**:
|
||||||
|
1. `contracts.py` service:建合同(含首版本,`validate_values` 校验)、加版本(带 `effective_from`,自动闭合上一版本 `effective_to`)、激活(把其它 active 置 false)、`active_contract_version_at(session, ts)`(取 active 合同在 ts 生效的版本)。
|
||||||
|
2. 路由:`GET/POST/GET{id}/PATCH{id}`、`POST {id}/versions`、`GET /profiles`(返回结构供前端渲染表单);session+CSRF;数值不合规 422。
|
||||||
|
- **Out of scope**: 不算费用(T07);不发 MQTT;不写采集。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] CRUD + 版本 + 激活行为正确:新建按 profile 校验(不合规 422);加版本自动闭合上一段;激活互斥(同时只一个 active);`GET /profiles` 返回结构。
|
||||||
|
- [ ] 未登录 401、缺 CSRF 403;schema 经 OpenAPI 固化入库。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。
|
||||||
|
|
||||||
|
### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
|
||||||
|
- **Status**: `todo` · **Depends**: M6-T01, M6-T02, M6-T03
|
||||||
|
- **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`
|
||||||
|
- **Steps**:
|
||||||
|
1. `client.py`:`fetch_price_range(token, home_id|None) -> list[PricePoint]`(§3.3 query,httpx 超时/认证失败抛错,不假设固定节点数);`fetch_current_price(...)`(供 test)。
|
||||||
|
2. `tibber_prices.py`:`refresh_prices(session, settings)`(拉今天+明天 upsert,幂等);仅当 active 合同 kind=tibber 且 token 存在时执行。
|
||||||
|
3. `main.py`:同步 wrapper + `IntervalTrigger`,`max_instances=1, coalesce=True`,吞异常不崩。
|
||||||
|
- **Out of scope**: 不算费用(T07);不发 MQTT;test 路由在 T09(本任务备 service)。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 单测(httpx mock):解析返回为 PricePoint;认证失败/超时抛可识别异常。
|
||||||
|
- [ ] 单测:`refresh_prices` 幂等(按 `starts_at` upsert,不重复插);非 tibber active 时 no-op。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: token 不进日志;httpx 超时;upsert 幂等;时间存 UTC;无对 `tibber_price` 的破坏性批删。
|
||||||
|
|
||||||
|
### M6-T06 — MqttManager 扩订阅 + DSMR ingest
|
||||||
|
- **Status**: `todo` · **Depends**: M6-T01, M6-T02
|
||||||
|
- **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`
|
||||||
|
- **Steps**:
|
||||||
|
1. `mqtt.py`:`subscribe(topic, handler)`(`on_connect` 里对已注册 topic subscribe,`on_message` 按 topic 分发,handler 异常吞掉不崩连接);保持既有 publish 不回归。
|
||||||
|
2. `dsmr_ingest.py`:`handle_message(payload_bytes, settings)`:parse JSON → 取 `timestamp`(UTC) → 降采样(`second % dsmr_sample_interval_s != 0` 丢弃)→ **整帧存 payload**(值原样/Decimal 友好)→ 短 session 写 `dsmr_reading`(`source_id`=`id`,存在则跳过)。
|
||||||
|
3. `main.py`:`dsmr_ingest_enabled` 时把 handler 注册到 `dsmr_mqtt_topic`。
|
||||||
|
- **Out of scope**: 不算费用(T07);不改既有 publish/discovery 语义;不做字段 allowlist(整帧存)。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 单测:`subscribe` 注册后 `on_message` 分发到 handler;既有 publish 无回归。
|
||||||
|
- [ ] 单测:喂样本 JSON(字符串数值、null 相位),秒=00 整帧落盘、秒≠10 整数倍丢弃;同 `id` 不重插。
|
||||||
|
- [ ] `dsmr_ingest_enabled=false` 不订阅/不落盘。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: on_message 网络线程内只做短事务、吞异常不崩连接;整帧存(含 gas/各相);null 容错;`source_id` 幂等;10s 降采样正确。
|
||||||
|
|
||||||
|
### M6-T07 — 计费引擎 + 周期 job + 汇总 + 重算
|
||||||
|
- **Status**: `todo` · **Depends**: M6-T01, M6-T03, M6-T04, M6-T05, M6-T06
|
||||||
|
- **Context**: 每 15min 用寄存器差 × active 合同 strategy 算计量电费(快照、不可变);汇总加固定费 − 抵扣;周期 job + 重算。
|
||||||
|
- **Files**: `create app/services/energy_cost.py`;`modify app/main.py`(周期 job);`create tests/test_energy_cost.py`
|
||||||
|
- **Steps**:
|
||||||
|
1. `energy_cost.py`:`register_at(session, boundary)`(取 ≤boundary 最后一行的 d1/d2/r1/r2,Decimal);`compute_period(session, t0)`(取 active 版本 + strategy(T03/T04)→ 算 → upsert + 快照 `pricing` + `contract_version_id`,缺价/缺数据标 `degraded` 或跳过);`compute_closed_periods(session)`;`recompute_range(session, start, end)`(幂等覆盖);`summarize(session, start, end)`(Σnet + 固定费按天 − heffingskorting 按天,取 active 版本值)。
|
||||||
|
2. `main.py`:1 分钟 tick job(`max_instances=1, coalesce=True`),有 active 合同 + DSMR 数据时算;吞异常不崩。
|
||||||
|
- **Out of scope**: 不发 MQTT(T08);不加 HTTP(T09,备 service);不改采集/价格抓取。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 单测:构造 `dsmr_reading`+合同版本(manual 双费率 / tibber + `tibber_price`),`compute_period` 算出正确 cost/revenue/net,快照价 + `contract_version_id` 落库;upsert 幂等。
|
||||||
|
- [ ] 单测:跨价格版本时按 t0 选对版本;缺价跳过、缺读数标 `degraded`。
|
||||||
|
- [ ] 单测:`summarize` = Σnet + 固定费(月→天×天数)− heffingskorting(年→天×天数)。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0<effective_to`;job 不崩。
|
||||||
|
|
||||||
|
### M6-T08 — energy_cost expose provider + state 发布
|
||||||
|
- **Status**: `todo` · **Depends**: M6-T07
|
||||||
|
- **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`
|
||||||
|
- **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`。
|
||||||
|
- **Out of scope**: 不改 Discovery 机制本身;不改采集/计费。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 单测:`build_catalog` 含 energy_cost 实体;累计项 `total_increasing`;勾选默认未勾。
|
||||||
|
- [ ] 单测:`value_getter` 取到正确当前价/累计值;MQTT 未启用整链 no-op。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。
|
||||||
|
|
||||||
|
### M6-T09 — Energy 数据 API
|
||||||
|
- **Status**: `todo` · **Depends**: M6-T05, M6-T07
|
||||||
|
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。
|
||||||
|
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`;`modify app/main.py`(注册);`create tests/test_api_energy.py`
|
||||||
|
- **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。
|
||||||
|
- **Out of scope**: 不写采集/计费/发布逻辑(复用 service)。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 各端点行为正确:窗口+limit 上限生效;recompute 幂等;tibber/test 三态;401/403 正确;schema 入库(`git diff --exit-code openapi/` 无差异)。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
|
||||||
|
|
||||||
|
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
|
||||||
|
- **Status**: `todo` · **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`
|
||||||
|
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
|
||||||
|
- **Out of scope**: 不做服务端降采样;不改后端。
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 合同 CRUD/激活/版本可用,表单按 profile 渲染;价格/费用/汇总渲染正确;区间只取窗口;币种/单位来自 API。
|
||||||
|
- [ ] Tibber 测试三态可用。
|
||||||
|
- [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。
|
||||||
|
- **Reviewer checklist**: 全走类型化 client;图表组件隔离复用;表单按结构渲染、不 hardcode 字段;窗口/上限;空/错/加载/缺 key 容错。
|
||||||
|
|
||||||
|
### M6-T11 — 文档 + OpenAPI + roadmap 收尾
|
||||||
|
- **Status**: `todo` · **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/`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异;校验闸门全绿。
|
||||||
|
- **Reviewer checklist**: 无残留过时描述;OpenAPI 入库;§13 已敲定项同步回文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端校验闸门
|
||||||
|
|
||||||
|
在 `frontend/` 下:`npm ci && npm run lint && npm run typecheck && npm run test && npm run build`(留意 chunk 体积告警)。后端同任务改路由/schema 仍需根目录 `export_openapi.py` 并提交 `openapi/`。**不新增前端依赖**(Recharts 已在)。
|
||||||
|
|
||||||
|
## 9. 构建上下文完整性(M1 教训)
|
||||||
|
|
||||||
|
- **纯新增、不删/移文件**,**不新增依赖**(httpx/paho/pyyaml 已在)。新增源文件都在 `app/`、`scripts/`、`frontend/` 既有 `COPY` 范围内,无需改 `Dockerfile`;**`app/integrations/pricing/profiles/*.yaml` 是非 .py 资源**——确认随 `COPY app` 进镜像、运行期按 `__file__`/`importlib.resources` 定位(别用 CWD 相对路径)。`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
|
||||||
|
- 改 `app/main.py` lifespan 注册新 job/订阅时**不得破坏**既有 public-ip / modbus-poll / discovery job 与 MQTT 连接。
|
||||||
|
- 发版前置走查(CLAUDE.md):真起 app → 订到 `dsmr/json`、(合同生效后)拉一次 Tibber 价、看费用落库、前端合同/价格/费用视图人工瞄一眼,再打 tag。
|
||||||
|
|
||||||
|
## 10. 后续杠杆(本里程碑不做,文档留痕)
|
||||||
|
|
||||||
|
- **回送阶梯罚金(terugleverkosten)**:按自然年累计、阶梯式;本期不做。将来做就把阶梯表存进合同结构,在**年度汇总**层按 YTD 累计回送量套阶梯(不进每周期引擎)。
|
||||||
|
- **燃气 / 区域供暖计费**:数据已在 blob 里(gas `extra_device_*`);将来在合同结构加"商品价"+ `energy_cost_period` 加 `commodity` 维度即可,不改主链。
|
||||||
|
- **能源税分档**:年用电跨 10000 kWh 档时税率变;现按单档配置。
|
||||||
|
- **降采样 / 保留**:`dsmr_reading` 10s 长期行数大,加定期降采样/保留窗口任务(SQL 端 `AVG(json_extract(...))`)。
|
||||||
|
- **价格 level 配色 / 便宜时段提示**:用 Tibber `level` 驱动前端配色与用电建议。
|
||||||
|
- **HA 让 HA 算 vs 后端算**:当前后端算累计;也可只发买/卖价 €/kWh 让 HA Energy 自乘(实体都已具备)。
|
||||||
|
|
||||||
|
## 11. 人工验收 walkthrough(实现完成后)
|
||||||
|
|
||||||
|
> 受控手工验证(依赖家里 DSMR Reader + broker;Tibber 部分待合同生效),不进自动化。
|
||||||
|
|
||||||
|
1. **合同(先用 manual,立即可用)**:Energy 页新建一个 `manual` 合同,按表单填双费率/能源税/固定费/抵扣,激活。
|
||||||
|
2. **DSMR 落库**:开 `dsmr_ingest_enabled`、确认 topic;MQTT Explorer 看 `dsmr/json` 有消息;等若干 10 秒 → `GET /api/energy/dsmr/latest` 有最新整帧;`dsmr_reading` 约每 10 秒一行(整帧 blob)。
|
||||||
|
3. **费用计算**:跨过一个整刻钟边界 → `GET /api/energy/costs` 出现该周期 import/export/cost/revenue/net(manual 双费率分档对得上手算);`GET /costs/summary` 含固定费/抵扣;改价加新版本后旧周期不变、`POST /recompute` 才覆盖。
|
||||||
|
4. **Tibber(合同生效后)**:建 `tibber` 合同、Config 填 token、点「Tibber 测试」三态成功;激活后抓价 → `GET /prices` 有 15min 价、费用切到动态。
|
||||||
|
5. **反哺 HA**:Expose 勾选 energy_cost 实体、开 Discovery、重发 → HA 出现当前买/卖价 + 累计成本/收入;HA Energy 挂累计实体。
|
||||||
|
|
||||||
|
## 12. 里程碑完成定义(DoD)
|
||||||
|
|
||||||
|
- 后端能:订 `dsmr/json` 按 10s 整帧落库;按 active 合同(manual 双费率 / tibber 动态)每 15min 用寄存器差 × 价算计量电费(不可变、快照);日/月/年汇总加固定费 − 抵扣;不做净计量;改价走版本、历史不回改。
|
||||||
|
- 复用 M5 Discovery 把当前买/卖价 + 累计成本/收入(`total_increasing`)发成 HA 实体。
|
||||||
|
- 前端 Energy 视图能管理合同(表单按 profile 渲染、激活、版本)、看价格曲线与费用走势/明细/汇总、测 Tibber。
|
||||||
|
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 入库。
|
||||||
|
- README / architecture / roadmap / design 索引反映 M6。
|
||||||
|
|
||||||
|
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
||||||
|
|
||||||
|
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
||||||
|
2. **卖价残差(tibber)**:`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。
|
||||||
|
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
||||||
|
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
||||||
|
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
||||||
|
6. **manual 现行数值**(你的固定合同):normal 0.133 / dal 0.127 / 回送两档(现同价)/ 管理费 ≈€0.329/天(≈€9.87/月)/ 电网费 + heffingskorting 待填。
|
||||||
|
</content>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# Tibber API + 荷兰电价 + DSMR Reader 参考
|
||||||
|
|
||||||
|
> 本文件汇总 M6(动态/固定电价 → 实时买卖电费计算)所需的全部外部事实,供 `docs/design/m6-tibber-dynamic-energy.md` 与实现者直接 refer。
|
||||||
|
> 来源:Tibber GraphQL introspection(实证)、Tibber Explorer demo 数据、Tibber NL support 文档、用户实际合同与 DSMR Reader 配置(2026-06 整理)。
|
||||||
|
> 凡标「✅ 实证」的为代码/数据验证过的事实;金额类以用户真实账单 / 合同生效后真实 token 为最终准(见末尾「待真实数据核对」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Tibber GraphQL API
|
||||||
|
|
||||||
|
- **Endpoint**:`POST https://api.tibber.com/v1-beta/gql`
|
||||||
|
- **认证**:HTTP header `Authorization: Bearer <token>`;token 在 [developer.tibber.com](https://developer.tibber.com) 登录后生成(每个 Tibber 账户一个)。
|
||||||
|
- **在线 Explorer**:[developer.tibber.com/explorer](https://developer.tibber.com/explorer),自带一个公共 **demo token**。
|
||||||
|
- ⚠️ **demo token 现在只能做 schema introspection 与历史 demo 数据查询**;对 `viewer` 的真实数据查询返回 `UNAUTHENTICATED`。要拿自己家的实时价,必须用**自己账户的 token**。
|
||||||
|
|
||||||
|
### 1.1 `Price` 类型(✅ introspection 实证)
|
||||||
|
|
||||||
|
`Price` 对象恰好 6 个字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `total` | Float | **全包价 = energy + tax**(即 App 里的 All-in Price,含 spot+能源税+VAT 等)|
|
||||||
|
| `energy` | Float | 纯能源/现货分量(ex-VAT 市场价)|
|
||||||
|
| `tax` | Float | 税费分量(能源税 + VAT 合并)|
|
||||||
|
| `startsAt` | String | 该价格段起点(ISO8601,**含时区偏移**,如 `+02:00`)|
|
||||||
|
| `currency` | String! | 币种(唯一 non-null 字段;NL 为 EUR,demo 为 SEK)|
|
||||||
|
| `level` | PriceLevel | 价格档枚举(`VERY_CHEAP/CHEAP/NORMAL/EXPENSIVE/VERY_EXPENSIVE`)|
|
||||||
|
|
||||||
|
**✅ 实证**:`total == energy + tax` 精确成立(见 §1.4 样本)。
|
||||||
|
|
||||||
|
### 1.2 15 分钟价格:`Subscription.priceInfoRange`(✅ introspection 实证)
|
||||||
|
|
||||||
|
- **字段**:`currentSubscription.priceInfoRange`
|
||||||
|
- **参数**:`resolution: PriceInfoRangeResolution!`(必填)、`first: Int`、`last: Int`、`before: String`、`after: String`(游标分页)
|
||||||
|
- **resolution 枚举 `PriceInfoRangeResolution`**:值 `DAILY` / `HOURLY` / `QUARTER_HOURLY`
|
||||||
|
- ⚠️ 注意别用错枚举:另有 `PriceResolution`(只有 HOURLY/DAILY,**无 QUARTER_HOURLY**)和 `PriceInfoResolution`——`priceInfoRange` 用的是 **`PriceInfoRangeResolution`**。
|
||||||
|
- **返回**:`SubscriptionPriceConnection { nodes: [Price]!, edges: [...], pageInfo }`
|
||||||
|
- **废弃说明**:老的 `priceInfo.range`(`deprecationReason: "use Subscription.priceInfoRange instead"`)**不支持 QUARTER_HOURLY**,必须改用 `Subscription.priceInfoRange`。
|
||||||
|
- **✅ 实证(demo key,2018 历史日)**:`QUARTER_HOURLY` 返回 **96 个节点、间隔精确 15 分钟**;无真 15 分钟价的历史日,API 把**小时价重复成 4 个相同刻钟**(00/15/30/45 同值,到下一整点才变);NL 当前数据则是真 15 分钟。
|
||||||
|
- **解析注意**:**不要假设固定 96 个节点 / 固定 15 分钟间隔**,按 `startsAt` 逐点存最稳。
|
||||||
|
|
||||||
|
**15 分钟查询(拉今天 96 个刻钟)**:
|
||||||
|
```graphql
|
||||||
|
{ viewer { homes { id currentSubscription {
|
||||||
|
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
||||||
|
nodes { startsAt total energy tax currency level }
|
||||||
|
} } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 小时价 / 当前价 / 探测:`priceInfo`
|
||||||
|
|
||||||
|
`currentSubscription.priceInfo` 含 `current`(当前价)、`today[]`、`tomorrow[]`(次日价约 13:00 CET 发布)。探测账户能力 + 当前价:
|
||||||
|
```graphql
|
||||||
|
{ viewer { homes {
|
||||||
|
id appNickname
|
||||||
|
features { realTimeConsumptionEnabled }
|
||||||
|
currentSubscription { priceInfo {
|
||||||
|
current { total energy tax startsAt level currency }
|
||||||
|
today { total energy tax startsAt level }
|
||||||
|
tomorrow { total energy tax startsAt level }
|
||||||
|
} } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 现成 curl + 样本响应
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 15 分钟价(换成自己账户 token;demo token 拿不到 viewer 真实数据)
|
||||||
|
curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
||||||
|
-H "Authorization: Bearer <YOUR_TOKEN>" -H "Content-Type: application/json" \
|
||||||
|
-d '{"query":"{ viewer { homes { id currentSubscription { priceInfoRange(resolution: QUARTER_HOURLY, first: 96) { nodes { startsAt total energy tax currency } } } } } }"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**样本:`priceInfo`(demo,瑞典家庭,SEK)**
|
||||||
|
```json
|
||||||
|
{"current": {"total": 0.8239, "energy": 0.5663, "tax": 0.2576,
|
||||||
|
"startsAt": "2026-06-23T14:00:00.000+02:00", "level": "NORMAL", "currency": "SEK"},
|
||||||
|
"today": [{"total": 1.276, "energy": 0.928, "tax": 0.348,
|
||||||
|
"startsAt": "2026-06-23T00:00:00.000+02:00", "level": "VERY_EXPENSIVE"}, … 24 项],
|
||||||
|
"tomorrow":[… 24 项]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**样本:`priceInfoRange` QUARTER_HOURLY(demo,2018-11-02,96 节点)**
|
||||||
|
```json
|
||||||
|
{"nodes": [
|
||||||
|
{"startsAt":"2018-11-02T00:00:00.000+01:00","total":0.63, "energy":0.435,"tax":0.195,"currency":"SEK"},
|
||||||
|
{"startsAt":"2018-11-02T00:15:00.000+01:00","total":0.63, "energy":0.435,"tax":0.195,"currency":"SEK"},
|
||||||
|
{"startsAt":"2018-11-02T00:30:00.000+01:00","total":0.63, "energy":0.435,"tax":0.195,"currency":"SEK"},
|
||||||
|
{"startsAt":"2018-11-02T00:45:00.000+01:00","total":0.63, "energy":0.435,"tax":0.195,"currency":"SEK"},
|
||||||
|
{"startsAt":"2018-11-02T01:00:00.000+01:00","total":0.6141,"energy":0.4223,"tax":0.1918,"currency":"SEK"},
|
||||||
|
… 共 96 个,末节点 23:45 total 0.6342]}
|
||||||
|
```
|
||||||
|
(注意 00:00–00:45 四个刻钟同值 = 小时价被重复;`total==energy+tax`。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 荷兰电价构成(Tibber NL + 用户合同)
|
||||||
|
|
||||||
|
> 所有金额**含 VAT(incl. btw)**,除非另注。VAT/BTW 标准税率 **21%**。
|
||||||
|
|
||||||
|
| 分量 | 荷兰语 | 单位 | 值 / 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 现货/市场价 | marktprijs / dynamische kwartierprijs | €/kWh | 每 15 分钟跟随交易所;= API `energy` |
|
||||||
|
| 买侧采购费 | **inkoopvergoeding** | €/kWh | **€0.0248**(覆盖 onbalans + 绿证;见下「相等」事实)|
|
||||||
|
| 卖侧上网费 | **verkoopvergoeding** | €/kWh | **€0.0248**(2026-01-01 起)|
|
||||||
|
| 能源税 | energiebelasting | €/kWh(含 VAT)| 2026 第一档(0–10000 kWh)≈ **€0.1108**;2025 ≈ €0.1228;高档更低 |
|
||||||
|
| 增值税 | BTW | % | **21%** |
|
||||||
|
| 固定月费 | vast bedrag / leveringskosten | €/月/合同 | Tibber **€5.99/月**(电、气各一次)|
|
||||||
|
| 电网维护费 | netbeheerkosten / systeembeheerkosten | €/天或/月 | 电网公司定、供应商代收;按地区,2026 约 +3.38% |
|
||||||
|
| 能源税抵扣 | heffingskorting / vermindering energiebelasting | €/年/连接 | 政府年度减免,从总费用扣 |
|
||||||
|
| ODE | Opslag Duurzame Energie | €/kWh | **现为 0**(已并入 energiebelasting)|
|
||||||
|
|
||||||
|
**✅ 关键事实(Tibber NL 文档原文)**:
|
||||||
|
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
||||||
|
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
||||||
|
|
||||||
|
→ **买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
||||||
|
|
||||||
|
### 3.1 回送价(净计量期内,文档原文)
|
||||||
|
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
||||||
|
|
||||||
|
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**(spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。
|
||||||
|
|
||||||
|
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
||||||
|
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
||||||
|
|
||||||
|
即**超额回送(或 2027 取消净计量后)**:盈余按 `beursprijs + inkoopvergoeding − verkoopvergoeding`(**无能源税**)计价 → 因两费抵消 → **= 纯 spot**。
|
||||||
|
|
||||||
|
### 3.3 净计量机制
|
||||||
|
- 法定**按自然年**结算;可抵扣到「用电量」为止(回送抵到用电为止)。
|
||||||
|
- Tibber 不用预付,**净计量周期在第 12 张账单后结束**。
|
||||||
|
- **2027 起荷兰取消净计量(saldering stopt in 2027)**——用户据此设计:**不再考虑净计量,直接按实时买卖算**。
|
||||||
|
|
||||||
|
### 3.4 负电价
|
||||||
|
- 负价时**用电**:理论上你拿钱(但仍计能源税,净计量期内税会退回)。
|
||||||
|
- 负价时**回送**:你要为回送的电付那个负价(= 倒贴)。
|
||||||
|
- 因 Tibber 给的是 all-in `total`(已含能源税),**只有 spot 负到比能源税还多,total 才转负**;中等负价时 total 仍正(照付)。
|
||||||
|
- **结论:负电价不需要特殊处理**——`buy=total` 与 `sell=total−能源税` 符号自洽。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 本项目采用的买/卖价公式(M6)
|
||||||
|
|
||||||
|
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
||||||
|
|
||||||
|
- **Tibber 动态合同**(post-2027 口径):
|
||||||
|
- 买价 `buy = price.total`
|
||||||
|
- 卖价 `sell = price.total − energy_tax_per_kwh − sell_adjust`(`sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里)
|
||||||
|
- **固定合同(manual,双费率)**:
|
||||||
|
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
||||||
|
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
||||||
|
- **固定费/抵扣不进每度**:`network_fee`、`management_fee`(月→天)、`heffingskorting`(年→天)在**日/月/年汇总**层加减。
|
||||||
|
- 详见 `docs/design/m6-tibber-dynamic-energy.md` §3.4。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 用户当前固定合同(manual 实例,2026-06)
|
||||||
|
|
||||||
|
> 双费率(NL:`_1`=dal/低谷、`_2`=normal/高峰)。以下为当前值,会随合同阶段/年度变(系统按"加新版本+生效日期"留底)。
|
||||||
|
|
||||||
|
| 项 | 当前值 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 能源价 normal(高) | €0.133/kWh | 买侧,含 VAT,不含能源税 |
|
||||||
|
| 能源价 dal(低) | €0.127/kWh | 买侧 |
|
||||||
|
| 回送价 normal | (现与 dal 同)| 卖侧,分档但现同价;系统仍分开填 |
|
||||||
|
| 回送价 dal | — | 卖侧 |
|
||||||
|
| 能源税 energiebelasting | 待填(≈€0.1108)| 含 VAT,加到买价 |
|
||||||
|
| ODE | 0 | 已并入能源税 |
|
||||||
|
| 电网维护费 | 待填 | 按天(合同按天收)|
|
||||||
|
| 供应商管理费 | ≈€0.329/天(≈€9.87/月)| 对应 Tibber 的 €5.99/月 |
|
||||||
|
| heffingskorting | 待填 | 年度减免,汇总时扣 |
|
||||||
|
| 回送阶梯罚金 terugleverkosten | **不做** | 按自然年累计、阶梯式;用户住不到年底算不准,M6 不实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DSMR Reader → MQTT
|
||||||
|
|
||||||
|
### 6.1 取数方式
|
||||||
|
- 用 **Telegram JSON**(单 topic **`dsmr/json`**,一条 = 一帧完整 telegram 的 JSON),**不用** split-topic(每字段一 topic、要按 id 拼)、**不用** raw(未解析 OBIS)。
|
||||||
|
- DSMR Reader **每秒一条**。本项目按 **10 秒降采样**(仅秒数整 10 落盘)、**整帧存 JSON blob**(不做字段 allowlist)。
|
||||||
|
|
||||||
|
### 6.2 telegram JSON 字段(DSMR Reader 配置的 JSON mapping)
|
||||||
|
```
|
||||||
|
id, timestamp,
|
||||||
|
electricity_delivered_1, electricity_delivered_2, # 进口累计 kWh(_1=dal 低谷, _2=normal 高峰)
|
||||||
|
electricity_returned_1, electricity_returned_2, # 出口/回送累计 kWh
|
||||||
|
electricity_currently_delivered, electricity_currently_returned, # 瞬时进/出口功率 kW
|
||||||
|
phase_currently_delivered_l1/l2/l3, phase_currently_returned_l1/l2/l3, # 各相瞬时功率 kW
|
||||||
|
phase_voltage_l1/l2/l3, # 各相电压 V
|
||||||
|
phase_power_current_l1/l2/l3, # 各相电流 A
|
||||||
|
extra_device_timestamp, extra_device_delivered # 燃气表(m³,每 5 分钟更新)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 实测样本(单相,`dsmr/json`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 200086230,
|
||||||
|
"timestamp": "2026-06-23T12:16:48Z",
|
||||||
|
"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",
|
||||||
|
"electricity_currently_returned": "2.704",
|
||||||
|
"phase_currently_delivered_l1": "0.000",
|
||||||
|
"phase_currently_delivered_l2": null,
|
||||||
|
"phase_currently_delivered_l3": null,
|
||||||
|
"extra_device_timestamp": "2026-06-23T12:15:00Z",
|
||||||
|
"extra_device_delivered": "6208.234",
|
||||||
|
"phase_currently_returned_l1": "2.704",
|
||||||
|
"phase_currently_returned_l2": null,
|
||||||
|
"phase_currently_returned_l3": null,
|
||||||
|
"phase_voltage_l1": "237.0",
|
||||||
|
"phase_voltage_l2": null,
|
||||||
|
"phase_voltage_l3": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 解析要点(实测确认)
|
||||||
|
- **数值都是 JSON 字符串**(`"20915.154"`、`"0.000"`)→ 计费读取转 **Decimal**(累计寄存器算钱要精度)。
|
||||||
|
- **缺测相位 = `null`**(不是缺 key)→ 视为缺测;单相只有 `*_l1`,三相后 `_l2/_l3` 由 null 变数字。
|
||||||
|
- **进口总量** = `electricity_delivered_1 + _2`;**出口总量** = `electricity_returned_1 + _2`(双费率档对动态合同无意义,求和;对固定合同分档计价)。
|
||||||
|
- **`timestamp` 为 UTC(Z)**;`id` 自增,做幂等去重。
|
||||||
|
- 历史样本里电压 key 曾被错配成带前缀 `dsmr/reading/phase_voltage_l1`(DSMR Reader 的 JSON mapping 配置笔误,用户已改对)——解析仍按"键名容错"。
|
||||||
|
- **整表寄存器与相数无关**:`delivered/returned_1/2` 是整表总量,三相只多了各相瞬时通道 → **计费逻辑相数无关**。
|
||||||
|
- 燃气 `extra_device_delivered`(m³)一并存入 blob;燃气计费 M6 不做(数据先留,未来按 commodity 扩展)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 来源 URL
|
||||||
|
|
||||||
|
- Tibber GraphQL reference / explorer:`https://developer.tibber.com/docs/reference#rootsubscription`、`https://developer.tibber.com/explorer`
|
||||||
|
- Tibber NL 费用构成:`https://support.tibber.com/nl/articles/5605892-de-kosten-bij-tibber`
|
||||||
|
- Tibber NL 净计量与回送:`https://support.tibber.com/nl/articles/4669873-salderen-en-terugleveren-bij-tibber`
|
||||||
|
- DSMR Reader(HA 集成):`https://www.home-assistant.io/integrations/dsmr_reader/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
||||||
|
|
||||||
|
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
||||||
|
2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0)。
|
||||||
|
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
||||||
|
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
||||||
|
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
||||||
|
</content>
|
||||||
+30
-4
@@ -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)。这些文档为 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)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
||||||
|
|
||||||
## 当前基线(v1.0.3)
|
## 当前基线(v1.0.3)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
|
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
|
||||||
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
||||||
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
||||||
| **M5** | IoT / 能耗采集 | Modbus/Energy + MQTT/HA Discovery + 前端侧边栏 |
|
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + 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 接入。
|
||||||
@@ -150,6 +150,32 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## M5 — IoT 集成与能耗采集(✅ 已完成)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
给后端接入家庭 IoT 生态,建立通用 Modbus 设备采集链路(首个领域:能耗),接入 MQTT + Home Assistant Discovery,并重构前端为侧边导航并新增 Energy 视图。
|
||||||
|
|
||||||
|
### 范围
|
||||||
|
|
||||||
|
- **两层数据模型(协议与部署分离)**:YAML profile(随代码走,声明协议知识:寄存器/解码/key/unit/ha_component)+ `modbus_device`(部署层 DB 行:friendly_name/host/port/unit_id/profile/poll_interval/enabled)+ `modbus_reading`(通用遥测:device_id + recorded_at + JSON payload)。多设备可共享同一 profile。
|
||||||
|
- **通用 Modbus-TCP 采集**:pymodbus,APScheduler 后台 job 轮询所有 enabled 设备;per-device `last_poll_at`/`last_poll_ok`;全局开关 `MODBUS_POLLING_ENABLED`(CONFIG_FIELDS)。首个 profile:SDM120 单相电表。
|
||||||
|
- **手工 CLI 试读**:`scripts/modbus_cli`(`read`/`probe` 两个只读子命令),供受控手工验证,不进自动化。
|
||||||
|
- **MQTT + HA Discovery**:paho-mqtt 长连接;通用 expose 框架(provider 动态产出可暴露实体目录,元数据从 YAML profile 派生);`exposed_entity_toggle` 表存逐 key 开关(默认不暴露);每设备 = 一个 HA device,各量 = sensor entity + online binary_sensor;`unique_id` 锚定于设备 `uuid`(稳定,不随改名变);配置变更可重连重发 discovery。
|
||||||
|
- **前端侧边栏**:`AppShell` 侧边导航(Home / Records / Energy / Config + 主题切换 + 注销),移动端可折叠,当前路由高亮。
|
||||||
|
- **Energy 视图**(`/energy`):设备 CRUD;最新读数卡片(标签/单位取自 profile metrics 端点);Recharts 走势图(时间范围 + limit)。
|
||||||
|
- **Config 页 Accordion**:各大 section 折叠/展开;「Home Assistant Expose」面板勾选可暴露实体 + 连接状态 + 重新发布按钮。
|
||||||
|
- **API**(`/api/modbus/*` + `/api/expose` + `/api/config/mqtt/test`):完整 CRUD + readings + metrics + test + expose 勾选 + 重发 discovery。
|
||||||
|
- `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿,`openapi/` 已入库。
|
||||||
|
|
||||||
|
### 命名决策(已锁定)
|
||||||
|
|
||||||
|
存储/采集/API 全部使用通用 `modbus_*`(`/api/modbus/devices`),**不锁死"电表"**;面向用户的领域呈现叫 **Energy**。接入新 Modbus 设备型号只需新增 YAML profile,无需改表/改 API。
|
||||||
|
|
||||||
|
> 详细设计与任务卡:[`docs/design/m5-iot-energy.md`](./design/m5-iot-energy.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## M3 — 开放与移动端(远期试水)
|
## M3 — 开放与移动端(远期试水)
|
||||||
|
|
||||||
### 目标
|
### 目标
|
||||||
@@ -169,13 +195,13 @@
|
|||||||
|
|
||||||
## 下一阶段:已确定要做(尚未拆解为任务卡)
|
## 下一阶段:已确定要做(尚未拆解为任务卡)
|
||||||
|
|
||||||
> 这些是 M4 之后**已经定下来要做**的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定**做什么、为什么**;具体排期、依赖与原子任务,等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**,具体排期等动手时再定。
|
> 这些是 M5 之后**已经定下来要做**的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定**做什么、为什么**;具体排期、依赖与原子任务,等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**,具体排期等动手时再定。
|
||||||
|
|
||||||
### 1. 前端优化
|
### 1. 前端优化
|
||||||
|
|
||||||
**动机**:M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项**确定要做,但具体优化什么还没定**。
|
**动机**:M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项**确定要做,但具体优化什么还没定**。
|
||||||
|
|
||||||
**范围(待定)**:方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。
|
**范围(待定)**:方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2/M5 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。
|
||||||
|
|
||||||
### 2. 设置页生成 Long-lived Token(供 API 调用)
|
### 2. 设置页生成 Long-lived Token(供 API 调用)
|
||||||
|
|
||||||
|
|||||||
Generated
+386
-3
@@ -22,7 +22,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -1446,6 +1447,42 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
|
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.3",
|
"version": "1.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||||
@@ -1855,7 +1892,12 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
@@ -2058,6 +2100,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -2131,6 +2236,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||||
@@ -3120,6 +3231,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -3213,6 +3445,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-equal": {
|
"node_modules/deep-equal": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
||||||
@@ -3565,6 +3803,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.48.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz",
|
||||||
|
"integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -3847,6 +4095,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -4312,6 +4566,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4377,6 +4641,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arguments": {
|
"node_modules/is-arguments": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
@@ -5712,7 +5985,6 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
@@ -5739,6 +6011,29 @@
|
|||||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -5867,6 +6162,36 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -5881,6 +6206,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5935,6 +6275,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -6458,6 +6804,12 @@
|
|||||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -6873,6 +7225,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
+41
-122
@@ -6,24 +6,20 @@
|
|||||||
*
|
*
|
||||||
* Route tree:
|
* Route tree:
|
||||||
* /login → LoginPage (public)
|
* /login → LoginPage (public)
|
||||||
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
|
* /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate)
|
||||||
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
* / → ProtectedRoute → AppLayout → HomePage
|
||||||
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
* /config → ProtectedRoute → AppLayout → ConfigPage
|
||||||
|
* /records → ProtectedRoute → AppLayout → RecordsPage
|
||||||
|
* /energy → ProtectedRoute → AppLayout → EnergyPage
|
||||||
*
|
*
|
||||||
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
|
* AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right.
|
||||||
|
* /login and /change-password have no sidebar layout.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
|
||||||
import {
|
import { MantineProvider, AppShell, Burger } from '@mantine/core'
|
||||||
MantineProvider,
|
|
||||||
Group,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
useMantineColorScheme,
|
|
||||||
useComputedColorScheme,
|
|
||||||
} from '@mantine/core'
|
|
||||||
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
|
||||||
|
|
||||||
// Mantine requires its CSS to be imported once.
|
// Mantine requires its CSS to be imported once.
|
||||||
import '@mantine/core/styles.css'
|
import '@mantine/core/styles.css'
|
||||||
@@ -34,9 +30,9 @@ import { LoginPage } from './pages/LoginPage'
|
|||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { ConfigPage } from './pages/ConfigPage'
|
import { ConfigPage } from './pages/ConfigPage'
|
||||||
import { RecordsPage } from './pages/RecordsPage'
|
import { RecordsPage } from './pages/RecordsPage'
|
||||||
|
import { EnergyPage } from './pages/EnergyPage'
|
||||||
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||||
import apiClient from './api/client'
|
import { AppSidebar } from './components/AppSidebar'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
||||||
@@ -57,120 +53,42 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Logout button component (needs navigate + queryClient hooks, so it's a component)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function LogoutButton() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
try {
|
|
||||||
await apiClient.POST('/api/auth/logout')
|
|
||||||
} catch {
|
|
||||||
// Ignore errors on logout — we clear the session regardless.
|
|
||||||
}
|
|
||||||
// Invalidate session so SessionProvider becomes unauthenticated.
|
|
||||||
await qc.invalidateQueries({ queryKey: ['session'] })
|
|
||||||
navigate('/login', { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label="Log out">
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleLogout}
|
|
||||||
aria-label="Log out"
|
|
||||||
data-testid="logout-button"
|
|
||||||
>
|
|
||||||
<LogOut size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Dark-mode toggle (sits next to the gear / settings icon)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function ColorSchemeToggle() {
|
|
||||||
const { setColorScheme } = useMantineColorScheme()
|
|
||||||
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
|
||||||
const isDark = computed === 'dark'
|
|
||||||
return (
|
|
||||||
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label="Toggle color scheme"
|
|
||||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
|
||||||
data-testid="color-scheme-toggle"
|
|
||||||
>
|
|
||||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App shell layout (used by all protected pages)
|
// App shell layout (used by all protected pages)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
// Mobile navbar open/close state — controlled here so the Burger and
|
||||||
|
// AppShell share the same state.
|
||||||
|
const [navbarOpen, setNavbarOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<AppShell
|
||||||
{/* Top nav */}
|
navbar={{
|
||||||
<nav
|
width: 220,
|
||||||
style={{
|
breakpoint: 'sm',
|
||||||
display: 'flex',
|
collapsed: { mobile: !navbarOpen },
|
||||||
alignItems: 'center',
|
}}
|
||||||
justifyContent: 'space-between',
|
header={{ height: { base: 48, sm: 0 } }}
|
||||||
padding: '0.5rem 1rem',
|
padding="md"
|
||||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
>
|
||||||
}}
|
{/* Burger shown on mobile only (hidden on sm+) */}
|
||||||
>
|
<AppShell.Header hiddenFrom="sm" p="xs">
|
||||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
<Burger
|
||||||
Home Automation
|
opened={navbarOpen}
|
||||||
</Link>
|
onClick={() => setNavbarOpen((o) => !o)}
|
||||||
|
size="sm"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
data-testid="nav-burger"
|
||||||
|
/>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
<Group gap="xs">
|
<AppSidebar onNavClick={() => setNavbarOpen(false)} />
|
||||||
{/* Records nav link */}
|
|
||||||
<Tooltip label="Records">
|
|
||||||
<ActionIcon
|
|
||||||
component={Link}
|
|
||||||
to="/records"
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label="Records"
|
|
||||||
>
|
|
||||||
<List size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
{/* Dark-mode toggle — directly beside the settings gear */}
|
|
||||||
<ColorSchemeToggle />
|
|
||||||
{/* Settings — links to config page (§5#10) */}
|
|
||||||
<Tooltip label="Settings">
|
|
||||||
<ActionIcon
|
|
||||||
component={Link}
|
|
||||||
to="/config"
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label="Settings"
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<LogoutButton />
|
|
||||||
</Group>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Page content */}
|
<AppShell.Main>
|
||||||
<main style={{ flex: 1 }}>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</AppShell.Main>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +116,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Protected routes — all nested under AppLayout */}
|
{/* Protected routes — all nested under AppLayout (sidebar) */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
@@ -209,6 +127,7 @@ export default function App() {
|
|||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="/config" element={<ConfigPage />} />
|
<Route path="/config" element={<ConfigPage />} />
|
||||||
<Route path="/records" element={<RecordsPage />} />
|
<Route path="/records" element={<RecordsPage />} />
|
||||||
|
<Route path="/energy" element={<EnergyPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
Vendored
+1045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AppSidebar (M5-T01, updated M5-T06).
|
||||||
|
*
|
||||||
|
* Strategy: render AppSidebar inside a minimal AppShell (required by
|
||||||
|
* AppShell.Navbar) + MemoryRouter so useLocation() works.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. All four nav items render (Home, Records, Energy, Config).
|
||||||
|
* 2. Home nav item is active when pathname is '/'.
|
||||||
|
* 3. Records nav item is active when pathname is '/records'.
|
||||||
|
* 4. Config nav item is active when pathname is '/config'.
|
||||||
|
* 5. Home is NOT active when on '/records'.
|
||||||
|
* 6. Energy nav item is active when on '/energy'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { MantineProvider, AppShell } from '@mantine/core'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { AppSidebar } from './AppSidebar'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient (LogoutButton calls POST /api/auth/logout)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
POST: vi.fn(),
|
||||||
|
GET: 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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: render AppSidebar inside the required AppShell + router providers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderSidebar(initialPath = '/') {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<AppShell
|
||||||
|
navbar={{ width: 220, breakpoint: 'sm', collapsed: { mobile: false } }}
|
||||||
|
header={{ height: { base: 48, sm: 0 } }}
|
||||||
|
>
|
||||||
|
<AppSidebar />
|
||||||
|
</AppShell>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AppSidebar', () => {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1. All nav items render
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders Home, Records, Energy, and Config nav items', () => {
|
||||||
|
renderSidebar('/')
|
||||||
|
expect(screen.getByTestId('nav-home')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('nav-records')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('nav-energy')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('nav-config')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Home active on '/'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Home nav item as active when on "/"', () => {
|
||||||
|
renderSidebar('/')
|
||||||
|
// Mantine NavLink sets data-active="true" on the active item
|
||||||
|
const homeLink = screen.getByTestId('nav-home')
|
||||||
|
expect(homeLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Records active on '/records'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Records nav item as active when on "/records"', () => {
|
||||||
|
renderSidebar('/records')
|
||||||
|
const recordsLink = screen.getByTestId('nav-records')
|
||||||
|
expect(recordsLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Config active on '/config'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Config nav item as active when on "/config"', () => {
|
||||||
|
renderSidebar('/config')
|
||||||
|
const configLink = screen.getByTestId('nav-config')
|
||||||
|
expect(configLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 5. Home NOT active on '/records' (exact match guard)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('does NOT mark Home as active when on "/records"', () => {
|
||||||
|
renderSidebar('/records')
|
||||||
|
const homeLink = screen.getByTestId('nav-home')
|
||||||
|
expect(homeLink).not.toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 6. Energy active on '/energy'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Energy nav item as active when on "/energy"', () => {
|
||||||
|
renderSidebar('/energy')
|
||||||
|
const energyLink = screen.getByTestId('nav-energy')
|
||||||
|
expect(energyLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* AppSidebar — vertical sidebar navigation for all protected pages.
|
||||||
|
*
|
||||||
|
* Nav items: Home / Records / Energy / Config
|
||||||
|
* Utilities: ColorSchemeToggle + LogoutButton (at bottom)
|
||||||
|
*
|
||||||
|
* Current route is highlighted via useLocation().
|
||||||
|
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Home, List, Settings, Sun, Moon, LogOut, Zap } from 'react-feather'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Individual nav entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NavEntry {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
testId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ENTRIES: NavEntry[] = [
|
||||||
|
{
|
||||||
|
to: '/',
|
||||||
|
label: 'Home',
|
||||||
|
icon: <Home size={18} />,
|
||||||
|
testId: 'nav-home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/records',
|
||||||
|
label: 'Records',
|
||||||
|
icon: <List size={18} />,
|
||||||
|
testId: 'nav-records',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/energy',
|
||||||
|
label: 'Energy',
|
||||||
|
icon: <Zap size={18} />,
|
||||||
|
testId: 'nav-energy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/config',
|
||||||
|
label: 'Config',
|
||||||
|
icon: <Settings size={18} />,
|
||||||
|
testId: 'nav-config',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colour-scheme toggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ColorSchemeToggle() {
|
||||||
|
const { setColorScheme } = useMantineColorScheme()
|
||||||
|
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
const isDark = computed === 'dark'
|
||||||
|
return (
|
||||||
|
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'} position="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||||
|
data-testid="color-scheme-toggle"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logout button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LogoutButton() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await apiClient.POST('/api/auth/logout')
|
||||||
|
} catch {
|
||||||
|
// Ignore errors — clear session regardless.
|
||||||
|
}
|
||||||
|
await qc.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label="Log out" position="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleLogout}
|
||||||
|
aria-label="Log out"
|
||||||
|
data-testid="logout-button"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AppSidebarProps {
|
||||||
|
/** Called when a nav link is clicked on mobile (to close the navbar). */
|
||||||
|
onNavClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({ onNavClick }: AppSidebarProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell.Navbar p="xs">
|
||||||
|
{/* App title */}
|
||||||
|
<AppShell.Section>
|
||||||
|
<Group px="xs" py="sm">
|
||||||
|
<Text fw={700} size="sm" component={Link} to="/" style={{ textDecoration: 'none' }}>
|
||||||
|
Home Automation
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
</AppShell.Section>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<AppShell.Section grow mt="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
|
{NAV_ENTRIES.map(({ to, label, icon, testId }) => {
|
||||||
|
// For the home route "/" we need an exact match; others prefix-match is fine.
|
||||||
|
const isActive =
|
||||||
|
to === '/'
|
||||||
|
? location.pathname === '/'
|
||||||
|
: location.pathname.startsWith(to)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
component={Link}
|
||||||
|
to={to}
|
||||||
|
label={label}
|
||||||
|
leftSection={icon}
|
||||||
|
active={isActive}
|
||||||
|
data-testid={testId}
|
||||||
|
onClick={onNavClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</AppShell.Section>
|
||||||
|
|
||||||
|
{/* Bottom utilities: theme toggle + logout */}
|
||||||
|
<AppShell.Section>
|
||||||
|
<Divider mb="xs" />
|
||||||
|
<Group gap="xs" px="xs" pb="xs">
|
||||||
|
<ColorSchemeToggle />
|
||||||
|
<LogoutButton />
|
||||||
|
</Group>
|
||||||
|
</AppShell.Section>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ExposeSettings (M5-T12).
|
||||||
|
*
|
||||||
|
* Strategy: vi.mock the apiClient so GET/PUT/POST responses are controlled
|
||||||
|
* without a real server or MQTT broker.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Loading state shows a loader.
|
||||||
|
* 2. Error state shows an error alert.
|
||||||
|
* 3. Empty catalog shows the empty-state message.
|
||||||
|
* 4. Catalog with entities: renders entity names, device groups, toggle switches.
|
||||||
|
* 5. MQTT status badges render correctly (configured/connected/discovery).
|
||||||
|
* 6. Toggling a switch calls PUT /api/expose with the correct key/bool.
|
||||||
|
* 7. Re-publish button calls POST /api/expose/republish.
|
||||||
|
* 8. Republish ok=true shows success alert; ok=false shows warning.
|
||||||
|
* 9. value_getter is never rendered (no crash from non-serialisable callable).
|
||||||
|
* 10. binary_sensor entities are shown in the catalog alongside sensors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { ExposeSettings } from './ExposeSettings'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MOCK_MQTT_STATUS_OFF = {
|
||||||
|
mqtt_configured: false,
|
||||||
|
mqtt_connected: false,
|
||||||
|
discovery_enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_MQTT_STATUS_ON = {
|
||||||
|
mqtt_configured: true,
|
||||||
|
mqtt_connected: true,
|
||||||
|
discovery_enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_CATALOG_EMPTY = {
|
||||||
|
catalog: [],
|
||||||
|
mqtt_status: MOCK_MQTT_STATUS_OFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_CATALOG_ONE_DEVICE = {
|
||||||
|
catalog: [
|
||||||
|
{
|
||||||
|
entity: {
|
||||||
|
key: 'modbus.abc.voltage',
|
||||||
|
component: 'sensor',
|
||||||
|
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||||
|
device_class: 'voltage',
|
||||||
|
unit: 'V',
|
||||||
|
name: 'Test Meter Voltage',
|
||||||
|
state_class: 'measurement',
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entity: {
|
||||||
|
key: 'modbus.abc.current',
|
||||||
|
component: 'sensor',
|
||||||
|
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||||
|
device_class: 'current',
|
||||||
|
unit: 'A',
|
||||||
|
name: 'Test Meter Current',
|
||||||
|
state_class: 'measurement',
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entity: {
|
||||||
|
key: 'modbus.abc.online',
|
||||||
|
component: 'binary_sensor',
|
||||||
|
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||||
|
device_class: 'connectivity',
|
||||||
|
unit: '',
|
||||||
|
name: 'Test Meter Online',
|
||||||
|
state_class: null,
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mqtt_status: MOCK_MQTT_STATUS_ON,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
PUT: (...args: unknown[]) => mockPut(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
},
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderExpose() {
|
||||||
|
return renderWithProviders(<ExposeSettings />, { initialPath: '/config' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ExposeSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1. Loading state
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows a loader while the catalog is loading', () => {
|
||||||
|
// Never resolves
|
||||||
|
mockGet.mockReturnValue(new Promise(() => {}))
|
||||||
|
renderExpose()
|
||||||
|
expect(screen.getByTestId('expose-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Error state
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows an error alert when the catalog request fails', async () => {
|
||||||
|
mockGet.mockRejectedValue(new Error('Network error'))
|
||||||
|
renderExpose()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('expose-load-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Empty catalog
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows empty-state message when catalog is empty', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
||||||
|
renderExpose()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('expose-empty')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Catalog with entities
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders entity names and device groups', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Device group heading
|
||||||
|
expect(screen.getByTestId('device-group-Test Meter')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Entity names
|
||||||
|
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Test Meter Current')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Test Meter Online')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders toggle switches for each entity', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// voltage is disabled (enabled=false in fixture)
|
||||||
|
const voltageSwitch = screen.getByTestId(
|
||||||
|
'entity-toggle-modbus.abc.voltage',
|
||||||
|
) as HTMLInputElement
|
||||||
|
expect(voltageSwitch.checked).toBe(false)
|
||||||
|
|
||||||
|
// current is enabled (enabled=true in fixture)
|
||||||
|
const currentSwitch = screen.getByTestId(
|
||||||
|
'entity-toggle-modbus.abc.current',
|
||||||
|
) as HTMLInputElement
|
||||||
|
expect(currentSwitch.checked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows binary_sensor entities alongside sensor entities', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('entity-row-modbus.abc.online')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// binary_sensor row contains "binary_sensor" text
|
||||||
|
const onlineRow = screen.getByTestId('entity-row-modbus.abc.online')
|
||||||
|
expect(onlineRow).toHaveTextContent('binary_sensor')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 5. MQTT status badges
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders MQTT status badges', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-status-badges')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('badge-mqtt-connected')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('badge-discovery-enabled')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Configured" when mqtt_configured is true', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Configured')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Not Configured" when mqtt_configured is false', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Not Configured')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 6. Toggle a switch → PUT /api/expose
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('calls PUT /api/expose when a toggle is switched', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
mockPut.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click the voltage toggle (currently off → turning on)
|
||||||
|
const voltageSwitch = screen.getByTestId('entity-toggle-modbus.abc.voltage')
|
||||||
|
fireEvent.click(voltageSwitch)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const putCall = mockPut.mock.calls[0]
|
||||||
|
// path arg
|
||||||
|
expect(putCall[0]).toBe('/api/expose')
|
||||||
|
// body
|
||||||
|
expect(putCall[1]?.body?.toggles).toMatchObject({ 'modbus.abc.voltage': true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 7. Republish button → POST /api/expose/republish
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('calls POST /api/expose/republish when republish button is clicked', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published.' } })
|
||||||
|
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('republish-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const postCall = mockPost.mock.calls[0]
|
||||||
|
expect(postCall[0]).toBe('/api/expose/republish')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 8. Republish ok=true → success alert; ok=false → warning
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows success alert when republish returns ok=true', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: true, message: 'HA Discovery re-published successfully.' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('republish-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('republish-success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('republish-warning')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows warning alert when republish returns ok=false', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: false, message: 'MQTT not connected.' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('republish-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('republish-warning')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('republish-success')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 9. No crash from non-serialisable value_getter (value_getter NOT rendered)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders without crashing even if value_getter were present (it is excluded by API)', async () => {
|
||||||
|
// The API never sends value_getter; this tests resilience of the schema
|
||||||
|
const catalogWithExtraField = {
|
||||||
|
...MOCK_CATALOG_ONE_DEVICE,
|
||||||
|
catalog: MOCK_CATALOG_ONE_DEVICE.catalog.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
entity: { ...entry.entity },
|
||||||
|
// No value_getter — confirm the component handles normal data
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
mockGet.mockResolvedValue({ data: catalogWithExtraField })
|
||||||
|
renderExpose()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
// No errors thrown, entities visible
|
||||||
|
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* ExposeSettings — Home Assistant Expose entity management (M5-T12).
|
||||||
|
*
|
||||||
|
* Behaviours:
|
||||||
|
* 1. Load: GET /api/expose → display catalog of exposable entities grouped by HA device,
|
||||||
|
* with per-entity toggle switches and MQTT/Discovery connection status badges.
|
||||||
|
* 2. Toggle: PUT /api/expose with the changed key → bool; triggers discovery re-publish.
|
||||||
|
* 3. Republish: POST /api/expose/republish → manually trigger HA Discovery re-publish.
|
||||||
|
*
|
||||||
|
* All requests go through the typed apiClient (CSRF injected automatically by
|
||||||
|
* csrfMiddleware for write operations).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { components } from '../api/schema.d.ts'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CatalogEntry = components['schemas']['CatalogEntrySchema']
|
||||||
|
type MqttStatus = components['schemas']['MqttStatusSchema']
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook: load expose catalog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useExposeCatalog() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['expose-catalog'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/expose')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook: toggle a single entity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useToggleEntity() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
|
||||||
|
await apiClient.PUT('/api/expose', {
|
||||||
|
body: { toggles: { [key]: enabled } },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook: republish discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useRepublish() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiClient.POST('/api/expose/republish')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MqttStatusBadges — connection status indicators
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function MqttStatusBadges({ status }: { status: MqttStatus }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="wrap" data-testid="mqtt-status-badges">
|
||||||
|
<Badge
|
||||||
|
color={status.mqtt_configured ? 'blue' : 'gray'}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="badge-mqtt-configured"
|
||||||
|
>
|
||||||
|
MQTT {status.mqtt_configured ? 'Configured' : 'Not Configured'}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
color={status.mqtt_connected ? 'green' : 'red'}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="badge-mqtt-connected"
|
||||||
|
>
|
||||||
|
{status.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
color={status.discovery_enabled ? 'teal' : 'gray'}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="badge-discovery-enabled"
|
||||||
|
>
|
||||||
|
Discovery {status.discovery_enabled ? 'On' : 'Off'}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EntityRow — one entity toggle row
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EntityRowProps {
|
||||||
|
entry: CatalogEntry
|
||||||
|
onToggle: (key: string, enabled: boolean) => void
|
||||||
|
isToggling: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntityRow({ entry, onToggle, isToggling }: EntityRowProps) {
|
||||||
|
const { entity, enabled } = entry
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" gap="sm" wrap="nowrap" data-testid={`entity-row-${entity.key}`}>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{entity.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{entity.component}
|
||||||
|
{entity.device_class ? ` · ${entity.device_class}` : ''}
|
||||||
|
{entity.unit ? ` · ${entity.unit}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onToggle(entity.key, e.currentTarget.checked)}
|
||||||
|
disabled={isToggling}
|
||||||
|
size="sm"
|
||||||
|
data-testid={`entity-toggle-${entity.key}`}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeviceGroup — entities grouped by device name
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeviceGroupProps {
|
||||||
|
deviceName: string
|
||||||
|
entries: CatalogEntry[]
|
||||||
|
onToggle: (key: string, enabled: boolean) => void
|
||||||
|
isToggling: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceGroup({ deviceName, entries, onToggle, isToggling }: DeviceGroupProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" data-testid={`device-group-${deviceName}`}>
|
||||||
|
<Text fw={600} size="sm" c="dimmed">
|
||||||
|
{deviceName}
|
||||||
|
</Text>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<EntityRow
|
||||||
|
key={entry.entity.key}
|
||||||
|
entry={entry}
|
||||||
|
onToggle={onToggle}
|
||||||
|
isToggling={isToggling}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RepublishButton — POST /api/expose/republish
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RepublishButton() {
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||||
|
const republishMutation = useRepublish()
|
||||||
|
|
||||||
|
async function handleRepublish() {
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const data = await republishMutation.mutateAsync()
|
||||||
|
if (data) {
|
||||||
|
setResult({ ok: data.ok, message: data.message })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setResult({ ok: false, message: 'Republish request failed.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRepublish}
|
||||||
|
loading={republishMutation.isPending}
|
||||||
|
data-testid="republish-button"
|
||||||
|
>
|
||||||
|
Re-publish Discovery
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{result?.ok && (
|
||||||
|
<Alert color="green" data-testid="republish-success">
|
||||||
|
{result.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{result !== null && !result.ok && (
|
||||||
|
<Alert color="orange" data-testid="republish-warning">
|
||||||
|
{result.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ExposeSettings — main exported component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ExposeSettings() {
|
||||||
|
const { data, isLoading, isError } = useExposeCatalog()
|
||||||
|
const toggleMutation = useToggleEntity()
|
||||||
|
|
||||||
|
function handleToggle(key: string, enabled: boolean) {
|
||||||
|
toggleMutation.mutate({ key, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center pt="md">
|
||||||
|
<Loader size="sm" data-testid="expose-loading" />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid="expose-load-error">
|
||||||
|
Failed to load expose settings. Please refresh the page.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive null-coerce in case the data shape is unexpected at runtime.
|
||||||
|
const catalog = data.catalog ?? []
|
||||||
|
const mqtt_status = data.mqtt_status ?? {
|
||||||
|
mqtt_configured: false,
|
||||||
|
mqtt_connected: false,
|
||||||
|
discovery_enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group entities by device name for display.
|
||||||
|
const deviceGroups = new Map<string, CatalogEntry[]>()
|
||||||
|
for (const entry of catalog) {
|
||||||
|
const deviceName = entry.entity.device.name
|
||||||
|
const existing = deviceGroups.get(deviceName)
|
||||||
|
if (existing) {
|
||||||
|
existing.push(entry)
|
||||||
|
} else {
|
||||||
|
deviceGroups.set(deviceName, [entry])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" data-testid="expose-settings">
|
||||||
|
{/* Status row */}
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||||
|
<MqttStatusBadges status={mqtt_status} />
|
||||||
|
<RepublishButton />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Entity catalog — empty state */}
|
||||||
|
{catalog.length === 0 && (
|
||||||
|
<Text c="dimmed" size="sm" data-testid="expose-empty">
|
||||||
|
No exposable entities found. Add a Modbus device in the Energy page to get started.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entity catalog — grouped by device */}
|
||||||
|
{Array.from(deviceGroups.entries()).map(([deviceName, entries]) => (
|
||||||
|
<DeviceGroup
|
||||||
|
key={deviceName}
|
||||||
|
deviceName={deviceName}
|
||||||
|
entries={entries}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
isToggling={toggleMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy/DeviceForm.tsx
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders empty form in create mode (title "New Device").
|
||||||
|
* 2. Renders pre-filled form in edit mode (title "Edit Device"), shows UUID.
|
||||||
|
* 3. Validation: missing friendly_name shows error.
|
||||||
|
* 4. Profile dropdown loads options from GET /api/modbus/profiles.
|
||||||
|
* 5. Submit in create mode calls POST /api/modbus/devices.
|
||||||
|
* 6. Submit in edit mode calls PATCH /api/modbus/devices/{uuid}.
|
||||||
|
* 7. Cancel button invokes onClose without submitting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { DeviceForm } from './DeviceForm'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEVICE = {
|
||||||
|
uuid: 'abc-123',
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: null,
|
||||||
|
last_poll_ok: null,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILES_RESP = {
|
||||||
|
profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupProfilesMock() {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESP })
|
||||||
|
}
|
||||||
|
// modbus-devices for invalidation
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCreateForm(onClose = vi.fn(), onSaved = vi.fn()) {
|
||||||
|
return renderWithProviders(<DeviceForm onClose={onClose} onSaved={onSaved} />, {
|
||||||
|
initialPath: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditForm(onClose = vi.fn(), onSaved = vi.fn()) {
|
||||||
|
return renderWithProviders(<DeviceForm device={DEVICE} onClose={onClose} onSaved={onSaved} />, {
|
||||||
|
initialPath: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DeviceForm — create mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupProfilesMock()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with title "New Device" and empty friendly-name input', async () => {
|
||||||
|
renderCreateForm()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal title
|
||||||
|
expect(screen.getByText('New Device')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// UUID display should NOT be present in create mode
|
||||||
|
expect(screen.queryByTestId('device-uuid-display')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads profile options from GET /api/modbus/profiles', async () => {
|
||||||
|
renderCreateForm()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Profile select should be rendered
|
||||||
|
expect(screen.getByTestId('device-profile')).toBeInTheDocument()
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when friendly name is empty', async () => {
|
||||||
|
renderCreateForm()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-form-error').textContent).toContain('Friendly name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit calls POST /api/modbus/devices with correct body (profile auto-selected)', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: DEVICE })
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
renderCreateForm(vi.fn(), onSaved)
|
||||||
|
|
||||||
|
// Wait for profiles to load; sdm120 is auto-selected as first profile.
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Fill form (profile auto-selected to 'sdm120')
|
||||||
|
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||||
|
target: { value: 'My Meter' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('device-host'), {
|
||||||
|
target: { value: '10.0.0.1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/modbus/devices',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
friendly_name: 'My Meter',
|
||||||
|
host: '10.0.0.1',
|
||||||
|
transport: 'tcp',
|
||||||
|
profile: 'sdm120',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel calls onClose without POST', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
renderCreateForm(onClose)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form-cancel')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-form-cancel'))
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DeviceForm — edit mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupProfilesMock()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with title "Edit Device" and pre-filled friendly-name', async () => {
|
||||||
|
renderEditForm()
|
||||||
|
|
||||||
|
// Wait for modal to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit Device')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Wait for form to appear (profiles must load first)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-uuid-display').textContent).toContain('abc-123')
|
||||||
|
|
||||||
|
// Friendly name input should be pre-filled
|
||||||
|
const input = screen.getByTestId('device-friendly-name') as HTMLInputElement
|
||||||
|
expect(input.value).toBe('SDM120 Main')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit calls PATCH /api/modbus/devices/{uuid}', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ data: DEVICE })
|
||||||
|
renderEditForm()
|
||||||
|
|
||||||
|
// Wait for profiles to load so the form is visible
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||||
|
target: { value: 'SDM120 Updated' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'abc-123' } },
|
||||||
|
body: expect.objectContaining({ friendly_name: 'SDM120 Updated' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* DeviceForm — create / edit a Modbus device.
|
||||||
|
*
|
||||||
|
* Used for both new-device creation (no `device` prop) and editing an
|
||||||
|
* existing device (pass `device` prop with current values).
|
||||||
|
*
|
||||||
|
* Profile list is fetched from GET /api/modbus/profiles and rendered as a
|
||||||
|
* Select dropdown. All other fields are inline inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useProfiles, useCreateDevice, useUpdateDevice } from './hooks'
|
||||||
|
import type { ModbusDevice, ModbusDeviceCreate, ModbusDeviceUpdate } from './hooks'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeviceFormProps {
|
||||||
|
/** When provided, the form is in edit mode; otherwise create mode. */
|
||||||
|
device?: ModbusDevice
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function DeviceForm({ device, onClose, onSaved }: DeviceFormProps) {
|
||||||
|
const isEdit = !!device
|
||||||
|
|
||||||
|
// Form state — initialise from device if editing.
|
||||||
|
const [friendlyName, setFriendlyName] = useState(device?.friendly_name ?? '')
|
||||||
|
const [host, setHost] = useState(device?.host ?? '')
|
||||||
|
const [port, setPort] = useState<number | string>(device?.port ?? 502)
|
||||||
|
const [unitId, setUnitId] = useState<number | string>(device?.unit_id ?? 1)
|
||||||
|
const [profile, setProfile] = useState<string | null>(device?.profile ?? null)
|
||||||
|
const [pollIntervalS, setPollIntervalS] = useState<number | string>(device?.poll_interval_s ?? 5)
|
||||||
|
const [enabled, setEnabled] = useState(device?.enabled ?? true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const profilesQuery = useProfiles()
|
||||||
|
const createMutation = useCreateDevice()
|
||||||
|
const updateMutation = useUpdateDevice()
|
||||||
|
|
||||||
|
const isBusy = createMutation.isPending || updateMutation.isPending
|
||||||
|
|
||||||
|
// Build select options from profiles response.
|
||||||
|
const profileOptions =
|
||||||
|
profilesQuery.data?.profiles.map((p) => ({
|
||||||
|
value: p.name,
|
||||||
|
label: `${p.name} — ${p.description}`,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
// In create mode, if no explicit selection has been made and profiles are loaded,
|
||||||
|
// derive a default from the first available profile. We avoid useEffect+setState
|
||||||
|
// (causes cascading renders) by computing the effective value here instead.
|
||||||
|
const effectiveProfile: string | null =
|
||||||
|
profile !== null
|
||||||
|
? profile
|
||||||
|
: !isEdit && profileOptions.length > 0
|
||||||
|
? profileOptions[0].value
|
||||||
|
: null
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (!friendlyName.trim()) return 'Friendly name is required.'
|
||||||
|
if (!host.trim()) return 'Host is required.'
|
||||||
|
const portNum = Number(port)
|
||||||
|
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||||
|
return 'Port must be an integer between 1 and 65535.'
|
||||||
|
const unitNum = Number(unitId)
|
||||||
|
if (!Number.isInteger(unitNum) || unitNum < 1 || unitNum > 247)
|
||||||
|
return 'Unit ID must be an integer between 1 and 247.'
|
||||||
|
if (!effectiveProfile) return 'Profile is required.'
|
||||||
|
const pollNum = Number(pollIntervalS)
|
||||||
|
if (!Number.isInteger(pollNum) || pollNum < 1)
|
||||||
|
return 'Poll interval must be a positive integer (seconds).'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Submit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const validationError = validate()
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && device) {
|
||||||
|
const body: ModbusDeviceUpdate = {
|
||||||
|
friendly_name: friendlyName.trim(),
|
||||||
|
host: host.trim(),
|
||||||
|
port: Number(port),
|
||||||
|
unit_id: Number(unitId),
|
||||||
|
profile: effectiveProfile!,
|
||||||
|
poll_interval_s: Number(pollIntervalS),
|
||||||
|
enabled,
|
||||||
|
}
|
||||||
|
await updateMutation.mutateAsync({ uuid: device.uuid, body })
|
||||||
|
} else {
|
||||||
|
const body: ModbusDeviceCreate = {
|
||||||
|
friendly_name: friendlyName.trim(),
|
||||||
|
transport: 'tcp',
|
||||||
|
host: host.trim(),
|
||||||
|
port: Number(port),
|
||||||
|
unit_id: Number(unitId),
|
||||||
|
profile: effectiveProfile!,
|
||||||
|
poll_interval_s: Number(pollIntervalS),
|
||||||
|
enabled,
|
||||||
|
}
|
||||||
|
await createMutation.mutateAsync(body)
|
||||||
|
}
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
setError('Failed to save device. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEdit ? 'Edit Device' : 'New Device'}
|
||||||
|
size="md"
|
||||||
|
data-testid="device-form-modal"
|
||||||
|
>
|
||||||
|
{profilesQuery.isLoading && (
|
||||||
|
<Center py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profilesQuery.isError && (
|
||||||
|
<Alert color="red" mb="sm" data-testid="profiles-load-error">
|
||||||
|
Failed to load profiles. Cannot create device.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!profilesQuery.isLoading && (
|
||||||
|
<form onSubmit={handleSubmit} data-testid="device-form">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{isEdit && (
|
||||||
|
<Text size="xs" c="dimmed" data-testid="device-uuid-display">
|
||||||
|
UUID: {device!.uuid}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Friendly name"
|
||||||
|
required
|
||||||
|
value={friendlyName}
|
||||||
|
onChange={(e) => setFriendlyName(e.currentTarget.value)}
|
||||||
|
data-testid="device-friendly-name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Host"
|
||||||
|
description="Gateway IP address"
|
||||||
|
required
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.currentTarget.value)}
|
||||||
|
data-testid="device-host"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Port"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={port}
|
||||||
|
onChange={(val) => setPort(val)}
|
||||||
|
data-testid="device-port"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Unit ID"
|
||||||
|
description="Modbus slave address (Meter ID on device panel)"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={247}
|
||||||
|
value={unitId}
|
||||||
|
onChange={(val) => setUnitId(val)}
|
||||||
|
data-testid="device-unit-id"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Profile"
|
||||||
|
description="YAML device profile"
|
||||||
|
required
|
||||||
|
data={profileOptions}
|
||||||
|
value={effectiveProfile}
|
||||||
|
onChange={setProfile}
|
||||||
|
data-testid="device-profile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Poll interval (seconds)"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
value={pollIntervalS}
|
||||||
|
onChange={(val) => setPollIntervalS(val)}
|
||||||
|
data-testid="device-poll-interval"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label="Enabled"
|
||||||
|
description="Whether this device is polled"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.currentTarget.checked)}
|
||||||
|
data-testid="device-enabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" data-testid="device-form-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose} data-testid="device-form-cancel">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isBusy} data-testid="device-form-submit">
|
||||||
|
{isEdit ? 'Save' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy/EnergyCharts.tsx
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders chart title with device name.
|
||||||
|
* 2. Shows loading state while readings/metrics are loading.
|
||||||
|
* 3. Shows error state when either query fails.
|
||||||
|
* 4. Shows empty state when no readings are in the time window.
|
||||||
|
* 5. Renders chart container when data is available.
|
||||||
|
* 6. Tolerates missing keys in payload (no crash when key absent).
|
||||||
|
* 7. Time-range segmented control is rendered.
|
||||||
|
*
|
||||||
|
* NOTE: Recharts itself is NOT mocked — we let it render (jsdom-compatible
|
||||||
|
* rendering), but we only assert on data-testid wrappers, not Recharts internals.
|
||||||
|
* Recharts SVG rendering may produce warnings in jsdom; these are expected and
|
||||||
|
* benign (jsdom lacks ResizeObserver / SVG layout).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { EnergyCharts } from './EnergyCharts'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const UUID = 'device-uuid-test'
|
||||||
|
const DEVICE_NAME = 'SDM120 Main'
|
||||||
|
|
||||||
|
const METRICS_RESP = {
|
||||||
|
profile: 'sdm120',
|
||||||
|
metrics: [
|
||||||
|
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
|
||||||
|
{ key: 'current', label: 'Current', unit: 'A', device_class: 'current' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const READING = {
|
||||||
|
recorded_at: '2026-06-22T10:00:00Z',
|
||||||
|
payload: { voltage: 230.2, current: 1.3 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const READINGS_RESP = {
|
||||||
|
items: [READING],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupDefaultMocks() {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
return Promise.resolve({ data: READINGS_RESP })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
return renderWithProviders(
|
||||||
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||||
|
{ initialPath: '/energy' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnergyCharts — rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders chart title with device name', async () => {
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-title-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId(`energy-charts-title-${UUID}`).textContent).toBe(DEVICE_NAME)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders time-range segmented control', async () => {
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-preset-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading state while data is being fetched', () => {
|
||||||
|
// Never resolve
|
||||||
|
mockGet.mockReturnValue(new Promise(() => {}))
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
expect(screen.getByTestId(`energy-charts-loading-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error state when readings query fails', async () => {
|
||||||
|
mockGet.mockRejectedValue(new Error('Network error'))
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-error-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no readings in time window', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-empty-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders chart container when data is available', async () => {
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyCharts — truncation notice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('shows truncation notice when readings count equals the limit cap (1000)', async () => {
|
||||||
|
// Build exactly 1000 synthetic readings to trigger the truncation hint.
|
||||||
|
const truncatedItems = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
recorded_at: new Date(Date.now() - (999 - i) * 5000).toISOString(),
|
||||||
|
payload: { voltage: 230 + i * 0.01 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
return Promise.resolve({ data: { items: truncatedItems } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId(`energy-charts-truncated-${UUID}`),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT show truncation notice when readings count is below limit', async () => {
|
||||||
|
// Default mock has exactly 1 item — well below 1000.
|
||||||
|
setupDefaultMocks()
|
||||||
|
renderChart()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId(`energy-charts-truncated-${UUID}`)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyCharts — payload key tolerance', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('does not crash when payload is missing a metric key', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
// payload is missing 'current' key
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should render without throwing
|
||||||
|
expect(() => renderChart()).not.toThrow()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not crash when payload is entirely empty', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: {} }] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => renderChart()).not.toThrow()
|
||||||
|
|
||||||
|
// All values null → empty state (no chart) or chart with no data points shown.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-refresh: rolling window (A2 — the core bug fix)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnergyCharts — auto-refresh rolling window', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EnergyCharts now passes spanMs (not fixed start/end) to useReadings.
|
||||||
|
* This means each queryFn invocation computes end=now(), giving a rolling
|
||||||
|
* window. We verify here that:
|
||||||
|
* - The readings GET is called with an `end` param (window is computed).
|
||||||
|
* - Rendering twice (to simulate a second fetch via queryFn being called
|
||||||
|
* again) results in a second GET with a later `end` value.
|
||||||
|
*
|
||||||
|
* We test this by manually invoking the apiClient.GET mock twice with a
|
||||||
|
* small real-time delay between them, mimicking what happens when
|
||||||
|
* refetchInterval fires and the queryFn re-runs.
|
||||||
|
*/
|
||||||
|
it('uses spanMs-based rolling window so each queryFn call gets a fresh end=now()', async () => {
|
||||||
|
const endTimestamps: string[] = []
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
const end = opts?.params?.query?.end
|
||||||
|
if (end) endTimestamps.push(end)
|
||||||
|
return Promise.resolve({ data: READINGS_RESP })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// First render: triggers initial fetch (end=now at T0).
|
||||||
|
const t0 = Date.now()
|
||||||
|
renderWithProviders(
|
||||||
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||||
|
{ initialPath: '/energy' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for the initial readings GET to fire.
|
||||||
|
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
||||||
|
|
||||||
|
// The recorded `end` must not be before the test started.
|
||||||
|
const firstEndMs = new Date(endTimestamps[0]).getTime()
|
||||||
|
expect(firstEndMs).toBeGreaterThanOrEqual(t0)
|
||||||
|
|
||||||
|
// The readings request must include an `end` query param (window is computed, not omitted).
|
||||||
|
expect(endTimestamps[0]).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not freeze the window on mount — window end advances across separate renders', async () => {
|
||||||
|
// We simulate two independent hook invocations (as if refetch fired).
|
||||||
|
// Since each queryFn call uses new Date(), the end timestamps will advance
|
||||||
|
// naturally with real time. We just need to verify the concept holds.
|
||||||
|
const endTimestamps: string[] = []
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
const end = opts?.params?.query?.end
|
||||||
|
if (end) endTimestamps.push(end)
|
||||||
|
return Promise.resolve({ data: READINGS_RESP })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { unmount } = renderWithProviders(
|
||||||
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||||
|
{ initialPath: '/energy' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
||||||
|
|
||||||
|
const firstEnd = new Date(endTimestamps[0]).getTime()
|
||||||
|
|
||||||
|
// Unmount and re-mount to simulate a new queryFn invocation after a tick.
|
||||||
|
unmount()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
endTimestamps.length = 0
|
||||||
|
|
||||||
|
// Small real-time delay to ensure new Date() will be >= first.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||||
|
return Promise.resolve({ data: METRICS_RESP })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||||
|
const end = opts?.params?.query?.end
|
||||||
|
if (end) endTimestamps.push(end)
|
||||||
|
return Promise.resolve({ data: READINGS_RESP })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||||
|
{ initialPath: '/energy' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
||||||
|
|
||||||
|
const secondEnd = new Date(endTimestamps[0]).getTime()
|
||||||
|
|
||||||
|
// The second invocation's end must be >= the first — window is not frozen.
|
||||||
|
expect(secondEnd).toBeGreaterThanOrEqual(firstEnd)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* EnergyCharts — self-contained Recharts wrapper for Modbus device trend charts.
|
||||||
|
*
|
||||||
|
* Design decisions (M5 decision 11):
|
||||||
|
* - Recharts is imported ONLY in this file (isolation, easy to swap later).
|
||||||
|
* - The component is fully self-contained: it owns its own data fetching via
|
||||||
|
* useReadings / useMetrics hooks and renders loading/error/empty states.
|
||||||
|
* - Time-range selection is internal; readings are always fetched with a window
|
||||||
|
* + limit cap — never a full-table pull.
|
||||||
|
* - Metric labels and units come from GET /metrics; missing keys in payload are
|
||||||
|
* tolerated (a null data point is emitted so the line simply has a gap).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recharts imports — keep ALL recharts imports inside this file.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Alert,
|
||||||
|
SegmentedControl,
|
||||||
|
Paper,
|
||||||
|
Title,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
} from '@mantine/core'
|
||||||
|
|
||||||
|
import { useReadings, useMetrics } from './hooks'
|
||||||
|
import type { MetricInfo, AutoRefreshOptions } from './hooks'
|
||||||
|
import { formatMetricValue } from './format'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Must match the limit passed to useReadings below. */
|
||||||
|
const CHART_READINGS_LIMIT = 1000
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EnergyChartsProps {
|
||||||
|
/** UUID of the Modbus device to chart. */
|
||||||
|
uuid: string
|
||||||
|
/** Friendly name for the chart title. */
|
||||||
|
deviceName: string
|
||||||
|
/** Auto-refresh options forwarded to useReadings. */
|
||||||
|
autoRefresh?: AutoRefreshOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Time-range presets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TimePreset {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
/** How many milliseconds of history to show. */
|
||||||
|
spanMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_PRESETS: TimePreset[] = [
|
||||||
|
{ label: '1 h', value: '1h', spanMs: 60 * 60 * 1000 },
|
||||||
|
{ label: '6 h', value: '6h', spanMs: 6 * 60 * 60 * 1000 },
|
||||||
|
{ label: '24 h', value: '24h', spanMs: 24 * 60 * 60 * 1000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_PRESET = '1h'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metric colour palette — cycles through a fixed set
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LINE_COLORS = [
|
||||||
|
'#4c9cdb',
|
||||||
|
'#f59f00',
|
||||||
|
'#51cf66',
|
||||||
|
'#f03e3e',
|
||||||
|
'#cc5de8',
|
||||||
|
'#20c997',
|
||||||
|
'#fd7e14',
|
||||||
|
'#74c0fc',
|
||||||
|
]
|
||||||
|
|
||||||
|
function lineColor(index: number): string {
|
||||||
|
return LINE_COLORS[index % LINE_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: format a recorded_at timestamp for the X-axis tick
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatTimeTick(isoString: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString)
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
} catch {
|
||||||
|
return isoString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: safe numeric read from payload — returns null if key absent/non-numeric
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function safePayloadValue(
|
||||||
|
payload: Record<string, unknown> | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): number | null {
|
||||||
|
if (!payload) return null
|
||||||
|
const raw = payload[key]
|
||||||
|
if (raw === null || raw === undefined) return null
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnergyCharts component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) {
|
||||||
|
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
||||||
|
|
||||||
|
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
||||||
|
|
||||||
|
// Pass spanMs rather than fixed start/end so that every refetchInterval-triggered
|
||||||
|
// queryFn call computes end=now() and rolls the window forward — new readings
|
||||||
|
// recorded after mount will appear without a manual page refresh.
|
||||||
|
// Switching presets changes spanMs → different queryKey → immediate re-fetch.
|
||||||
|
const readingsQuery = useReadings(
|
||||||
|
uuid,
|
||||||
|
{ spanMs: selectedPreset.spanMs, limit: CHART_READINGS_LIMIT },
|
||||||
|
autoRefresh,
|
||||||
|
)
|
||||||
|
const metricsQuery = useMetrics(uuid)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Derive chart data: [{recorded_at, voltage: 230.2, current: 1.3, ...}, ...]
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metricsData = metricsQuery.data?.metrics
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const metrics: MetricInfo[] = metricsData ?? []
|
||||||
|
const readings = readingsQuery.data?.items ?? []
|
||||||
|
return readings.map((row) => {
|
||||||
|
const point: Record<string, string | number | null> = {
|
||||||
|
recorded_at: row.recorded_at,
|
||||||
|
}
|
||||||
|
for (const m of metrics) {
|
||||||
|
// Tolerate missing keys — null produces a gap in the line, not a crash.
|
||||||
|
point[m.key] = safePayloadValue(row.payload as Record<string, unknown>, m.key)
|
||||||
|
}
|
||||||
|
return point
|
||||||
|
})
|
||||||
|
}, [readingsQuery.data, metricsData])
|
||||||
|
|
||||||
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// States: loading / error / empty
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const isLoading = readingsQuery.isLoading || metricsQuery.isLoading
|
||||||
|
const isError = readingsQuery.isError || metricsQuery.isError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the returned row count equals the limit cap, meaning the window
|
||||||
|
* contains more data than was fetched. The backend returns the most-recent N
|
||||||
|
* rows in this case, so the chart shows the latest segment — but a hint is
|
||||||
|
* shown so the user knows the full window is not displayed.
|
||||||
|
*/
|
||||||
|
const isTruncated =
|
||||||
|
!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
(readingsQuery.data?.items.length ?? 0) >= CHART_READINGS_LIMIT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" data-testid={`energy-charts-${uuid}`}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{/* Header row */}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Title order={4} data-testid={`energy-charts-title-${uuid}`}>
|
||||||
|
{deviceName}
|
||||||
|
</Title>
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={activePreset}
|
||||||
|
onChange={setActivePreset}
|
||||||
|
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
||||||
|
data-testid={`energy-charts-preset-${uuid}`}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<Group justify="center" py="lg" data-testid={`energy-charts-loading-${uuid}`}>
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading readings…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{!isLoading && isError && (
|
||||||
|
<Alert color="red" data-testid={`energy-charts-error-${uuid}`}>
|
||||||
|
Failed to load readings or metrics. Please try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty */}
|
||||||
|
{!isLoading && !isError && chartData.length === 0 && (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
ta="center"
|
||||||
|
py="lg"
|
||||||
|
data-testid={`energy-charts-empty-${uuid}`}
|
||||||
|
>
|
||||||
|
No readings in this time window.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Truncation notice — shown when window has more data than the fetch limit */}
|
||||||
|
{isTruncated && (
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
ta="right"
|
||||||
|
data-testid={`energy-charts-truncated-${uuid}`}
|
||||||
|
>
|
||||||
|
Showing the most recent {CHART_READINGS_LIMIT} readings; full long-range trend pending downsampling
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{!isLoading && !isError && chartData.length > 0 && (
|
||||||
|
<Box data-testid={`energy-charts-chart-${uuid}`}>
|
||||||
|
{/* Render one chart per metric for clarity (avoids mixed Y-axis units) */}
|
||||||
|
{metrics.map((metric, idx) => {
|
||||||
|
// Check if this metric has any non-null data points; skip if all null.
|
||||||
|
const hasData = chartData.some((pt) => pt[metric.key] !== null)
|
||||||
|
if (!hasData) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={metric.key} mb="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
{metric.label}
|
||||||
|
</Text>
|
||||||
|
{metric.unit && (
|
||||||
|
<Badge size="xs" variant="outline" color="gray">
|
||||||
|
{metric.unit}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<LineChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.4} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="recorded_at"
|
||||||
|
tickFormatter={formatTimeTick}
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
minTickGap={40}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
width={50}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
metric.unit ? `${v} ${metric.unit}` : String(v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(value) => {
|
||||||
|
const formatted = formatMetricValue(value, metric)
|
||||||
|
if (formatted === '—') return ['—', metric.label] as [string, string]
|
||||||
|
return [
|
||||||
|
`${formatted}${metric.unit ? ' ' + metric.unit : ''}`,
|
||||||
|
metric.label,
|
||||||
|
] as [string, string]
|
||||||
|
}}
|
||||||
|
labelFormatter={(label) => {
|
||||||
|
try {
|
||||||
|
return new Date(String(label)).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return String(label)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric.key}
|
||||||
|
name={metric.label}
|
||||||
|
stroke={lineColor(idx)}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy/format.ts — formatMetricValue helper.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. energy device_class → 3 decimal places.
|
||||||
|
* 2. non-energy device_class → 2 decimal places.
|
||||||
|
* 3. null value → "—" placeholder.
|
||||||
|
* 4. undefined value → "—" placeholder.
|
||||||
|
* 5. NaN value → "—" placeholder.
|
||||||
|
* 6. Missing / null metric metadata → default 2 decimal places.
|
||||||
|
* 7. Integer value still rendered with correct decimal places.
|
||||||
|
* 8. String numeric value is coerced and formatted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatMetricValue, VALUE_PLACEHOLDER } from './format'
|
||||||
|
import type { MetricInfo } from './hooks'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeMetric(overrides: Partial<MetricInfo> = {}): MetricInfo {
|
||||||
|
return {
|
||||||
|
key: 'voltage',
|
||||||
|
label: 'Voltage',
|
||||||
|
unit: 'V',
|
||||||
|
device_class: 'voltage',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const energyMetric = makeMetric({ key: 'import_energy', label: 'Import Energy', unit: 'kWh', device_class: 'energy' })
|
||||||
|
const voltageMetric = makeMetric()
|
||||||
|
const powerMetric = makeMetric({ key: 'active_power', label: 'Active Power', unit: 'W', device_class: 'power' })
|
||||||
|
const powerFactorMetric = makeMetric({ key: 'power_factor', label: 'Power Factor', unit: '', device_class: 'power_factor' })
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatMetricValue — energy device_class → 3 decimal places', () => {
|
||||||
|
it('formats kWh energy value to 3 decimal places', () => {
|
||||||
|
expect(formatMetricValue(123.4567, energyMetric)).toBe('123.457')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats a whole-number energy value to 3 decimal places', () => {
|
||||||
|
expect(formatMetricValue(123, energyMetric)).toBe('123.000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats zero energy value to 3 decimal places', () => {
|
||||||
|
expect(formatMetricValue(0, energyMetric)).toBe('0.000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMetricValue — non-energy device_class → 2 decimal places', () => {
|
||||||
|
it('formats voltage to 2 decimal places', () => {
|
||||||
|
expect(formatMetricValue(230.2567, voltageMetric)).toBe('230.26')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats power to 2 decimal places', () => {
|
||||||
|
expect(formatMetricValue(295.0, powerMetric)).toBe('295.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats power factor to 2 decimal places', () => {
|
||||||
|
expect(formatMetricValue(0.98, powerFactorMetric)).toBe('0.98')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats current to 2 decimal places', () => {
|
||||||
|
const currentMetric = makeMetric({ key: 'current', label: 'Current', unit: 'A', device_class: 'current' })
|
||||||
|
expect(formatMetricValue(1.3456, currentMetric)).toBe('1.35')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMetricValue — null/undefined/NaN → placeholder', () => {
|
||||||
|
it('returns placeholder for null', () => {
|
||||||
|
expect(formatMetricValue(null, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns placeholder for undefined', () => {
|
||||||
|
expect(formatMetricValue(undefined, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns placeholder for NaN', () => {
|
||||||
|
expect(formatMetricValue(NaN, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns placeholder for non-numeric string', () => {
|
||||||
|
expect(formatMetricValue('not-a-number', voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns placeholder for Infinity', () => {
|
||||||
|
expect(formatMetricValue(Infinity, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMetricValue — missing metric metadata → default 2 decimal places', () => {
|
||||||
|
it('falls back to 2 decimal places when metric is undefined', () => {
|
||||||
|
expect(formatMetricValue(230.2567)).toBe('230.26')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to 2 decimal places when metric is null', () => {
|
||||||
|
expect(formatMetricValue(230.2567, null)).toBe('230.26')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns placeholder for null value even without metric', () => {
|
||||||
|
expect(formatMetricValue(null)).toBe(VALUE_PLACEHOLDER)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMetricValue — numeric string coercion', () => {
|
||||||
|
it('coerces a numeric string to number and formats it', () => {
|
||||||
|
// API could theoretically return strings; we handle them gracefully.
|
||||||
|
expect(formatMetricValue('230.2567', voltageMetric)).toBe('230.26')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces a numeric string for energy metric → 3 decimals', () => {
|
||||||
|
expect(formatMetricValue('123.4567', energyMetric)).toBe('123.457')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* format.ts — shared metric value formatting helpers for the Energy view.
|
||||||
|
*
|
||||||
|
* Rules (from M5 polish requirements):
|
||||||
|
* - device_class === "energy" → 3 decimal places (kWh values need precision)
|
||||||
|
* - everything else → 2 decimal places
|
||||||
|
* - null / undefined / NaN → placeholder "—"
|
||||||
|
* - missing metric metadata → default 2 decimal places
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MetricInfo } from './hooks'
|
||||||
|
|
||||||
|
/** Placeholder shown when a value is unavailable. */
|
||||||
|
export const VALUE_PLACEHOLDER = '—'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a numeric metric value for display.
|
||||||
|
*
|
||||||
|
* @param value The raw value from the API payload (any type; non-numeric → "—").
|
||||||
|
* @param metric Optional metric metadata from /metrics. When absent, falls back
|
||||||
|
* to 2 decimal places.
|
||||||
|
* @returns A formatted string ready for display (e.g. "230.25" or "—").
|
||||||
|
*/
|
||||||
|
export function formatMetricValue(
|
||||||
|
value: unknown,
|
||||||
|
metric?: MetricInfo | null,
|
||||||
|
): string {
|
||||||
|
// Guard: null / undefined
|
||||||
|
if (value === null || value === undefined) return VALUE_PLACEHOLDER
|
||||||
|
|
||||||
|
const n = Number(value)
|
||||||
|
|
||||||
|
// Guard: NaN or non-finite
|
||||||
|
if (!Number.isFinite(n)) return VALUE_PLACEHOLDER
|
||||||
|
|
||||||
|
// Determine decimal places from device_class.
|
||||||
|
const decimals = metric?.device_class === 'energy' ? 3 : 2
|
||||||
|
|
||||||
|
return n.toFixed(decimals)
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy/hooks.ts — Modbus API wrappers.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. useDevices — calls GET /api/modbus/devices and returns data.
|
||||||
|
* 2. useProfiles — calls GET /api/modbus/profiles.
|
||||||
|
* 3. useCreateDevice — calls POST /api/modbus/devices with body.
|
||||||
|
* 4. useUpdateDevice — calls PATCH /api/modbus/devices/{uuid} with body.
|
||||||
|
* 5. useDeleteDevice — calls DELETE /api/modbus/devices/{uuid}.
|
||||||
|
* 6. useTestReadDevice — calls POST /api/modbus/devices/{uuid}/test (no body required).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
DELETE: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
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 DEVICE = {
|
||||||
|
uuid: 'test-uuid-1',
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: null,
|
||||||
|
last_poll_ok: null,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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('useDevices', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/devices and returns device list', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [DEVICE], total: 1 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useDevices } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useDevices(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices')
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
expect(result.current.data?.items[0].uuid).toBe('test-uuid-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProfiles', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/profiles and returns profiles', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { profiles: [{ name: 'sdm120', description: 'Eastron SDM120' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useProfiles } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useProfiles(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
|
||||||
|
expect(result.current.data?.profiles[0].name).toBe('sdm120')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCreateDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls POST /api/modbus/devices with the provided body', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: DEVICE })
|
||||||
|
// LIST query to satisfy invalidation
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useCreateDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useCreateDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices', { body })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useUpdateDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls PATCH /api/modbus/devices/{uuid} with uuid in path and body', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ data: DEVICE })
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useUpdateDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useUpdateDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({ uuid: 'test-uuid-1', body: { enabled: false } })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
body: { enabled: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDeleteDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls DELETE /api/modbus/devices/{uuid}', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: null })
|
||||||
|
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('test-uuid-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useTestReadDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls POST /api/modbus/devices/{uuid}/test with uuid in path', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: true, payload: { voltage: 230.2 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useTestReadDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useTestReadDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync('test-uuid-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/test', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useLatestReading', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/devices/{uuid}/latest with path param', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useLatestReading } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useLatestReading('test-uuid-1'), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/latest', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
})
|
||||||
|
expect(result.current.data?.found).toBe(true)
|
||||||
|
expect(result.current.data?.payload).toEqual({ voltage: 230.2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns found=false when device has no readings', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { found: false, recorded_at: null, payload: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useLatestReading } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useLatestReading('test-uuid-2'), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(result.current.data?.found).toBe(false)
|
||||||
|
expect(result.current.data?.payload).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useMetrics', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/devices/{uuid}/metrics with path param', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
profile: 'sdm120',
|
||||||
|
metrics: [{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useMetrics } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useMetrics('test-uuid-1'), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/metrics', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
})
|
||||||
|
expect(result.current.data?.metrics).toHaveLength(1)
|
||||||
|
expect(result.current.data?.metrics[0].key).toBe('voltage')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useLatestReading — auto-refresh option', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useLatestReading } = await import('./hooks')
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
// Query succeeds — refetchIntervalMs is wired through without error.
|
||||||
|
expect(result.current.data?.found).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works without options (no auto-refresh)', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { found: false, recorded_at: null, payload: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useLatestReading } = await import('./hooks')
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useLatestReading('test-uuid-2'),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(result.current.data?.found).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useReadings — auto-refresh option', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
// Query succeeds — refetchIntervalMs is wired through without error.
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces minimum refetchInterval of 2000ms', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
// Pass a very small value — should be clamped to 2000ms internally.
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
// No crash; the hook accepted the option and clamped it internally.
|
||||||
|
expect(result.current.isSuccess).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useReadings', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/devices/{uuid}/readings with window and limit', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
const params = { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500 }
|
||||||
|
const { result } = renderHook(() => useReadings('test-uuid-1', params), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', {
|
||||||
|
params: {
|
||||||
|
path: { uuid: 'test-uuid-1' },
|
||||||
|
query: {
|
||||||
|
start: '2026-06-22T09:00:00Z',
|
||||||
|
end: '2026-06-22T10:00:00Z',
|
||||||
|
limit: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps limit at READINGS_MAX_LIMIT (1000) even if caller requests more', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useReadings('test-uuid-1', { limit: 99999 }),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
// The query param limit should be capped at 1000.
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
query: expect.objectContaining({ limit: 1000 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits start/end from query params when not provided', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useReadings('test-uuid-1', {}),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
query: expect.not.objectContaining({ start: expect.anything() }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Rolling window: spanMs causes queryFn to compute end=now() on each call
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('when spanMs is provided, sends computed start/end (rolling window) instead of fixed params', async () => {
|
||||||
|
const tBefore = Date.now()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
const SPAN_MS = 60 * 60 * 1000 // 1 hour
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useReadings('test-uuid-1', { spanMs: SPAN_MS }),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const tAfter = Date.now()
|
||||||
|
|
||||||
|
// The GET should have been called with start and end computed from now().
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/api/modbus/devices/{uuid}/readings',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
query: expect.objectContaining({
|
||||||
|
end: expect.any(String),
|
||||||
|
start: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const call = mockGet.mock.calls[mockGet.mock.calls.length - 1]
|
||||||
|
const query = call[1].params.query as { start: string; end: string }
|
||||||
|
const endMs = new Date(query.end).getTime()
|
||||||
|
const startMs = new Date(query.start).getTime()
|
||||||
|
|
||||||
|
// end must be within the test window (tBefore..tAfter).
|
||||||
|
expect(endMs).toBeGreaterThanOrEqual(tBefore)
|
||||||
|
expect(endMs).toBeLessThanOrEqual(tAfter + 100) // small tolerance
|
||||||
|
|
||||||
|
// start must be approximately end - spanMs.
|
||||||
|
expect(endMs - startMs).toBeCloseTo(SPAN_MS, -2) // within 100ms
|
||||||
|
})
|
||||||
|
|
||||||
|
it('when spanMs is provided, queryKey uses spanMs (not absolute timestamps) for stable caching', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||||
|
|
||||||
|
const { Wrapper, qc } = makeWrapper()
|
||||||
|
const { useReadings } = await import('./hooks')
|
||||||
|
const SPAN_MS = 3600000 // 1 hour
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useReadings('test-uuid-1', { spanMs: SPAN_MS }),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
// The query cache should have a key containing spanMs, not an absolute timestamp.
|
||||||
|
const queries = qc.getQueryCache().findAll({
|
||||||
|
predicate: (q) => {
|
||||||
|
const key = q.queryKey as unknown[]
|
||||||
|
return key[0] === 'modbus-readings' && key[1] === 'test-uuid-1'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(queries).toHaveLength(1)
|
||||||
|
const keyParams = queries[0].queryKey[2] as Record<string, unknown>
|
||||||
|
expect(keyParams).toHaveProperty('spanMs', SPAN_MS)
|
||||||
|
expect(keyParams).not.toHaveProperty('start')
|
||||||
|
expect(keyParams).not.toHaveProperty('end')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Energy / Modbus hooks — typed TanStack Query wrappers for /api/modbus/*.
|
||||||
|
*
|
||||||
|
* All write operations go through the typed apiClient (openapi-fetch), which
|
||||||
|
* injects CSRF via the csrfMiddleware already wired into the client.
|
||||||
|
*
|
||||||
|
* Query-key conventions:
|
||||||
|
* ['modbus-devices'] — device list
|
||||||
|
* ['modbus-device', uuid] — single device
|
||||||
|
* ['modbus-profiles'] — profile list (rarely changes)
|
||||||
|
* ['modbus-latest', uuid] — latest reading per device
|
||||||
|
* ['modbus-metrics', uuid] — profile metric metadata per device
|
||||||
|
* ['modbus-readings', uuid, params] — time-range readings per device
|
||||||
|
*
|
||||||
|
* On success, mutations invalidate the device list so the UI refreshes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { components } from '../api/schema.d.ts'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Re-exported types for consumers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ModbusDevice = components['schemas']['ModbusDeviceResponse']
|
||||||
|
export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate']
|
||||||
|
export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate']
|
||||||
|
export type ProfileSummary = components['schemas']['ProfileSummary']
|
||||||
|
export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse']
|
||||||
|
export type ModbusLatestResponse = components['schemas']['ModbusLatestResponse']
|
||||||
|
export type ModbusMetricsResponse = components['schemas']['ModbusMetricsResponse']
|
||||||
|
export type MetricInfo = components['schemas']['MetricInfo']
|
||||||
|
export type ModbusReadingResponse = components['schemas']['ModbusReadingResponse']
|
||||||
|
export type ModbusReadingsResponse = components['schemas']['ModbusReadingsResponse']
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reading query params
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ReadingsQueryParams {
|
||||||
|
/**
|
||||||
|
* How many milliseconds of history to show. When provided the queryFn
|
||||||
|
* computes end=now() and start=now()-spanMs on every invocation, so
|
||||||
|
* refetchInterval-triggered re-fetches always use a rolling window.
|
||||||
|
* Mutually exclusive with `start`/`end`.
|
||||||
|
*/
|
||||||
|
spanMs?: number
|
||||||
|
start?: string | null
|
||||||
|
end?: string | null
|
||||||
|
/** Capped server-side; default max is 1000 to avoid pulling full history. */
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: list all devices
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['modbus-devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/modbus/devices')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: list available profiles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useProfiles() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['modbus-profiles'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/modbus/profiles')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
// Profiles are static — 5 min stale time.
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: create device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useCreateDevice() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ModbusDeviceCreate) =>
|
||||||
|
apiClient.POST('/api/modbus/devices', { body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: update (PATCH) device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useUpdateDevice() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ uuid, body }: { uuid: string; body: ModbusDeviceUpdate }) =>
|
||||||
|
apiClient.PATCH('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: delete device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useDeleteDevice() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (uuid: string) =>
|
||||||
|
apiClient.DELETE('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: test-read device (POST /devices/{uuid}/test, does NOT persist)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useTestReadDevice() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (uuid: string) =>
|
||||||
|
apiClient.POST('/api/modbus/devices/{uuid}/test', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query options shared by auto-refresh–capable queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AutoRefreshOptions {
|
||||||
|
/**
|
||||||
|
* When provided, TanStack Query will automatically re-fetch this query at
|
||||||
|
* this interval (milliseconds). Pass `undefined` to disable auto-refresh.
|
||||||
|
* Minimum enforced value: 2 000 ms.
|
||||||
|
*/
|
||||||
|
refetchIntervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: latest reading for a device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useLatestReading(uuid: string, options?: AutoRefreshOptions) {
|
||||||
|
const refetchInterval = options?.refetchIntervalMs != null
|
||||||
|
? Math.max(2_000, options.refetchIntervalMs)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['modbus-latest', uuid],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/modbus/devices/{uuid}/latest', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
refetchInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: profile metric metadata for a device (label/unit per key)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useMetrics(uuid: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['modbus-metrics', uuid],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/modbus/devices/{uuid}/metrics', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
// Metrics are static (tied to profile version); 5 min stale time.
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: time-range readings for a device (window + limit — never full-table)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
|
||||||
|
const READINGS_MAX_LIMIT = 1000
|
||||||
|
|
||||||
|
export function useReadings(
|
||||||
|
uuid: string,
|
||||||
|
params: ReadingsQueryParams,
|
||||||
|
options?: AutoRefreshOptions,
|
||||||
|
) {
|
||||||
|
const { spanMs, start, end, limit } = params
|
||||||
|
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
|
||||||
|
|
||||||
|
const refetchInterval = options?.refetchIntervalMs != null
|
||||||
|
? Math.max(2_000, options.refetchIntervalMs)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// When spanMs is provided, the query key uses the stable span value (not
|
||||||
|
// absolute timestamps), so switching presets triggers a fresh fetch while
|
||||||
|
// refetchInterval-triggered re-fetches reuse the cached key and run the
|
||||||
|
// queryFn again — computing end=now() each time, giving a rolling window.
|
||||||
|
const queryKey = spanMs != null
|
||||||
|
? ['modbus-readings', uuid, { spanMs, limit: effectiveLimit }]
|
||||||
|
: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }]
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
let resolvedStart = start
|
||||||
|
let resolvedEnd = end
|
||||||
|
|
||||||
|
if (spanMs != null) {
|
||||||
|
const now = new Date()
|
||||||
|
resolvedEnd = now.toISOString()
|
||||||
|
resolvedStart = new Date(now.getTime() - spanMs).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', {
|
||||||
|
params: {
|
||||||
|
path: { uuid },
|
||||||
|
query: {
|
||||||
|
...(resolvedStart ? { start: resolvedStart } : {}),
|
||||||
|
...(resolvedEnd ? { end: resolvedEnd } : {}),
|
||||||
|
limit: effectiveLimit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
// Enabled only when we have valid uuid; start/end may be null (full window).
|
||||||
|
enabled: !!uuid,
|
||||||
|
refetchInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for ConfigPage (M2-T08).
|
* Tests for ConfigPage (M2-T08, M5-T01B).
|
||||||
*
|
*
|
||||||
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
|
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
|
||||||
* without a real server.
|
* without a real server.
|
||||||
@@ -15,9 +15,11 @@
|
|||||||
* 8. SMTP test button: success state (200 result=success).
|
* 8. SMTP test button: success state (200 result=success).
|
||||||
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
|
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
|
||||||
* 10. SMTP test button: failed state (502/ApiError result=failed).
|
* 10. SMTP test button: failed state (502/ApiError result=failed).
|
||||||
|
* 11. (M5-T01B) Accordion: sections rendered as accordion items.
|
||||||
|
* 12. (M5-T01B) Accordion: second section can be expanded by clicking its control.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
import { ConfigPage } from './ConfigPage'
|
import { ConfigPage } from './ConfigPage'
|
||||||
@@ -45,6 +47,26 @@ const MOCK_CONFIG = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extended fixture that includes a checkbox (bool) field. */
|
||||||
|
const MOCK_CONFIG_WITH_CHECKBOX = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
name: 'MQTT',
|
||||||
|
fields: [
|
||||||
|
{ env_name: 'MQTT_ENABLED', label: 'MQTT Enabled', value: 'false', secret: false, input_type: 'checkbox', configured: true },
|
||||||
|
{ env_name: 'MQTT_BROKER_HOST', label: 'MQTT Broker Host', value: 'localhost', secret: false, input_type: 'text', configured: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SMTP',
|
||||||
|
fields: [
|
||||||
|
{ env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true },
|
||||||
|
{ env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock apiClient
|
// Mock apiClient
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -334,4 +356,301 @@ describe('ConfigPage', () => {
|
|||||||
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 11. (M5-T01B) Accordion: sections rendered as accordion items
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders an accordion with one item per config section', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('config-accordion')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each section should have an accordion control
|
||||||
|
expect(screen.getByTestId('accordion-control-General')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('accordion-control-SMTP')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 12. (M5-T01B) Accordion: second section can be expanded by clicking its control
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('expands a collapsed section when its accordion control is clicked', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('accordion-control-SMTP')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The SMTP section control is clickable (the accordion item exists)
|
||||||
|
const smtpControl = screen.getByTestId('accordion-control-SMTP')
|
||||||
|
expect(smtpControl).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click to expand the SMTP section
|
||||||
|
fireEvent.click(smtpControl)
|
||||||
|
|
||||||
|
// After clicking, the SMTP section fields should still be in the DOM
|
||||||
|
// (Mantine accordion keeps content in DOM even when collapsed)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// M5-fix2: MqttTestButton — Issue 1 (frontend)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ConfigPage — MQTT test button', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Use the MOCK_CONFIG_WITH_CHECKBOX fixture which includes an MQTT section
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG_WITH_CHECKBOX, response: { status: 200, ok: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders MQTT test button when MQTT section is present', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render MQTT test button when no MQTT section present', async () => {
|
||||||
|
// Use a config fixture without MQTT section
|
||||||
|
mockGet.mockResolvedValueOnce({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// No MQTT section in MOCK_CONFIG → button absent
|
||||||
|
// Wait a bit for any async renders
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('mqtt-test-button')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success alert after MQTT test succeeds', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: { result: 'success', message: 'Test message published.' },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('mqtt-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-result-success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('mqtt-result-config-error')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('mqtt-result-failed')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows config-error alert when MQTT test returns config-error', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPost.mockRejectedValueOnce(
|
||||||
|
new ApiError(400, { result: 'config-error', message: 'Broker host not configured.' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('mqtt-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-result-config-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('mqtt-result-success')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('mqtt-result-failed')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failed alert when MQTT test returns failed (502)', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPost.mockRejectedValueOnce(
|
||||||
|
new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('mqtt-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-result-failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('mqtt-result-success')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('mqtt-result-config-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MQTT test button has type="button" and does not submit the config form', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mqttBtn = screen.getByTestId('mqtt-test-button')
|
||||||
|
// The rendered button element should have type="button"
|
||||||
|
expect(mqttBtn.getAttribute('type')).toBe('button')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// M5-fix2: ExposeSettings in config accordion — Issue 3 (frontend)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ConfigPage — ExposeSettings in main accordion', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Home Assistant Expose accordion item inside the config accordion', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('config-accordion')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The Expose accordion item should be inside the main config-accordion
|
||||||
|
const configAccordion = screen.getByTestId('config-accordion')
|
||||||
|
const exposeControl = screen.getByTestId('accordion-control-expose')
|
||||||
|
expect(configAccordion.contains(exposeControl)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Expose accordion item appears before the Save button in DOM order', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('accordion-control-expose')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const exposeControl = screen.getByTestId('accordion-control-expose')
|
||||||
|
const saveButton = screen.getByTestId('config-save-button')
|
||||||
|
|
||||||
|
// compareDocumentPosition: if expose comes before save, position & Node.DOCUMENT_POSITION_FOLLOWING === true
|
||||||
|
const position = exposeControl.compareDocumentPosition(saveButton)
|
||||||
|
// DOCUMENT_POSITION_FOLLOWING = 4 means saveButton comes after exposeControl
|
||||||
|
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Area B: checkbox (Switch) rendering and value round-trip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ConfigPage — checkbox (bool) fields', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG_WITH_CHECKBOX, response: { status: 200, ok: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 13. Checkbox field renders as a Switch (checked=false when value='false')
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders a checkbox field as a Switch with checked=false when value is "false"', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('field-MQTT_ENABLED')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchEl = screen.getByTestId('field-MQTT_ENABLED') as HTMLInputElement
|
||||||
|
// Mantine Switch renders as <input type="checkbox">
|
||||||
|
expect(switchEl.type).toBe('checkbox')
|
||||||
|
expect(switchEl.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 14. Toggling Switch to ON produces "true" in the save payload
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('toggling Switch to checked sends "true" for that field in buildUpdates', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG_WITH_CHECKBOX, response: { status: 200, ok: true } })
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('field-MQTT_ENABLED')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle the switch ON
|
||||||
|
const switchEl = screen.getByTestId('field-MQTT_ENABLED') as HTMLInputElement
|
||||||
|
fireEvent.click(switchEl)
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
fireEvent.submit(screen.getByTestId('config-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const putCall = mockPut.mock.calls[0]
|
||||||
|
const body = putCall[1].body as { updates: Record<string, string> }
|
||||||
|
const updates = body.updates
|
||||||
|
|
||||||
|
// The bool field must be sent as the string "true"
|
||||||
|
expect(updates).toHaveProperty('MQTT_ENABLED', 'true')
|
||||||
|
// Other non-secret fields still included
|
||||||
|
expect(updates).toHaveProperty('MQTT_BROKER_HOST', 'localhost')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 15. Switch starts checked=true when initial value is "true"
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders Switch as checked when initial value is "true"', async () => {
|
||||||
|
const configWithEnabled = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
name: 'MQTT',
|
||||||
|
fields: [
|
||||||
|
{ env_name: 'MQTT_ENABLED', label: 'MQTT Enabled', value: 'true', secret: false, input_type: 'checkbox', configured: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mockGet.mockResolvedValue({ data: configWithEnabled, response: { status: 200, ok: true } })
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('field-MQTT_ENABLED')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchEl = screen.getByTestId('field-MQTT_ENABLED') as HTMLInputElement
|
||||||
|
expect(switchEl.checked).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ConfigPage — config editor (M2-T08).
|
* ConfigPage — config editor (M2-T08, M5-T01B).
|
||||||
*
|
*
|
||||||
* Behaviours:
|
* Behaviours:
|
||||||
* 1. Load config: GET /api/config → render sections (grouped) with Mantine inputs.
|
* 1. Load config: GET /api/config → render sections as Accordion items (M5-T01B).
|
||||||
* - Non-secret fields show their value.
|
* - Non-secret fields show their value.
|
||||||
* - Secret fields render as empty PasswordInput (never show a masked value).
|
* - Secret fields render as empty PasswordInput (never show a masked value).
|
||||||
* 2. Save config: PUT /api/config with full-field submission semantics.
|
* 2. Save config: PUT /api/config with full-field submission semantics.
|
||||||
@@ -13,11 +13,16 @@
|
|||||||
* 3. SMTP test button: POST /api/config/smtp/test.
|
* 3. SMTP test button: POST /api/config/smtp/test.
|
||||||
* - Tri-state: success / config-error / failed.
|
* - Tri-state: success / config-error / failed.
|
||||||
* - Errors read `err.body.result` from ApiError.
|
* - Errors read `err.body.result` from ApiError.
|
||||||
|
*
|
||||||
|
* Layout (M5-T01B): each config section = one Accordion.Item. The first section
|
||||||
|
* is open by default. Future panels (e.g. T12 Home Assistant Expose) should be
|
||||||
|
* appended as additional Accordion.Items after the config sections.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -30,11 +35,12 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Paper,
|
|
||||||
Badge,
|
Badge,
|
||||||
|
Switch,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import apiClient, { ApiError } from '../api/client'
|
import apiClient, { ApiError } from '../api/client'
|
||||||
import type { components } from '../api/schema.d.ts'
|
import type { components } from '../api/schema.d.ts'
|
||||||
|
import { ExposeSettings } from '../components/ExposeSettings'
|
||||||
import { TotpSettings } from './TotpSettings'
|
import { TotpSettings } from './TotpSettings'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -51,6 +57,13 @@ type SmtpResult =
|
|||||||
| { kind: 'failed'; message: string }
|
| { kind: 'failed'; message: string }
|
||||||
| null
|
| null
|
||||||
|
|
||||||
|
/** MQTT test result tri-state. */
|
||||||
|
type MqttResult =
|
||||||
|
| { kind: 'success'; message: string }
|
||||||
|
| { kind: 'config-error'; message: string }
|
||||||
|
| { kind: 'failed'; message: string }
|
||||||
|
| null
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hook: load config
|
// Hook: load config
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -129,6 +142,17 @@ function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.input_type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
label={field.label}
|
||||||
|
checked={(value ?? '').toLowerCase() === 'true'}
|
||||||
|
onChange={(e) => onChange(field.env_name, e.currentTarget.checked ? 'true' : 'false')}
|
||||||
|
data-testid={`field-${field.env_name}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (field.input_type === 'number') {
|
if (field.input_type === 'number') {
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -153,32 +177,27 @@ function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ConfigSectionPanel — one section
|
// ConfigSectionFields — the fields inside one accordion panel
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ConfigSectionPanelProps {
|
interface ConfigSectionFieldsProps {
|
||||||
section: ConfigSection
|
section: ConfigSection
|
||||||
localValues: Record<string, string>
|
localValues: Record<string, string>
|
||||||
onChange: (envName: string, value: string) => void
|
onChange: (envName: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
|
function ConfigSectionFields({ section, localValues, onChange }: ConfigSectionFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Stack gap="sm">
|
||||||
<Title order={4} mb="md">
|
{section.fields.map((field) => (
|
||||||
{section.name}
|
<ConfigFieldInput
|
||||||
</Title>
|
key={field.env_name}
|
||||||
<Stack gap="sm">
|
field={field}
|
||||||
{section.fields.map((field) => (
|
value={localValues[field.env_name] ?? ''}
|
||||||
<ConfigFieldInput
|
onChange={onChange}
|
||||||
key={field.env_name}
|
/>
|
||||||
field={field}
|
))}
|
||||||
value={localValues[field.env_name] ?? ''}
|
</Stack>
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +270,75 @@ function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MqttTestButton — sends POST /api/config/mqtt/test and displays tri-state result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MqttTestButtonProps {
|
||||||
|
mqttResult: MqttResult
|
||||||
|
setMqttResult: (r: MqttResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MqttTestButton({ mqttResult, setMqttResult }: MqttTestButtonProps) {
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setMqttResult(null)
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.POST('/api/config/mqtt/test')
|
||||||
|
if (res.data) {
|
||||||
|
setMqttResult({ kind: 'success', message: res.data.message })
|
||||||
|
}
|
||||||
|
} 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') {
|
||||||
|
setMqttResult({ kind: 'config-error', message })
|
||||||
|
} else {
|
||||||
|
setMqttResult({ kind: 'failed', message })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMqttResult({ kind: 'failed', message: 'Unexpected error sending test message.' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
loading={testing}
|
||||||
|
data-testid="mqtt-test-button"
|
||||||
|
>
|
||||||
|
Send Test Message
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{mqttResult?.kind === 'success' && (
|
||||||
|
<Alert color="green" data-testid="mqtt-result-success">
|
||||||
|
MQTT test message sent successfully. {mqttResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{mqttResult?.kind === 'config-error' && (
|
||||||
|
<Alert color="orange" data-testid="mqtt-result-config-error">
|
||||||
|
MQTT configuration error — check your MQTT settings. {mqttResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{mqttResult?.kind === 'failed' && (
|
||||||
|
<Alert color="red" data-testid="mqtt-result-failed">
|
||||||
|
MQTT test failed. {mqttResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ConfigPage — main component
|
// ConfigPage — main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -283,6 +371,9 @@ export function ConfigPage() {
|
|||||||
// SMTP test tri-state
|
// SMTP test tri-state
|
||||||
const [smtpResult, setSmtpResult] = useState<SmtpResult>(null)
|
const [smtpResult, setSmtpResult] = useState<SmtpResult>(null)
|
||||||
|
|
||||||
|
// MQTT test tri-state
|
||||||
|
const [mqttResult, setMqttResult] = useState<MqttResult>(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)
|
||||||
@@ -338,6 +429,12 @@ export function ConfigPage() {
|
|||||||
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
|
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Detect if there is an MQTT section (to show the MQTT test button).
|
||||||
|
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
|
||||||
|
|
||||||
|
// Default: open the first section so users immediately see content.
|
||||||
|
const defaultAccordionValue = data.sections[0]?.name ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
|
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
|
||||||
<Group justify="space-between" mb="lg" wrap="nowrap">
|
<Group justify="space-between" mb="lg" wrap="nowrap">
|
||||||
@@ -349,14 +446,42 @@ export function ConfigPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSave} data-testid="config-form">
|
<form onSubmit={handleSave} data-testid="config-form">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{data.sections.map((section) => (
|
{/* M5-T01B: each config section is one Accordion.Item.
|
||||||
<ConfigSectionPanel
|
Future panels (e.g. T12 Home Assistant Expose) should be appended
|
||||||
key={section.name}
|
as additional Accordion.Items here, after the config sections. */}
|
||||||
section={section}
|
<Accordion
|
||||||
localValues={localValues}
|
defaultValue={defaultAccordionValue}
|
||||||
onChange={handleChange}
|
variant="separated"
|
||||||
/>
|
radius="md"
|
||||||
))}
|
data-testid="config-accordion"
|
||||||
|
>
|
||||||
|
{data.sections.map((section) => (
|
||||||
|
<Accordion.Item key={section.name} value={section.name}>
|
||||||
|
<Accordion.Control data-testid={`accordion-control-${section.name}`}>
|
||||||
|
<Text fw={500}>{section.name}</Text>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<ConfigSectionFields
|
||||||
|
section={section}
|
||||||
|
localValues={localValues}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* M5-fix2: Home Assistant Expose panel merged into the config accordion,
|
||||||
|
above Save/Test buttons. All interactive elements inside ExposeSettings
|
||||||
|
use type="button" so they do not trigger the config form submit. */}
|
||||||
|
<Accordion.Item value="expose" data-testid="expose-accordion-item">
|
||||||
|
<Accordion.Control data-testid="accordion-control-expose">
|
||||||
|
<Text fw={500}>Home Assistant Expose</Text>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<ExposeSettings />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
@@ -380,9 +505,14 @@ export function ConfigPage() {
|
|||||||
Save Configuration
|
Save Configuration
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{hasSmtpSection && (
|
<Group gap="sm" wrap="wrap">
|
||||||
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
|
{hasSmtpSection && (
|
||||||
)}
|
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
|
||||||
|
)}
|
||||||
|
{hasMqttSection && (
|
||||||
|
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* Tests for EnergyPage (M5-T06 + M5-polish).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders device list from GET /api/modbus/devices.
|
||||||
|
* 2. Empty state when no devices.
|
||||||
|
* 3. "New Device" button opens DeviceForm modal.
|
||||||
|
* 4. Edit button opens DeviceForm modal pre-filled with device data.
|
||||||
|
* 5. Delete button opens confirmation modal; confirm calls DELETE.
|
||||||
|
* 6. Delete 409 error shows friendly hint in confirmation modal.
|
||||||
|
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
|
||||||
|
* 8. Test-read failure shows error in modal.
|
||||||
|
* 9. Auto-refresh switch is rendered (default on) and can be toggled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { EnergyPage } from './EnergyPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEVICE = {
|
||||||
|
uuid: 'abc-123',
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: '2026-06-22T10:00:00Z',
|
||||||
|
last_poll_ok: true,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILES_RESP = {
|
||||||
|
profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockDelete = 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: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupDefaultMocks(devices = [DEVICE]) {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices') {
|
||||||
|
return Promise.resolve({ data: { items: devices, total: devices.length } })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESP })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnergy() {
|
||||||
|
return renderWithProviders(<EnergyPage />, { initialPath: '/energy' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnergyPage — device list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders device list from GET /api/modbus/devices', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('devices-table')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Device name appears in multiple places now (table row + readings section + chart title).
|
||||||
|
const nameElements = screen.getAllByText('SDM120 Main')
|
||||||
|
expect(nameElements.length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
|
||||||
|
// 'sdm120' may also appear in multiple places (table badge + chart).
|
||||||
|
const profileElements = screen.getAllByText('sdm120')
|
||||||
|
expect(profileElements.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no devices', async () => {
|
||||||
|
setupDefaultMocks([])
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('devices-empty')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading state initially', () => {
|
||||||
|
// Delay resolution so we can catch the loading state
|
||||||
|
mockGet.mockReturnValue(new Promise(() => {}))
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('energy-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — create device', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens DeviceForm modal when New Device is clicked', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-new-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-new-button'))
|
||||||
|
|
||||||
|
// Wait for both modal AND the form (which appears after profiles load)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-friendly-name')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('device-host')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit on new device form calls POST /api/modbus/devices', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: DEVICE })
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-new-button')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('device-new-button'))
|
||||||
|
|
||||||
|
// Wait for profiles to load so the form is visible (profiles auto-select sdm120)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Fill required fields (profile is auto-selected to 'sdm120')
|
||||||
|
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||||
|
target: { value: 'Test Device' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('device-host'), {
|
||||||
|
target: { value: '10.0.0.1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/modbus/devices',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
friendly_name: 'Test Device',
|
||||||
|
host: '10.0.0.1',
|
||||||
|
profile: 'sdm120',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — edit device', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens DeviceForm modal with device data when Edit is clicked', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-edit-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-edit-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for profiles to load so form body is visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// UUID shown in edit mode
|
||||||
|
expect(screen.getByTestId('device-uuid-display').textContent).toContain(DEVICE.uuid)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — delete device', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows confirmation modal on Delete click', async () => {
|
||||||
|
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-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-delete-message').textContent).toContain('SDM120 Main')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls DELETE on confirm and closes modal', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: null })
|
||||||
|
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())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: DEVICE.uuid } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel button closes confirmation modal', async () => {
|
||||||
|
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-cancel')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-cancel'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('device-delete-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows friendly 409 hint when device has readings', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockDelete.mockRejectedValue(new ApiError(409, { detail: 'device has readings' }))
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — test-read', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success payload modal after successful test-read', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: true, payload: { voltage: 230.2, current: 1.3 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
|
||||||
|
// Values rendered via formatMetricValue — voltage 230.2 → "230.20"
|
||||||
|
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error modal after failed test-read', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: false, error: 'Connection timed out' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-read-error')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — auto-refresh switch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders auto-refresh switch when devices exist', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-refresh switch is checked by default (default on)', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mantine Switch uses role="switch" and places data-testid directly on the
|
||||||
|
// <input> element; query it by the testid or by role.
|
||||||
|
const switchInput = screen.getByRole('switch', { name: /auto-refresh/i })
|
||||||
|
expect(switchInput).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggling auto-refresh switch changes its checked state', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchInput = screen.getByRole('switch', { name: /auto-refresh/i })
|
||||||
|
expect(switchInput).toBeChecked()
|
||||||
|
|
||||||
|
fireEvent.click(switchInput)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(switchInput).not.toBeChecked()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
/**
|
||||||
|
* EnergyPage — device management UI for the Energy (Modbus) domain.
|
||||||
|
*
|
||||||
|
* Features (T06 + T07):
|
||||||
|
* - Device list with status indicators (enabled, last poll).
|
||||||
|
* - Create / edit via DeviceForm modal.
|
||||||
|
* - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead.
|
||||||
|
* - "Test read" button per device — calls POST /devices/{uuid}/test, shows
|
||||||
|
* decoded payload or error without persisting to the database (T06 OBS-1: error
|
||||||
|
* now caught and displayed rather than producing an unhandled rejection).
|
||||||
|
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
||||||
|
* missing payload keys.
|
||||||
|
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Badge,
|
||||||
|
ScrollArea,
|
||||||
|
Modal,
|
||||||
|
Code,
|
||||||
|
Tooltip,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
||||||
|
import { DeviceForm } from '../energy/DeviceForm'
|
||||||
|
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||||
|
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
import { formatMetricValue } from '../energy/format'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation modal (with 409 "disable instead" hint)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ConfirmDeleteProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
loading: boolean
|
||||||
|
/** Set when the delete failed with 409. */
|
||||||
|
has409Error: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onCancel}
|
||||||
|
title="Confirm Delete"
|
||||||
|
size="sm"
|
||||||
|
data-testid="device-delete-modal"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text data-testid="device-delete-message">
|
||||||
|
Delete device <strong>{device.friendly_name}</strong>?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{has409Error && (
|
||||||
|
<Alert color="orange" data-testid="device-delete-409-hint">
|
||||||
|
This device has existing readings and cannot be deleted. Consider{' '}
|
||||||
|
<strong>disabling</strong> it instead (click Edit → uncheck Enabled).
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={loading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="device-delete-confirm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test-read result modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TestResultModalProps {
|
||||||
|
result: ModbusTestReadResponse
|
||||||
|
deviceName: string
|
||||||
|
metrics?: MetricInfo[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Test read — ${deviceName}`}
|
||||||
|
size="md"
|
||||||
|
data-testid="test-read-modal"
|
||||||
|
>
|
||||||
|
{result.ok ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Badge color="green" data-testid="test-read-ok">Success</Badge>
|
||||||
|
{result.payload && typeof result.payload === 'object' ? (
|
||||||
|
<SimpleGrid cols={2} spacing="xs" data-testid="test-read-payload">
|
||||||
|
{Object.entries(result.payload as Record<string, unknown>).map(([key, val]) => {
|
||||||
|
const metricMeta = metrics?.find((m) => m.key === key)
|
||||||
|
return (
|
||||||
|
<Group key={key} justify="space-between" align="baseline" gap={4}
|
||||||
|
data-testid={`test-read-value-${key}`}>
|
||||||
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||||
|
{metricMeta?.label ?? key}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{formatMetricValue(val, metricMeta)}
|
||||||
|
{metricMeta?.unit ? (
|
||||||
|
<Text component="span" size="xs" c="dimmed" ml={2}>
|
||||||
|
{metricMeta.unit}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Code block data-testid="test-read-payload">
|
||||||
|
{JSON.stringify(result.payload, null, 2)}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Badge color="red" data-testid="test-read-error-badge">Failed</Badge>
|
||||||
|
<Text c="red" data-testid="test-read-error">
|
||||||
|
{result.error ?? 'Unknown error'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device row action: test-read button (self-contained per row)
|
||||||
|
// T06 OBS-1 fix: catch errors from mutateAsync so they don't produce unhandled
|
||||||
|
// promise rejections (e.g. 422 when profile cannot load).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TestReadButtonProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestReadButton({ device }: TestReadButtonProps) {
|
||||||
|
const testMutation = useTestReadDevice()
|
||||||
|
const metricsQuery = useMetrics(device.uuid)
|
||||||
|
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
||||||
|
const [testError, setTestError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setResult(null)
|
||||||
|
setTestError(null)
|
||||||
|
try {
|
||||||
|
const res = await testMutation.mutateAsync(device.uuid)
|
||||||
|
if (res.data) {
|
||||||
|
setResult(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network or API errors (e.g. 422 profile load failure) — surface inline.
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError
|
||||||
|
? `Error ${err.status}: ${JSON.stringify(err.body?.detail ?? err.body ?? 'unknown error')}`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Unexpected error during test read.'
|
||||||
|
setTestError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Immediately read device (not saved)" position="top">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="blue"
|
||||||
|
loading={testMutation.isPending}
|
||||||
|
onClick={handleTest}
|
||||||
|
data-testid={`device-test-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Test read
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Show inline test error as a small alert (no modal needed for error path) */}
|
||||||
|
{testError && (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={() => setTestError(null)}
|
||||||
|
title={`Test read error — ${device.friendly_name}`}
|
||||||
|
size="sm"
|
||||||
|
data-testid="test-read-error-modal"
|
||||||
|
>
|
||||||
|
<Text c="red" data-testid="test-read-inline-error">
|
||||||
|
{testError}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={() => setTestError(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<TestResultModal
|
||||||
|
result={result}
|
||||||
|
deviceName={device.friendly_name}
|
||||||
|
metrics={metricsQuery.data?.metrics}
|
||||||
|
onClose={() => setResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Latest readings card (T07)
|
||||||
|
// Fetches /latest and /metrics; tolerates missing payload keys.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LatestReadingsCardProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
autoRefresh?: { refetchIntervalMs: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
|
||||||
|
const latestQuery = useLatestReading(device.uuid, autoRefresh)
|
||||||
|
const metricsQuery = useMetrics(device.uuid)
|
||||||
|
|
||||||
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||||
|
const latest = latestQuery.data
|
||||||
|
|
||||||
|
if (latestQuery.isLoading || metricsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="sm" data-testid={`latest-card-loading-${device.uuid}`}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading latest reading…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestQuery.isError || metricsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid={`latest-card-error-${device.uuid}`}>
|
||||||
|
Failed to load latest reading.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latest?.found || !latest.payload) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="sm" data-testid={`latest-card-empty-${device.uuid}`}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No readings yet.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = latest.payload as Record<string, unknown>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="sm" data-testid={`latest-card-${device.uuid}`}>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
Latest reading
|
||||||
|
</Text>
|
||||||
|
{latest.recorded_at && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(latest.recorded_at).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
{metrics.map((m) => {
|
||||||
|
const raw = payload[m.key]
|
||||||
|
const displayValue = formatMetricValue(raw, m)
|
||||||
|
const hasValue = displayValue !== '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group key={m.key} justify="space-between" align="baseline" gap={4}
|
||||||
|
data-testid={`latest-value-${device.uuid}-${m.key}`}>
|
||||||
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||||
|
{m.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{displayValue}
|
||||||
|
{hasValue && m.unit ? (
|
||||||
|
<Text component="span" size="xs" c="dimmed" ml={2}>
|
||||||
|
{m.unit}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device list table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeviceTableProps {
|
||||||
|
devices: ModbusDevice[]
|
||||||
|
onEdit: (device: ModbusDevice) => void
|
||||||
|
onDelete: (device: ModbusDevice) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" size="sm" data-testid="devices-empty">
|
||||||
|
No devices configured yet. Click "New Device" to add one.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid="devices-table">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Host</Table.Th>
|
||||||
|
<Table.Th>Port</Table.Th>
|
||||||
|
<Table.Th>Unit ID</Table.Th>
|
||||||
|
<Table.Th>Profile</Table.Th>
|
||||||
|
<Table.Th>Poll (s)</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<Table.Tr key={device.uuid} data-testid={`device-row-${device.uuid}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{device.friendly_name}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{device.host}</Table.Td>
|
||||||
|
<Table.Td>{device.port}</Table.Td>
|
||||||
|
<Table.Td>{device.unit_id}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{device.profile}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{device.poll_interval_s}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color={device.enabled ? 'green' : 'gray'} variant="light" size="sm">
|
||||||
|
{device.enabled ? 'enabled' : 'disabled'}
|
||||||
|
</Badge>
|
||||||
|
{device.last_poll_ok !== null && (
|
||||||
|
<Badge
|
||||||
|
color={device.last_poll_ok ? 'teal' : 'red'}
|
||||||
|
variant="dot"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{device.last_poll_ok ? 'online' : 'offline'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<TestReadButton device={device} />
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(device)}
|
||||||
|
data-testid={`device-edit-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
onClick={() => onDelete(device)}
|
||||||
|
data-testid={`device-delete-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device readings section — latest card + trend chart per device (T07)
|
||||||
|
// Auto-refresh switch controls TanStack Query refetchInterval for all
|
||||||
|
// readings and latest queries in this section.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Default auto-refresh interval in milliseconds (matches backend ~5s poll). */
|
||||||
|
const AUTO_REFRESH_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
|
interface DeviceReadingsSectionProps {
|
||||||
|
devices: ModbusDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
|
||||||
|
// Default on — user wants charts to auto-update without manual refresh.
|
||||||
|
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
|
||||||
|
|
||||||
|
if (devices.length === 0) return null
|
||||||
|
|
||||||
|
const autoRefresh = autoRefreshEnabled
|
||||||
|
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl" data-testid="device-readings-section">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Divider label="Readings & Trends" labelPosition="left" style={{ flex: 1 }} />
|
||||||
|
<Switch
|
||||||
|
label="Auto-refresh"
|
||||||
|
checked={autoRefreshEnabled}
|
||||||
|
onChange={(e) => setAutoRefreshEnabled(e.currentTarget.checked)}
|
||||||
|
size="sm"
|
||||||
|
data-testid="auto-refresh-switch"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{device.friendly_name}
|
||||||
|
</Text>
|
||||||
|
<LatestReadingsCard device={device} autoRefresh={autoRefresh} />
|
||||||
|
<EnergyCharts
|
||||||
|
uuid={device.uuid}
|
||||||
|
deviceName={device.friendly_name}
|
||||||
|
autoRefresh={autoRefresh}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnergyPage — top-level
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EnergyPage() {
|
||||||
|
const devicesQuery = useDevices()
|
||||||
|
const deleteMutation = useDeleteDevice()
|
||||||
|
|
||||||
|
const [editDevice, setEditDevice] = useState<ModbusDevice | null>(null)
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [deleteDevice, setDeleteDevice] = useState<ModbusDevice | null>(null)
|
||||||
|
const [delete409, setDelete409] = useState(false)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditDevice(null)
|
||||||
|
setShowCreateForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(device: ModbusDevice) {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setEditDevice(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(device: ModbusDevice) {
|
||||||
|
setDeleteDevice(device)
|
||||||
|
setDelete409(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDelete() {
|
||||||
|
setDeleteDevice(null)
|
||||||
|
setDelete409(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm() {
|
||||||
|
if (!deleteDevice) return
|
||||||
|
setDelete409(false)
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteDevice.uuid)
|
||||||
|
closeDelete()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setDelete409(true)
|
||||||
|
}
|
||||||
|
// Leave modal open so user sees the hint.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loading / error states
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (devicesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Center pt="xl" data-testid="energy-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devicesQuery.isError || !devicesQuery.data) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" pt="xl">
|
||||||
|
<Alert color="red" data-testid="energy-load-error">
|
||||||
|
Failed to load devices. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = devicesQuery.data.items
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Title order={2}>Energy — Devices</Title>
|
||||||
|
<Button onClick={openCreate} data-testid="device-new-button">
|
||||||
|
New Device
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<DeviceTable
|
||||||
|
devices={devices}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={openDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Latest readings cards + trend charts (T07) */}
|
||||||
|
<DeviceReadingsSection devices={devices} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<DeviceForm
|
||||||
|
onClose={() => setShowCreateForm(false)}
|
||||||
|
onSaved={() => setShowCreateForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit form */}
|
||||||
|
{editDevice && (
|
||||||
|
<DeviceForm
|
||||||
|
device={editDevice}
|
||||||
|
onClose={() => setEditDevice(null)}
|
||||||
|
onSaved={() => setEditDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{deleteDevice && (
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
device={deleteDevice}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={closeDelete}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
has409Error={delete409}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react'
|
|||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
import { RecordsPage } from './RecordsPage'
|
import { RecordsPage } from './RecordsPage'
|
||||||
import type { LocationRecord } from '../records'
|
import type { LocationRecord } from '../records'
|
||||||
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
@@ -439,3 +440,303 @@ describe('RecordsPage — multiple poo rows', () => {
|
|||||||
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
|
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Meter readings tabs (M5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const METER_DEVICE_1: ModbusDevice = {
|
||||||
|
uuid: 'dev-uuid-1',
|
||||||
|
friendly_name: 'Main Meter',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.10',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm630',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: '2026-06-22T10:00:00Z',
|
||||||
|
last_poll_ok: true,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T10:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METER_DEVICE_2: ModbusDevice = {
|
||||||
|
uuid: 'dev-uuid-2',
|
||||||
|
friendly_name: 'Sub Meter',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.11',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: null,
|
||||||
|
last_poll_ok: null,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T10:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METER_METRICS: MetricInfo[] = [
|
||||||
|
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
|
||||||
|
{ key: 'energy_kwh', label: 'Energy', unit: 'kWh', device_class: 'energy' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A single reading with an open-ended payload shape for test flexibility. */
|
||||||
|
type TestReading = { recorded_at: string; payload: Record<string, number> }
|
||||||
|
|
||||||
|
const METER_READINGS: TestReading[] = [
|
||||||
|
{
|
||||||
|
recorded_at: '2026-06-22T09:00:00Z',
|
||||||
|
payload: { voltage: 230.5, energy_kwh: 12.345 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recorded_at: '2026-06-22T09:05:00Z',
|
||||||
|
payload: { voltage: 231.1, energy_kwh: 12.350 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Extend the poo/location mock to also serve Modbus endpoints. */
|
||||||
|
function setupGetMockWithMeters({
|
||||||
|
devices = [METER_DEVICE_1],
|
||||||
|
metricsMap = { 'dev-uuid-1': METER_METRICS },
|
||||||
|
readingsMap = { 'dev-uuid-1': METER_READINGS },
|
||||||
|
}: {
|
||||||
|
devices?: ModbusDevice[]
|
||||||
|
metricsMap?: Record<string, MetricInfo[]>
|
||||||
|
readingsMap?: Record<string, TestReading[]>
|
||||||
|
} = {}) {
|
||||||
|
mockGet.mockImplementation((path: string, opts?: { params?: { path?: { uuid?: string }; query?: { offset?: number } } }) => {
|
||||||
|
const offset = opts?.params?.query?.offset ?? 0
|
||||||
|
if (path === '/api/poo') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [POO_RECORD], limit: 100, offset },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/locations') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [LOCATION_RECORD], limit: 100, offset },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: devices, total: devices.length },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const uuid = opts?.params?.path?.uuid
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics' && uuid) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { profile: 'sdm630', metrics: metricsMap[uuid] ?? [] },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings' && uuid) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: readingsMap[uuid] ?? [] },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecordsPage — meter tabs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 0 devices: no meter tabs
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows no meter tabs when there are 0 devices', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
// Poo and Locations tabs must still be present
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tab-poo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// No meter tab rendered
|
||||||
|
expect(screen.queryByTestId('tab-meter-dev-uuid-1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1 device: 1 meter tab with friendly_name label
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders 1 meter tab with friendly_name for 1 device', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent(METER_DEVICE_1.friendly_name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2 devices: 2 meter tabs with correct friendly_names
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders 2 meter tabs for 2 devices, each with correct friendly_name', async () => {
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1, METER_DEVICE_2],
|
||||||
|
metricsMap: { 'dev-uuid-1': METER_METRICS, 'dev-uuid-2': METER_METRICS },
|
||||||
|
readingsMap: { 'dev-uuid-1': METER_READINGS, 'dev-uuid-2': [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent('Main Meter')
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toHaveTextContent('Sub Meter')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: switching to it shows the readings table with metric columns
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('switching to meter tab shows table with metric column headers and reading rows', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
// Wait for tab to appear then click it
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
// Table should be rendered
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Metric column headers
|
||||||
|
expect(screen.getByText(/Voltage/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Energy/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// At least one reading row
|
||||||
|
const rows = screen.getAllByTestId(`meter-row-${METER_DEVICE_1.uuid}`)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: values formatted correctly (voltage 2 dp, energy 3 dp)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('formats voltage with 2 dp and energy with 3 dp', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// voltage: 230.5 → "230.50" (2 dp, device_class != energy)
|
||||||
|
expect(screen.getByText('230.50')).toBeInTheDocument()
|
||||||
|
// energy_kwh: 12.345 → "12.345" (3 dp, device_class == energy)
|
||||||
|
expect(screen.getByText('12.345')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: missing key in payload → placeholder "—"
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows placeholder "—" for a missing key in payload', async () => {
|
||||||
|
const readingsWithMissingKey = [
|
||||||
|
{
|
||||||
|
recorded_at: '2026-06-22T09:00:00Z',
|
||||||
|
payload: { voltage: 230.5 }, // energy_kwh missing
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1],
|
||||||
|
readingsMap: { 'dev-uuid-1': readingsWithMissingKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The "—" placeholder must appear (for the missing energy_kwh key)
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: empty readings → empty state message
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows empty state when device has no readings', async () => {
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1],
|
||||||
|
readingsMap: { 'dev-uuid-1': [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-empty-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: 1000 readings → truncation notice shown
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows truncation notice when readings.length equals METER_READINGS_LIMIT (1000)', async () => {
|
||||||
|
const truncatedReadings = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
recorded_at: `2026-06-22T${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
|
||||||
|
payload: { voltage: 230 + i * 0.01, energy_kwh: 10 + i * 0.001 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1],
|
||||||
|
readingsMap: { 'dev-uuid-1': truncatedReadings },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-truncated-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10).
|
* RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10).
|
||||||
|
* Meter readings tab (read-only) per Modbus device dynamically appended (M5).
|
||||||
*
|
*
|
||||||
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
|
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
|
||||||
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
|
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
|
||||||
* - Edit and delete use reusable components from src/records/.
|
* - Edit and delete use reusable components from src/records/.
|
||||||
* - Delete has a二次确认 modal before calling DELETE.
|
* - Delete has a二次确认 modal before calling DELETE.
|
||||||
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
|
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
|
||||||
|
* - Meter tabs: one read-only tab per Modbus device; readings from /readings (last 24h, limit 1000).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -30,6 +32,9 @@ import apiClient from '../api/client'
|
|||||||
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
|
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
|
||||||
import { useDeletePoo, useDeleteLocation } from '../records'
|
import { useDeletePoo, useDeleteLocation } from '../records'
|
||||||
import type { PooRecord, LocationRecord } from '../records'
|
import type { PooRecord, LocationRecord } from '../records'
|
||||||
|
import { useDevices, useMetrics, useReadings } from '../energy/hooks'
|
||||||
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
||||||
|
import { formatMetricValue } from '../energy/format'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -341,11 +346,133 @@ function LocationList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MeterReadingsTable — read-only table for a single Modbus device's readings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How many ms of history to show per meter tab (last 24 h). */
|
||||||
|
const METER_SPAN_MS = 24 * 60 * 60 * 1000
|
||||||
|
/** Upper bound on readings fetched; never a full-table pull. */
|
||||||
|
const METER_READINGS_LIMIT = 1000
|
||||||
|
|
||||||
|
interface MeterReadingsTableProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an ISO timestamp for display. */
|
||||||
|
function formatRecordedAt(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeterReadingsTable({ device }: MeterReadingsTableProps) {
|
||||||
|
const metricsQuery = useMetrics(device.uuid)
|
||||||
|
const readingsQuery = useReadings(device.uuid, {
|
||||||
|
spanMs: METER_SPAN_MS,
|
||||||
|
limit: METER_READINGS_LIMIT,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = metricsQuery.isLoading || readingsQuery.isLoading
|
||||||
|
const isError = metricsQuery.isError || readingsQuery.isError
|
||||||
|
|
||||||
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||||
|
const readings = readingsQuery.data?.items ?? []
|
||||||
|
const isTruncated = !isLoading && !isError && readings.length >= METER_READINGS_LIMIT
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center pt="xl" data-testid={`meter-loading-${device.uuid}`}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid={`meter-error-${device.uuid}`}>
|
||||||
|
Failed to load meter readings. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colSpan = 1 + metrics.length // time column + one per metric
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{isTruncated && (
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
ta="right"
|
||||||
|
data-testid={`meter-truncated-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Showing the most recent {METER_READINGS_LIMIT} readings (within the last 24h)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid={`meter-table-${device.uuid}`}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ whiteSpace: 'nowrap' }}>Recorded At</Table.Th>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<Table.Th key={m.key} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{m.label}
|
||||||
|
{m.unit ? (
|
||||||
|
<Text component="span" size="xs" c="dimmed" ml={4}>
|
||||||
|
({m.unit})
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{readings.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={colSpan}>
|
||||||
|
<Text c="dimmed" ta="center" size="sm" data-testid={`meter-empty-${device.uuid}`}>
|
||||||
|
No readings in the last 24 hours.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
readings.map((row) => {
|
||||||
|
const payload = row.payload as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={row.recorded_at}
|
||||||
|
data-testid={`meter-row-${device.uuid}`}
|
||||||
|
>
|
||||||
|
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatRecordedAt(row.recorded_at)}
|
||||||
|
</Table.Td>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<Table.Td key={m.key} data-testid={`meter-cell-${device.uuid}-${m.key}`}>
|
||||||
|
{formatMetricValue(payload[m.key], m)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RecordsPage — top-level
|
// RecordsPage — top-level
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function RecordsPage() {
|
export function RecordsPage() {
|
||||||
|
const devicesQuery = useDevices()
|
||||||
|
const devices: ModbusDevice[] = devicesQuery.data?.items ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
|
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
|
||||||
<Title order={2} mb="lg">
|
<Title order={2} mb="lg">
|
||||||
@@ -360,6 +487,11 @@ export function RecordsPage() {
|
|||||||
<Tabs.Tab value="locations" data-testid="tab-locations">
|
<Tabs.Tab value="locations" data-testid="tab-locations">
|
||||||
Locations
|
Locations
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Tabs.Tab key={d.uuid} value={d.uuid} data-testid={`tab-meter-${d.uuid}`}>
|
||||||
|
{d.friendly_name}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="poo">
|
<Tabs.Panel value="poo">
|
||||||
@@ -369,6 +501,12 @@ export function RecordsPage() {
|
|||||||
<Tabs.Panel value="locations">
|
<Tabs.Panel value="locations">
|
||||||
<LocationList />
|
<LocationList />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Tabs.Panel key={d.uuid} value={d.uuid}>
|
||||||
|
<MeterReadingsTable device={d} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
+1404
-1
File diff suppressed because it is too large
Load Diff
+1064
-1
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ apscheduler>=3.10,<4.0
|
|||||||
argon2-cffi>=25.1,<26.0
|
argon2-cffi>=25.1,<26.0
|
||||||
fastapi>=0.115,<0.116
|
fastapi>=0.115,<0.116
|
||||||
httpx>=0.28,<1.0
|
httpx>=0.28,<1.0
|
||||||
|
paho-mqtt>=2.0,<3.0
|
||||||
|
pymodbus>=3.6,<4.0
|
||||||
pydantic-settings>=2.6,<3.0
|
pydantic-settings>=2.6,<3.0
|
||||||
pyotp>=2.9,<3.0
|
pyotp>=2.9,<3.0
|
||||||
python-multipart>=0.0.12,<1.0
|
python-multipart>=0.0.12,<1.0
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ mako==1.3.11
|
|||||||
# via alembic
|
# via alembic
|
||||||
markupsafe==3.0.3
|
markupsafe==3.0.3
|
||||||
# via mako
|
# via mako
|
||||||
|
paho-mqtt==2.1.0
|
||||||
|
# via -r requirements.in
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
# via cffi
|
# via cffi
|
||||||
pydantic==2.13.2
|
pydantic==2.13.2
|
||||||
@@ -59,6 +61,8 @@ pydantic-core==2.46.2
|
|||||||
# via pydantic
|
# via pydantic
|
||||||
pydantic-settings==2.13.1
|
pydantic-settings==2.13.1
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
pymodbus==3.13.1
|
||||||
|
# via -r requirements.in
|
||||||
pyotp==2.10.0
|
pyotp==2.10.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
|
|||||||
@@ -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 = "20260621_08_totp"
|
APP_BASELINE_REVISION = "20260622_10_exposed_entities"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Modbus CLI — read-only manual test tool for Modbus TCP devices.
|
||||||
|
|
||||||
|
Entry point::
|
||||||
|
|
||||||
|
python -m scripts.modbus_cli <subcommand> [options]
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
-----------
|
||||||
|
read --host H --port P --unit U --profile NAME
|
||||||
|
Read all metrics defined in the named profile from the device and print
|
||||||
|
decoded engineering values as a formatted table. Use this to verify the
|
||||||
|
full decode chain end-to-end.
|
||||||
|
|
||||||
|
probe --host H --port P --unit U --fc {3,4} --address ADDR --count N
|
||||||
|
[--decode float32]
|
||||||
|
Send a raw FC03 (holding) or FC04 (input) read request for exactly N
|
||||||
|
registers starting at ADDR, and print the raw 16-bit register values in
|
||||||
|
hex. Optionally decode consecutive register pairs as big-endian float32.
|
||||||
|
Use this for first-contact verification of gateway reachability, framer
|
||||||
|
mode, and unit_id before setting up a full profile.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
- **Read-only**: only FC03 and FC04 are exposed. There is no write path.
|
||||||
|
This prevents accidental modification of device configuration registers
|
||||||
|
(Meter ID, baud rate, etc.) which could cause communication loss.
|
||||||
|
- **No DB dependency**: the tool connects directly to the gateway without
|
||||||
|
reading from the application database. It is safe to use before any
|
||||||
|
device has been configured in the application.
|
||||||
|
- **Non-zero exit on connection failure** so shell scripts can detect errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure the project root is on sys.path when invoked as ``python -m scripts.modbus_cli``
|
||||||
|
# or directly.
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app.integrations.modbus.driver import (
|
||||||
|
ModbusConnectionError,
|
||||||
|
ModbusDriverError,
|
||||||
|
ModbusResponseError,
|
||||||
|
registers_to_float,
|
||||||
|
)
|
||||||
|
from app.integrations.modbus.profiles import ProfileNotFoundError, ProfileValidationError, decode, load_profile
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper — raw FC03/FC04 read (used by ``probe``)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_read(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
unit_id: int,
|
||||||
|
fc: int,
|
||||||
|
address: int,
|
||||||
|
count: int,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Perform a raw FC03 or FC04 read and return the register list.
|
||||||
|
|
||||||
|
Raises ``ModbusConnectionError`` or ``ModbusResponseError`` on failure.
|
||||||
|
"""
|
||||||
|
from pymodbus.client import ModbusTcpClient
|
||||||
|
from pymodbus.exceptions import ConnectionException
|
||||||
|
|
||||||
|
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||||
|
try:
|
||||||
|
connected = client.connect()
|
||||||
|
if not connected:
|
||||||
|
raise ModbusConnectionError(
|
||||||
|
f"Could not connect to Modbus gateway at {host}:{port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fc == 4:
|
||||||
|
response = client.read_input_registers(address, count=count, device_id=unit_id)
|
||||||
|
elif fc == 3:
|
||||||
|
response = client.read_holding_registers(address, count=count, device_id=unit_id)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported function code FC{fc:02d} (only FC03/FC04 are allowed)")
|
||||||
|
|
||||||
|
if response.isError():
|
||||||
|
raise ModbusResponseError(
|
||||||
|
f"Modbus exception reading FC{fc:02d} registers 0x{address:04X}+{count}: "
|
||||||
|
f"{response}"
|
||||||
|
)
|
||||||
|
|
||||||
|
regs = response.registers
|
||||||
|
if len(regs) != count:
|
||||||
|
raise ModbusResponseError(
|
||||||
|
f"Expected {count} registers, got {len(regs)}"
|
||||||
|
)
|
||||||
|
return list(regs)
|
||||||
|
|
||||||
|
except ConnectionException as exc:
|
||||||
|
raise ModbusConnectionError(
|
||||||
|
f"Connection to {host}:{port} failed: {exc}"
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sub-command: read
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_read(args: argparse.Namespace) -> None:
|
||||||
|
"""Read all metrics from a named profile and print decoded values."""
|
||||||
|
profile_name: str = args.profile
|
||||||
|
host: str = args.host
|
||||||
|
port: int = args.port
|
||||||
|
unit_id: int = args.unit
|
||||||
|
|
||||||
|
# Load and validate the profile.
|
||||||
|
try:
|
||||||
|
profile = load_profile(profile_name)
|
||||||
|
except ProfileNotFoundError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except ProfileValidationError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Profile : {profile.name} — {profile.description}")
|
||||||
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||||
|
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Convert profile blocks to the dict format expected by read_blocks.
|
||||||
|
blocks = [{"start": b.start, "count": b.count} for b in profile.blocks]
|
||||||
|
|
||||||
|
# Perform the read.
|
||||||
|
from app.integrations.modbus.driver import read_blocks
|
||||||
|
|
||||||
|
try:
|
||||||
|
registers = read_blocks(host, port, unit_id, blocks)
|
||||||
|
except ModbusConnectionError as exc:
|
||||||
|
print(f"Connection error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except ModbusResponseError as exc:
|
||||||
|
print(f"Modbus response error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except ModbusDriverError as exc:
|
||||||
|
print(f"Modbus driver error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Decode.
|
||||||
|
values = decode(profile, registers)
|
||||||
|
|
||||||
|
# Print as table.
|
||||||
|
print(f"{'Metric':<20} {'Value':>14} Unit")
|
||||||
|
print("-" * 45)
|
||||||
|
for metric in profile.metrics:
|
||||||
|
key = metric.key
|
||||||
|
if key in values:
|
||||||
|
unit = metric.unit if metric.unit else "—"
|
||||||
|
print(f"{key:<20} {values[key]:>14.4f} {unit}")
|
||||||
|
else:
|
||||||
|
print(f"{key:<20} {'(not available)':>14}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sub-command: probe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_probe(args: argparse.Namespace) -> None:
|
||||||
|
"""Perform a raw FC03/FC04 read and print raw register values."""
|
||||||
|
host: str = args.host
|
||||||
|
port: int = args.port
|
||||||
|
unit_id: int = args.unit
|
||||||
|
fc: int = args.fc
|
||||||
|
address: int = args.address
|
||||||
|
count: int = args.count
|
||||||
|
do_decode: bool = args.decode == "float32"
|
||||||
|
|
||||||
|
if fc not in (3, 4):
|
||||||
|
print(f"Error: --fc must be 3 or 4 (got {fc})", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||||
|
print(f"Request : FC{fc:02d} start=0x{address:04X} count={count}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
regs = _raw_read(host, port, unit_id, fc, address, count)
|
||||||
|
except ModbusConnectionError as exc:
|
||||||
|
print(f"Connection error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except ModbusResponseError as exc:
|
||||||
|
print(f"Modbus response error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except ModbusDriverError as exc:
|
||||||
|
print(f"Modbus driver error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Print raw register values.
|
||||||
|
print(f"{'Addr':>8} {'Hex':>6} {'Dec':>7}")
|
||||||
|
print("-" * 28)
|
||||||
|
for i, val in enumerate(regs):
|
||||||
|
print(f"0x{address + i:04X} 0x{val:04X} {val:>7d}")
|
||||||
|
|
||||||
|
# Optional float32 decoding — consecutive register pairs.
|
||||||
|
if do_decode and len(regs) >= 2:
|
||||||
|
print()
|
||||||
|
print("float32 decode (big-endian, high register first):")
|
||||||
|
print(f"{'Addr pair':>14} {'Float value':>16}")
|
||||||
|
print("-" * 35)
|
||||||
|
for i in range(0, len(regs) - 1, 2):
|
||||||
|
hi = regs[i]
|
||||||
|
lo = regs[i + 1]
|
||||||
|
fval = registers_to_float(hi, lo)
|
||||||
|
print(f"0x{address + i:04X}+0x{address + i + 1:04X} {fval:>16.6f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argument parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_int(value: str) -> int:
|
||||||
|
"""Parse an integer that may be hex (``0x...``) or decimal."""
|
||||||
|
return int(value, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m scripts.modbus_cli",
|
||||||
|
description=(
|
||||||
|
"Modbus CLI — read-only manual test tool for Modbus TCP devices. "
|
||||||
|
"Use 'read' to decode all profile metrics; use 'probe' for raw "
|
||||||
|
"first-contact register inspection."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
||||||
|
subparsers.required = True
|
||||||
|
|
||||||
|
# -- read --
|
||||||
|
rp = subparsers.add_parser(
|
||||||
|
"read",
|
||||||
|
help=(
|
||||||
|
"Read and decode all metrics defined in a profile. "
|
||||||
|
"Prints a table of engineering values."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rp.add_argument("--host", required=True, metavar="HOST", help="Gateway IP/hostname.")
|
||||||
|
rp.add_argument(
|
||||||
|
"--port", type=int, default=502, metavar="PORT", help="Gateway TCP port (default 502)."
|
||||||
|
)
|
||||||
|
rp.add_argument(
|
||||||
|
"--unit",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
metavar="UNIT_ID",
|
||||||
|
help="Modbus slave / unit ID (Meter ID, typically 1).",
|
||||||
|
)
|
||||||
|
rp.add_argument(
|
||||||
|
"--profile",
|
||||||
|
required=True,
|
||||||
|
metavar="NAME",
|
||||||
|
help="Profile name (e.g. 'sdm120').",
|
||||||
|
)
|
||||||
|
rp.set_defaults(func=cmd_read)
|
||||||
|
|
||||||
|
# -- probe --
|
||||||
|
pp = subparsers.add_parser(
|
||||||
|
"probe",
|
||||||
|
help=(
|
||||||
|
"Raw read of N registers starting at ADDR via FC03 or FC04. "
|
||||||
|
"Prints raw hex values; optionally decodes as big-endian float32."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pp.add_argument("--host", required=True, metavar="HOST", help="Gateway IP/hostname.")
|
||||||
|
pp.add_argument(
|
||||||
|
"--port", type=int, default=502, metavar="PORT", help="Gateway TCP port (default 502)."
|
||||||
|
)
|
||||||
|
pp.add_argument(
|
||||||
|
"--unit",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
metavar="UNIT_ID",
|
||||||
|
help="Modbus slave / unit ID.",
|
||||||
|
)
|
||||||
|
pp.add_argument(
|
||||||
|
"--fc",
|
||||||
|
type=int,
|
||||||
|
choices=[3, 4],
|
||||||
|
default=4,
|
||||||
|
metavar="FC",
|
||||||
|
help="Function code: 3 (holding) or 4 (input registers, default).",
|
||||||
|
)
|
||||||
|
pp.add_argument(
|
||||||
|
"--address",
|
||||||
|
type=_auto_int,
|
||||||
|
required=True,
|
||||||
|
metavar="ADDR",
|
||||||
|
help="Start register address (hex e.g. 0x0000 or decimal).",
|
||||||
|
)
|
||||||
|
pp.add_argument(
|
||||||
|
"--count",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
metavar="N",
|
||||||
|
help="Number of registers to read.",
|
||||||
|
)
|
||||||
|
pp.add_argument(
|
||||||
|
"--decode",
|
||||||
|
choices=["float32"],
|
||||||
|
default=None,
|
||||||
|
metavar="TYPE",
|
||||||
|
help="Optional: decode consecutive register pairs as 'float32'.",
|
||||||
|
)
|
||||||
|
pp.set_defaults(func=cmd_probe)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+334
-1
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -410,3 +410,336 @@ def test_smtp_test_response_does_not_echo_smtp_password(client: TestClient) -> N
|
|||||||
assert "message" in body
|
assert "message" in body
|
||||||
# The plaintext password must not appear anywhere in the response body
|
# The plaintext password must not appear anywhere in the response body
|
||||||
assert smtp_password not in response.text
|
assert smtp_password not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M5-T08: MQTT / HA Discovery / Modbus CONFIG_FIELDS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_includes_mqtt_section(client: TestClient) -> None:
|
||||||
|
"""GET /api/config must include an MQTT section with expected fields."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
section_names = {s["name"] for s in body["sections"]}
|
||||||
|
assert "MQTT" in section_names, f"MQTT section missing; got {section_names}"
|
||||||
|
|
||||||
|
mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT")
|
||||||
|
env_names = {f["env_name"] for f in mqtt_section["fields"]}
|
||||||
|
assert "MQTT_ENABLED" in env_names
|
||||||
|
assert "MQTT_BROKER_HOST" in env_names
|
||||||
|
assert "MQTT_BROKER_PORT" in env_names
|
||||||
|
assert "MQTT_USERNAME" in env_names
|
||||||
|
assert "MQTT_PASSWORD" in env_names
|
||||||
|
assert "MQTT_TLS_ENABLED" in env_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_includes_ha_discovery_section(client: TestClient) -> None:
|
||||||
|
"""GET /api/config must include a Home Assistant Discovery section."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
section_names = {s["name"] for s in body["sections"]}
|
||||||
|
assert "Home Assistant Discovery" in section_names, (
|
||||||
|
f"Home Assistant Discovery section missing; got {section_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ha_section = next(s for s in body["sections"] if s["name"] == "Home Assistant Discovery")
|
||||||
|
env_names = {f["env_name"] for f in ha_section["fields"]}
|
||||||
|
assert "HA_DISCOVERY_ENABLED" in env_names
|
||||||
|
assert "HA_DISCOVERY_PREFIX" in env_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_includes_modbus_section(client: TestClient) -> None:
|
||||||
|
"""GET /api/config must include a Modbus section with MODBUS_POLLING_ENABLED."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
section_names = {s["name"] for s in body["sections"]}
|
||||||
|
assert "Modbus" in section_names, f"Modbus section missing; got {section_names}"
|
||||||
|
|
||||||
|
modbus_section = next(s for s in body["sections"] if s["name"] == "Modbus")
|
||||||
|
env_names = {f["env_name"] for f in modbus_section["fields"]}
|
||||||
|
assert "MODBUS_POLLING_ENABLED" in env_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_mqtt_password_is_secret(client: TestClient) -> None:
|
||||||
|
"""MQTT_PASSWORD must be marked secret and return empty string in GET response."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT")
|
||||||
|
password_field = next(f for f in mqtt_section["fields"] if f["env_name"] == "MQTT_PASSWORD")
|
||||||
|
|
||||||
|
assert password_field["secret"] is True
|
||||||
|
assert password_field["value"] == "", (
|
||||||
|
f"MQTT_PASSWORD should be masked (empty string), got {password_field['value']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_broker_port_input_type_is_number(client: TestClient) -> None:
|
||||||
|
"""MQTT_BROKER_PORT must have input_type='number' for correct frontend rendering."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT")
|
||||||
|
port_field = next(f for f in mqtt_section["fields"] if f["env_name"] == "MQTT_BROKER_PORT")
|
||||||
|
|
||||||
|
assert port_field["input_type"] == "number", (
|
||||||
|
f"MQTT_BROKER_PORT input_type should be 'number', got {port_field['input_type']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_blank_mqtt_password_keeps_existing(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Submitting blank MQTT_PASSWORD must NOT overwrite the stored secret."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Store a secret via full PUT
|
||||||
|
payload_with_secret = _full_config_payload({"MQTT_PASSWORD": "mqtt-secret-value"})
|
||||||
|
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({"MQTT_PASSWORD": ""})
|
||||||
|
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("MQTT_PASSWORD") == "mqtt-secret-value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_invalid_mqtt_port_returns_422_and_does_not_write(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Non-numeric MQTT_BROKER_PORT must return 422 and not persist the bad value."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
payload = _full_config_payload({"MQTT_BROKER_PORT": "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("MQTT_BROKER_PORT") != "not-a-number"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M5-polish2 Area B: bool fields have input_type="checkbox"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EXPECTED_CHECKBOX_FIELDS = {
|
||||||
|
"APP_DEBUG",
|
||||||
|
"SMTP_ENABLED",
|
||||||
|
"SMTP_USE_STARTTLS",
|
||||||
|
"AUTH_LOGIN_THROTTLE_ENABLED",
|
||||||
|
"MQTT_ENABLED",
|
||||||
|
"MQTT_TLS_ENABLED",
|
||||||
|
"HA_DISCOVERY_ENABLED",
|
||||||
|
"MODBUS_POLLING_ENABLED",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bool_fields_have_checkbox_input_type(client: TestClient) -> None:
|
||||||
|
"""All pure-bool config fields must return input_type='checkbox' from GET /api/config."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
all_fields = {
|
||||||
|
field["env_name"]: field
|
||||||
|
for section in body["sections"]
|
||||||
|
for field in section["fields"]
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_name in EXPECTED_CHECKBOX_FIELDS:
|
||||||
|
assert env_name in all_fields, f"{env_name} not found in config response"
|
||||||
|
actual_type = all_fields[env_name]["input_type"]
|
||||||
|
assert actual_type == "checkbox", (
|
||||||
|
f"{env_name}: expected input_type='checkbox', got {actual_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_cookie_secure_override_is_not_checkbox(client: TestClient) -> None:
|
||||||
|
"""AUTH_COOKIE_SECURE_OVERRIDE is bool|None (three-state) and must NOT be checkbox."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.get("/api/config")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
all_fields = {
|
||||||
|
field["env_name"]: field
|
||||||
|
for section in body["sections"]
|
||||||
|
for field in section["fields"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# This field is present in CONFIG_FIELDS; its type is bool|None so it stays text.
|
||||||
|
if "AUTH_COOKIE_SECURE_OVERRIDE" in all_fields:
|
||||||
|
actual_type = all_fields["AUTH_COOKIE_SECURE_OVERRIDE"]["input_type"]
|
||||||
|
assert actual_type != "checkbox", (
|
||||||
|
f"AUTH_COOKIE_SECURE_OVERRIDE should not be checkbox (it is bool|None); got {actual_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_bool_field_roundtrip_true_false(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""Saving 'true'/'false' string for a bool checkbox field roundtrips correctly."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Set MQTT_ENABLED to 'true' via config PUT
|
||||||
|
payload_true = _full_config_payload({"MQTT_ENABLED": "true"})
|
||||||
|
resp_true = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_true},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert resp_true.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("MQTT_ENABLED") == "true"
|
||||||
|
|
||||||
|
# Now set to 'false'
|
||||||
|
payload_false = _full_config_payload({"MQTT_ENABLED": "false"})
|
||||||
|
resp_false = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_false},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert resp_false.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("MQTT_ENABLED") == "false"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M5-fix2: DB-config → runtime settings (Issue 2 regression assertions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_put_config_mqtt_reconnect_uses_db_merged_settings(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""PUT /api/config with MQTT keys must reconnect with DB-merged settings,
|
||||||
|
not bootstrap-only settings. Asserts that mqtt_manager.reconnect receives
|
||||||
|
settings where mqtt_enabled=True and mqtt_broker_host matches the submitted value.
|
||||||
|
"""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
target_host = "mqtt.example.com"
|
||||||
|
payload = _full_config_payload({
|
||||||
|
"MQTT_ENABLED": "true",
|
||||||
|
"MQTT_BROKER_HOST": target_host,
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_mgr = MagicMock()
|
||||||
|
mock_mgr.is_configured.return_value = True
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.config.mqtt_manager", mock_mgr):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# reconnect must have been called once
|
||||||
|
assert mock_mgr.reconnect.call_count == 1, (
|
||||||
|
f"Expected mqtt_manager.reconnect called once, got {mock_mgr.reconnect.call_count}"
|
||||||
|
)
|
||||||
|
reconnect_settings = mock_mgr.reconnect.call_args[0][0]
|
||||||
|
assert reconnect_settings.mqtt_enabled is True, (
|
||||||
|
f"reconnect settings.mqtt_enabled must be True, got {reconnect_settings.mqtt_enabled!r}"
|
||||||
|
)
|
||||||
|
assert reconnect_settings.mqtt_broker_host == target_host, (
|
||||||
|
f"reconnect settings.mqtt_broker_host must be {target_host!r}, "
|
||||||
|
f"got {reconnect_settings.mqtt_broker_host!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_mqtt_test_uses_db_broker_host(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
"""POST /api/config/mqtt/test must use the DB-stored broker host, not env/bootstrap."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
db_host = "broker.db-configured.local"
|
||||||
|
|
||||||
|
# First write the MQTT config to DB via PUT /api/config
|
||||||
|
payload_save = _full_config_payload({
|
||||||
|
"MQTT_ENABLED": "true",
|
||||||
|
"MQTT_BROKER_HOST": db_host,
|
||||||
|
})
|
||||||
|
with patch("app.api.routes.api.config.mqtt_manager"):
|
||||||
|
save_resp = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": payload_save},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
assert save_resp.status_code == 200
|
||||||
|
|
||||||
|
# Now POST /api/config/mqtt/test — capture the host that _run_mqtt_test uses.
|
||||||
|
captured_host: list[str] = []
|
||||||
|
|
||||||
|
def _fake_run_mqtt_test(settings):
|
||||||
|
captured_host.append(settings.mqtt_broker_host)
|
||||||
|
# simulate success by just returning
|
||||||
|
return
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.config._run_mqtt_test", side_effect=_fake_run_mqtt_test):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/config/mqtt/test",
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(captured_host) == 1, "Expected _run_mqtt_test to be called once"
|
||||||
|
assert captured_host[0] == db_host, (
|
||||||
|
f"Expected mqtt test to use DB host {db_host!r}, got {captured_host[0]!r}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
"""Tests for M5-T12: Expose API (app/api/routes/api/expose.py).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- GET /api/expose — catalog + toggle state + MQTT/Discovery status
|
||||||
|
- PUT /api/expose — upsert toggles; triggers discovery re-publish
|
||||||
|
- POST /api/expose/republish — manual re-publish
|
||||||
|
|
||||||
|
Auth/CSRF matrix:
|
||||||
|
- Unauthenticated GET → 401
|
||||||
|
- Authenticated GET (no CSRF needed) → 200
|
||||||
|
- Authenticated PUT without CSRF → 403
|
||||||
|
- Authenticated PUT + CSRF → 200; toggles persisted; publish_discovery called
|
||||||
|
- Authenticated POST /republish without CSRF → 403
|
||||||
|
- Authenticated POST /republish + CSRF + MQTT off → 200 ok=False
|
||||||
|
- Authenticated POST /republish + CSRF + MQTT on → 200 ok=True; publish_discovery called
|
||||||
|
|
||||||
|
Value-getter non-serialisation:
|
||||||
|
- value_getter field is NOT present in the API response payload.
|
||||||
|
|
||||||
|
Isolation strategy:
|
||||||
|
- MqttManager and publish_discovery are always mocked so no real broker is needed.
|
||||||
|
- The Modbus _modbus_provider is patched away to a simple fake provider for catalog
|
||||||
|
tests, avoiding the need for DB-registered modbus devices in every test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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.text}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A minimal fake provider that returns a fixed entity (no real DB device needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_provider(entity_key: str = "modbus.abc.voltage"):
|
||||||
|
"""Return a provider callable that yields one hard-coded ExposableEntity."""
|
||||||
|
|
||||||
|
def _provider(session): # noqa: ANN001
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
|
||||||
|
return [
|
||||||
|
ExposableEntity(
|
||||||
|
key=entity_key,
|
||||||
|
component="sensor",
|
||||||
|
device=DeviceInfo(identifiers=("modbus", "abc"), name="Test Meter"),
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="Test Meter Voltage",
|
||||||
|
value_getter=lambda sess: 230.2, # non-serialisable callable
|
||||||
|
state_class="measurement",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return _provider
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def expose_client(auth_database):
|
||||||
|
"""TestClient + engine for Expose API tests, with real DB but no real broker."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers to insert/read toggle rows directly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _count_toggles(engine) -> int:
|
||||||
|
with Session(engine) as session:
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
return session.query(ExposedEntityToggle).count()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_toggle(engine, key: str) -> bool | None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
row = session.query(ExposedEntityToggle).filter(ExposedEntityToggle.key == key).first()
|
||||||
|
return None if row is None else row.enabled
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: GET /api/expose
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetExpose:
|
||||||
|
def test_unauthenticated_returns_401(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
resp = client.get("/api/expose")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_authenticated_returns_catalog_and_status(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
fake_key = "modbus.abc.voltage"
|
||||||
|
with patch(
|
||||||
|
"app.integrations.expose._REGISTRY",
|
||||||
|
[_make_fake_provider(fake_key)],
|
||||||
|
):
|
||||||
|
resp = client.get("/api/expose")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Must have 'catalog' list and 'mqtt_status' dict
|
||||||
|
assert "catalog" in data
|
||||||
|
assert "mqtt_status" in data
|
||||||
|
|
||||||
|
# Default toggle is False (no row in DB)
|
||||||
|
assert len(data["catalog"]) == 1
|
||||||
|
entry = data["catalog"][0]
|
||||||
|
assert entry["entity"]["key"] == fake_key
|
||||||
|
assert entry["enabled"] is False
|
||||||
|
|
||||||
|
# value_getter must NOT appear in the serialised entity
|
||||||
|
assert "value_getter" not in entry["entity"]
|
||||||
|
|
||||||
|
def test_mqtt_status_fields_present(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", []):
|
||||||
|
resp = client.get("/api/expose")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
status_data = resp.json()["mqtt_status"]
|
||||||
|
assert "mqtt_configured" in status_data
|
||||||
|
assert "mqtt_connected" in status_data
|
||||||
|
assert "discovery_enabled" in status_data
|
||||||
|
|
||||||
|
def test_entity_fields_correct(self, expose_client):
|
||||||
|
"""Verify the entity schema shape matches ExposableEntitySchema."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.integrations.expose._REGISTRY",
|
||||||
|
[_make_fake_provider("modbus.abc.voltage")],
|
||||||
|
):
|
||||||
|
resp = client.get("/api/expose")
|
||||||
|
|
||||||
|
entity = resp.json()["catalog"][0]["entity"]
|
||||||
|
for field in ("key", "component", "device", "device_class", "unit", "name"):
|
||||||
|
assert field in entity, f"Missing field: {field}"
|
||||||
|
# device sub-fields
|
||||||
|
assert "identifiers" in entity["device"]
|
||||||
|
assert "name" in entity["device"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: PUT /api/expose
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPutExpose:
|
||||||
|
def test_unauthenticated_returns_401(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
resp = client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_missing_csrf_returns_403(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.put("/api/expose", json={"toggles": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_toggles_persisted_in_db(self, expose_client):
|
||||||
|
client, engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
fake_key = "modbus.abc.voltage"
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||||
|
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {fake_key: True}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Row inserted
|
||||||
|
assert _get_toggle(engine, fake_key) is True
|
||||||
|
|
||||||
|
def test_toggle_upsert(self, expose_client):
|
||||||
|
"""Second PUT on same key updates the existing row rather than inserting another."""
|
||||||
|
client, engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
fake_key = "modbus.abc.voltage"
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||||
|
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||||
|
# Enable
|
||||||
|
client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {fake_key: True}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
# Disable (upsert)
|
||||||
|
resp = client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {fake_key: False}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Still exactly one row
|
||||||
|
assert _count_toggles(engine) == 1
|
||||||
|
assert _get_toggle(engine, fake_key) is False
|
||||||
|
|
||||||
|
def test_toggle_change_triggers_discovery(self, expose_client):
|
||||||
|
"""PUT on toggles calls _trigger_republish (which calls publish_discovery)."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
fake_key = "modbus.abc.voltage"
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||||
|
with patch(
|
||||||
|
"app.api.routes.api.expose._trigger_republish"
|
||||||
|
) as mock_republish:
|
||||||
|
resp = client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {fake_key: True}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_republish.assert_called_once()
|
||||||
|
|
||||||
|
def test_response_contains_updated_catalog(self, expose_client):
|
||||||
|
"""PUT response reflects the newly set toggle state."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
fake_key = "modbus.abc.voltage"
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||||
|
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {fake_key: True}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert "catalog" in data
|
||||||
|
# The entry for fake_key should now be enabled=True
|
||||||
|
entries = {e["entity"]["key"]: e["enabled"] for e in data["catalog"]}
|
||||||
|
assert entries[fake_key] is True
|
||||||
|
|
||||||
|
def test_value_getter_not_in_put_response(self, expose_client):
|
||||||
|
"""value_getter callable must not appear in the PUT response."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
fake_key = "modbus.abc.voltage"
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||||
|
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/expose",
|
||||||
|
json={"toggles": {fake_key: True}},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
entity = resp.json()["catalog"][0]["entity"]
|
||||||
|
assert "value_getter" not in entity
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: POST /api/expose/republish
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostRepublish:
|
||||||
|
def test_unauthenticated_returns_401(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/expose/republish",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_missing_csrf_returns_403(self, expose_client):
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post("/api/expose/republish")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_republish_when_mqtt_not_configured(self, expose_client):
|
||||||
|
"""When MQTT is not enabled/connected, republish returns ok=False."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Ensure MQTT is not configured (default in test env)
|
||||||
|
with patch.object(
|
||||||
|
type(
|
||||||
|
__import__(
|
||||||
|
"app.integrations.mqtt", fromlist=["mqtt_manager"]
|
||||||
|
).mqtt_manager
|
||||||
|
),
|
||||||
|
"is_connected",
|
||||||
|
new_callable=lambda: property(lambda self: False),
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/expose/republish",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
def test_republish_calls_publish_discovery_when_mqtt_on(self, expose_client):
|
||||||
|
"""When MQTT is active, republish calls publish_discovery."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Patch settings to enable MQTT + discovery
|
||||||
|
mock_settings: Any = MagicMock()
|
||||||
|
mock_settings.mqtt_enabled = True
|
||||||
|
mock_settings.ha_discovery_enabled = True
|
||||||
|
mock_settings.ha_discovery_prefix = "homeassistant"
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.expose.build_runtime_settings", return_value=mock_settings):
|
||||||
|
with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
|
||||||
|
mock_mqtt.is_configured.return_value = True
|
||||||
|
mock_mqtt.is_connected = True
|
||||||
|
with patch(
|
||||||
|
"app.api.routes.api.expose._trigger_republish"
|
||||||
|
) as mock_republish:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/expose/republish",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
mock_republish.assert_called_once()
|
||||||
|
|
||||||
|
def test_trigger_republish_calls_publish_discovery(self):
|
||||||
|
"""Unit test: _trigger_republish calls publish_discovery with the session."""
|
||||||
|
from app.api.routes.api.expose import _trigger_republish
|
||||||
|
|
||||||
|
mock_session: Any = MagicMock()
|
||||||
|
|
||||||
|
# publish_discovery is imported inside _trigger_republish; patch it
|
||||||
|
# at the source module so all code paths see the mock.
|
||||||
|
with patch("app.services.ha_discovery.publish_discovery") as mock_pd:
|
||||||
|
_trigger_republish(mock_session)
|
||||||
|
mock_pd.assert_called_once_with(mock_session)
|
||||||
|
|
||||||
|
def test_republish_mqtt_not_connected_returns_false(self, expose_client):
|
||||||
|
"""Republish when MQTT not connected returns ok=False (not an exception)."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Ensure settings say MQTT is enabled but mqtt_manager says not connected
|
||||||
|
mock_settings: Any = MagicMock()
|
||||||
|
mock_settings.mqtt_enabled = True
|
||||||
|
mock_settings.ha_discovery_enabled = True
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.expose.build_runtime_settings", return_value=mock_settings):
|
||||||
|
with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
|
||||||
|
mock_mqtt.is_connected = False
|
||||||
|
resp = client.post(
|
||||||
|
"/api/expose/republish",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: device grouping in catalog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogGrouping:
|
||||||
|
def test_catalog_groups_entities_by_device(self, expose_client):
|
||||||
|
"""Multiple entities from the same device share the same device info."""
|
||||||
|
client, _engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
def _multi_entity_provider(session):
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
|
||||||
|
device_info = DeviceInfo(identifiers=("modbus", "uuid-1"), name="My Meter")
|
||||||
|
return [
|
||||||
|
ExposableEntity(
|
||||||
|
key="modbus.uuid-1.voltage",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="My Meter Voltage",
|
||||||
|
),
|
||||||
|
ExposableEntity(
|
||||||
|
key="modbus.uuid-1.current",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="current",
|
||||||
|
unit="A",
|
||||||
|
name="My Meter Current",
|
||||||
|
),
|
||||||
|
ExposableEntity(
|
||||||
|
key="modbus.uuid-1.online",
|
||||||
|
component="binary_sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="connectivity",
|
||||||
|
unit="",
|
||||||
|
name="My Meter Online",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("app.integrations.expose._REGISTRY", [_multi_entity_provider]):
|
||||||
|
resp = client.get("/api/expose")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
catalog = resp.json()["catalog"]
|
||||||
|
assert len(catalog) == 3
|
||||||
|
|
||||||
|
# All entities share the same device name
|
||||||
|
device_names = {e["entity"]["device"]["name"] for e in catalog}
|
||||||
|
assert device_names == {"My Meter"}
|
||||||
|
|
||||||
|
# binary_sensor is present (not-sensor component)
|
||||||
|
components = {e["entity"]["component"] for e in catalog}
|
||||||
|
assert "binary_sensor" in components
|
||||||
|
assert "sensor" in components
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M5-fix2: DB-config → mqtt_status runtime assertion (Issue 2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMqttStatusReadsFromDB:
|
||||||
|
def test_get_expose_mqtt_configured_reflects_db_value(self, expose_client):
|
||||||
|
"""GET /api/expose must return mqtt_configured=True when MQTT is enabled+configured
|
||||||
|
in the DB (app_config), even if the bootstrap env says disabled.
|
||||||
|
|
||||||
|
This verifies that _get_mqtt_status uses build_runtime_settings (DB-merged)
|
||||||
|
rather than bare get_settings() (bootstrap-only).
|
||||||
|
"""
|
||||||
|
client, engine = expose_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Write MQTT_ENABLED=true and MQTT_BROKER_HOST into app_config directly.
|
||||||
|
# The lifespan seeds all config keys on startup, so we must UPDATE existing rows.
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from app.models.config import AppConfigEntry
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
for key, value in [("MQTT_ENABLED", "true"), ("MQTT_BROKER_HOST", "broker.test.local")]:
|
||||||
|
row = session.query(AppConfigEntry).filter(AppConfigEntry.key == key).first()
|
||||||
|
if row is None:
|
||||||
|
session.add(AppConfigEntry(key=key, value=value, updated_at=now))
|
||||||
|
else:
|
||||||
|
row.value = value
|
||||||
|
row.updated_at = now
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# The bootstrap env does NOT have MQTT enabled (default False in Settings).
|
||||||
|
# But GET /api/expose should still report mqtt_configured=True because it reads DB.
|
||||||
|
with patch("app.integrations.expose._REGISTRY", []):
|
||||||
|
resp = client.get("/api/expose")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mqtt_status = resp.json()["mqtt_status"]
|
||||||
|
assert mqtt_status["mqtt_configured"] is True, (
|
||||||
|
f"Expected mqtt_configured=True from DB settings, "
|
||||||
|
f"got {mqtt_status['mqtt_configured']!r}. Full status: {mqtt_status}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
"""Tests for M5-T05: Modbus JSON API (app/api/routes/api/modbus.py).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- GET /api/modbus/profiles — list profiles
|
||||||
|
- GET /api/modbus/devices — list devices
|
||||||
|
- POST /api/modbus/devices — create device (profile validation, auth/CSRF)
|
||||||
|
- GET /api/modbus/devices/{uuid} — get single device
|
||||||
|
- PATCH /api/modbus/devices/{uuid} — update device (enable/disable, profile validation)
|
||||||
|
- DELETE /api/modbus/devices/{uuid} — delete with 409 guard (app-layer, not FK)
|
||||||
|
- GET /api/modbus/devices/{uuid}/latest — latest reading (found / not-found)
|
||||||
|
- GET /api/modbus/devices/{uuid}/readings — time-range + limit cap
|
||||||
|
- GET /api/modbus/devices/{uuid}/metrics — profile metric catalogue
|
||||||
|
- POST /api/modbus/devices/{uuid}/test — test-read (mocked driver, not persisted)
|
||||||
|
|
||||||
|
Auth/CSRF matrix:
|
||||||
|
- Unauthenticated read → 401
|
||||||
|
- Authenticated write without CSRF → 403
|
||||||
|
- Authenticated read → 200/201/204
|
||||||
|
- Authenticated write + CSRF → 200/201/204
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.models.modbus import ModbusDevice, ModbusReading
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
|
||||||
|
def _create_device_payload(**overrides) -> dict[str, Any]:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"friendly_name": "Test Meter",
|
||||||
|
"host": "10.0.0.1",
|
||||||
|
"port": 502,
|
||||||
|
"unit_id": 1,
|
||||||
|
"profile": "sdm120",
|
||||||
|
"poll_interval_s": 5,
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def modbus_client(auth_database):
|
||||||
|
"""TestClient + engine for Modbus 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()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_device(engine, **kwargs) -> ModbusDevice:
|
||||||
|
"""Insert a ModbusDevice row directly and return it."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name=kwargs.get("friendly_name", "Test Meter"),
|
||||||
|
host=kwargs.get("host", "10.0.0.1"),
|
||||||
|
port=kwargs.get("port", 502),
|
||||||
|
unit_id=kwargs.get("unit_id", 1),
|
||||||
|
profile=kwargs.get("profile", "sdm120"),
|
||||||
|
poll_interval_s=kwargs.get("poll_interval_s", 5),
|
||||||
|
enabled=kwargs.get("enabled", True),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(device)
|
||||||
|
# Detach from this session so caller can inspect plain attributes.
|
||||||
|
device_id = device.id
|
||||||
|
device_uuid = device.uuid
|
||||||
|
device_friendly_name = device.friendly_name
|
||||||
|
device_host = device.host
|
||||||
|
device_profile = device.profile
|
||||||
|
|
||||||
|
# Return a simple namespace-like object (uuid is what we mostly need).
|
||||||
|
class _DeviceStub:
|
||||||
|
id = device_id
|
||||||
|
uuid = device_uuid
|
||||||
|
friendly_name = device_friendly_name
|
||||||
|
host = device_host
|
||||||
|
profile = device_profile
|
||||||
|
|
||||||
|
return _DeviceStub()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_reading(engine, device_id: int, recorded_at: datetime, payload: dict) -> None:
|
||||||
|
"""Insert a ModbusReading row directly."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
reading = ModbusReading(
|
||||||
|
device_id=device_id,
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
resp = client.get("/api/modbus/profiles")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_returns_list(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/modbus/profiles")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "profiles" in body
|
||||||
|
assert isinstance(body["profiles"], list)
|
||||||
|
# sdm120 profile must be present
|
||||||
|
names = [p["name"] for p in body["profiles"]]
|
||||||
|
assert "sdm120" in names
|
||||||
|
# Each profile has name + description
|
||||||
|
for p in body["profiles"]:
|
||||||
|
assert "name" in p
|
||||||
|
assert "description" in p
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/devices
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
resp = client.get("/api/modbus/devices")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_empty(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/modbus/devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_returns_created_device(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get("/api/modbus/devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["items"][0]["uuid"] == device.uuid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/modbus/devices
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_device_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/modbus/devices",
|
||||||
|
json=_create_device_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_device_missing_csrf_returns_403(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post("/api/modbus/devices", json=_create_device_payload())
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_device_success(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/modbus/devices",
|
||||||
|
json=_create_device_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["friendly_name"] == "Test Meter"
|
||||||
|
assert "uuid" in body
|
||||||
|
assert len(body["uuid"]) == 36 # uuid4 format
|
||||||
|
|
||||||
|
# Verify row exists in DB
|
||||||
|
with Session(engine) as session:
|
||||||
|
devices = session.query(ModbusDevice).all()
|
||||||
|
assert len(devices) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_device_invalid_profile_returns_422(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/modbus/devices",
|
||||||
|
json=_create_device_payload(profile="nonexistent_profile_xyz"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "nonexistent_profile_xyz" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/devices/{uuid}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_device_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_device_not_found_returns_404(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/modbus/devices/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_device_returns_correct_fields(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine, friendly_name="My Meter")
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["uuid"] == device.uuid
|
||||||
|
assert body["friendly_name"] == "My Meter"
|
||||||
|
assert body["profile"] == "sdm120"
|
||||||
|
assert "last_poll_at" in body
|
||||||
|
assert "last_poll_ok" in body
|
||||||
|
assert "created_at" in body
|
||||||
|
assert "updated_at" in body
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/modbus/devices/{uuid}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_device_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
json={"enabled": False},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_device_missing_csrf_returns_403(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
json={"enabled": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_device_enable_disable(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine, enabled=True)
|
||||||
|
|
||||||
|
# Disable
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
json={"enabled": False},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["enabled"] is False
|
||||||
|
|
||||||
|
# Re-enable
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
json={"enabled": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_device_invalid_profile_returns_422(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
json={"profile": "nonexistent_xyz"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_device_updates_correct_fields(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
json={"friendly_name": "Renamed Meter", "port": 503},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["friendly_name"] == "Renamed Meter"
|
||||||
|
assert body["port"] == 503
|
||||||
|
# Other fields unchanged
|
||||||
|
assert body["uuid"] == device.uuid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /api/modbus/devices/{uuid}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_device_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_device_missing_csrf_returns_403(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.delete(f"/api/modbus/devices/{device.uuid}")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_device_no_readings_succeeds(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Confirm device is gone
|
||||||
|
with Session(engine) as session:
|
||||||
|
devices = session.query(ModbusDevice).all()
|
||||||
|
assert len(devices) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_device_with_readings_returns_409(modbus_client):
|
||||||
|
"""Delete with existing readings must return 409 (application-layer check, not FK)."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
# Add a reading
|
||||||
|
_make_reading(
|
||||||
|
engine,
|
||||||
|
device_id=device.id,
|
||||||
|
recorded_at=datetime.now(UTC),
|
||||||
|
payload={"voltage": 230.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/modbus/devices/{device.uuid}",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "reading" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
# Device still exists
|
||||||
|
with Session(engine) as session:
|
||||||
|
devices = session.query(ModbusDevice).all()
|
||||||
|
assert len(devices) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_device_not_found_returns_404(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.delete(
|
||||||
|
"/api/modbus/devices/00000000-0000-0000-0000-000000000000",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/devices/{uuid}/latest
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_no_readings_returns_found_false(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/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
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_returns_most_recent_reading(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
earlier = now - timedelta(minutes=10)
|
||||||
|
later = now - timedelta(minutes=1)
|
||||||
|
|
||||||
|
_make_reading(engine, device.id, earlier, {"voltage": 228.0})
|
||||||
|
_make_reading(engine, device.id, later, {"voltage": 232.0})
|
||||||
|
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["found"] is True
|
||||||
|
assert body["payload"] == {"voltage": 232.0}
|
||||||
|
assert body["recorded_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/devices/{uuid}/readings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_empty_returns_empty_list(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_returns_all_by_default_ordered_ascending(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
t1 = now - timedelta(minutes=30)
|
||||||
|
t2 = now - timedelta(minutes=20)
|
||||||
|
t3 = now - timedelta(minutes=10)
|
||||||
|
|
||||||
|
_make_reading(engine, device.id, t3, {"voltage": 233.0})
|
||||||
|
_make_reading(engine, device.id, t1, {"voltage": 231.0})
|
||||||
|
_make_reading(engine, device.id, t2, {"voltage": 232.0})
|
||||||
|
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert len(items) == 3
|
||||||
|
# Ascending order
|
||||||
|
voltages = [item["payload"]["voltage"] for item in items]
|
||||||
|
assert voltages == [231.0, 232.0, 233.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_time_window_filters_correctly(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
t_old = now - timedelta(hours=2)
|
||||||
|
t_mid = now - timedelta(hours=1)
|
||||||
|
t_new = now - timedelta(minutes=5)
|
||||||
|
|
||||||
|
_make_reading(engine, device.id, t_old, {"voltage": 228.0})
|
||||||
|
_make_reading(engine, device.id, t_mid, {"voltage": 230.0})
|
||||||
|
_make_reading(engine, device.id, t_new, {"voltage": 232.0})
|
||||||
|
|
||||||
|
# Only the last 90 minutes
|
||||||
|
start = (now - timedelta(minutes=90)).isoformat()
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/readings",
|
||||||
|
params={"start": start},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert len(items) == 2
|
||||||
|
voltages = {item["payload"]["voltage"] for item in items}
|
||||||
|
assert 228.0 not in voltages
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_limit_applied(modbus_client):
|
||||||
|
"""When limit < total rows, exactly *limit* items are returned."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for i in range(10):
|
||||||
|
_make_reading(engine, device.id, now - timedelta(minutes=i), {"voltage": float(i)})
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/readings",
|
||||||
|
params={"limit": 3},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["items"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_limit_returns_most_recent_rows_ascending(modbus_client):
|
||||||
|
"""When window rows > limit, the MOST RECENT limit rows are returned, ascending.
|
||||||
|
|
||||||
|
This is the regression test for REWORK-1: previously the endpoint returned
|
||||||
|
the oldest N rows (ASC + LIMIT), causing 6h/24h charts to show stale data.
|
||||||
|
Now it returns the most recent N rows (DESC + LIMIT, then reversed to ASC).
|
||||||
|
"""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
# Insert 5 readings spread over the last 5 minutes (newest first, but order shouldn't matter).
|
||||||
|
timestamps = [now - timedelta(minutes=i) for i in range(5)] # t0=now, t1=now-1m, ..., t4=now-4m
|
||||||
|
for idx, ts in enumerate(timestamps):
|
||||||
|
_make_reading(engine, device.id, ts, {"voltage": float(200 + idx)})
|
||||||
|
# Chronologically: t4 < t3 < t2 < t1 < t0
|
||||||
|
# Voltages mapped: 204 203 202 201 200
|
||||||
|
|
||||||
|
# Request only the most recent 3 rows (limit=3).
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/readings",
|
||||||
|
params={"limit": 3},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert len(items) == 3
|
||||||
|
|
||||||
|
# Response must be in ascending recorded_at order.
|
||||||
|
recorded_ats = [item["recorded_at"] for item in items]
|
||||||
|
assert recorded_ats == sorted(recorded_ats), "Items must be ascending by recorded_at"
|
||||||
|
|
||||||
|
# The 3 most recent are timestamps[0], [1], [2] → voltages 200, 201, 202.
|
||||||
|
voltages = [item["payload"]["voltage"] for item in items]
|
||||||
|
assert voltages == [202.0, 201.0, 200.0], (
|
||||||
|
f"Expected most recent 3 rows (voltages 200/201/202 ascending), got {voltages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The oldest row (voltage 204.0, at t4=now-4m) must NOT be in the response.
|
||||||
|
assert 204.0 not in voltages, "Oldest row must not appear when limit truncates the window"
|
||||||
|
assert 203.0 not in voltages, "Second-oldest row must not appear when limit truncates"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_limit_exceeds_max_returns_422(modbus_client):
|
||||||
|
"""A limit > 5000 must be rejected by FastAPI query validation (422)."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/readings",
|
||||||
|
params={"limit": 9999},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/modbus/devices/{uuid}/metrics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_returns_profile_catalogue(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine, profile="sdm120")
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["profile"] == "sdm120"
|
||||||
|
assert isinstance(body["metrics"], list)
|
||||||
|
assert len(body["metrics"]) > 0
|
||||||
|
# Each metric has required fields
|
||||||
|
for m in body["metrics"]:
|
||||||
|
assert "key" in m
|
||||||
|
assert "label" in m
|
||||||
|
assert "unit" in m
|
||||||
|
assert "device_class" in m
|
||||||
|
|
||||||
|
# SDM120 must include voltage
|
||||||
|
keys = [m["key"] for m in body["metrics"]]
|
||||||
|
assert "voltage" in keys
|
||||||
|
|
||||||
|
# Label is human-readable (not raw snake_case for "active_power")
|
||||||
|
active_power = next((m for m in body["metrics"] if m["key"] == "active_power"), None)
|
||||||
|
assert active_power is not None
|
||||||
|
assert "Active Power" == active_power["label"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_label_derived_from_key(modbus_client):
|
||||||
|
"""Verify the label derivation helper converts snake_case to Title Case."""
|
||||||
|
from app.api.routes.api.modbus import _label_from_key
|
||||||
|
|
||||||
|
assert _label_from_key("voltage") == "Voltage"
|
||||||
|
assert _label_from_key("active_power") == "Active Power"
|
||||||
|
assert _label_from_key("import_energy") == "Import Energy"
|
||||||
|
assert _label_from_key("power_factor") == "Power Factor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/modbus/devices/{uuid}/test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_read_unauthenticated_returns_401(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/test",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_read_missing_csrf_returns_403(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
resp = client.post(f"/api/modbus/devices/{device.uuid}/test")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_read_success_returns_payload_and_does_not_persist(modbus_client):
|
||||||
|
"""Successful test-read returns payload and stores nothing in modbus_reading."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
known_payload = {"voltage": 230.2, "current": 1.3}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read,
|
||||||
|
patch("app.api.routes.api.modbus.decode_profile") as mock_decode,
|
||||||
|
):
|
||||||
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
mock_decode.return_value = known_payload
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/test",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["payload"] == known_payload
|
||||||
|
assert body["error"] is None
|
||||||
|
|
||||||
|
# Verify nothing was persisted
|
||||||
|
with Session(engine) as session:
|
||||||
|
readings = session.query(ModbusReading).all()
|
||||||
|
assert len(readings) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_read_driver_failure_returns_ok_false(modbus_client):
|
||||||
|
"""When the driver raises, test-read returns ok=False with an error message."""
|
||||||
|
from app.integrations.modbus.driver import ModbusConnectionError
|
||||||
|
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read:
|
||||||
|
mock_read.side_effect = ModbusConnectionError("Cannot reach gateway")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/modbus/devices/{device.uuid}/test",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["ok"] is False
|
||||||
|
assert body["error"] is not None
|
||||||
|
assert "Cannot reach gateway" in body["error"]
|
||||||
|
|
||||||
|
# Still no readings in DB
|
||||||
|
with Session(engine) as session:
|
||||||
|
readings = session.query(ModbusReading).all()
|
||||||
|
assert len(readings) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_read_not_found_returns_404(modbus_client):
|
||||||
|
client, _ = modbus_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/modbus/devices/00000000-0000-0000-0000-000000000000/test",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Row count integrity: CRUD operations produce the right row count
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_crud_row_counts(modbus_client):
|
||||||
|
"""Create 2 devices, verify row count; delete one (no readings), verify count drops."""
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/modbus/devices",
|
||||||
|
json=_create_device_payload(friendly_name="Meter A"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
uuid_a = resp1.json()["uuid"]
|
||||||
|
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/modbus/devices",
|
||||||
|
json=_create_device_payload(friendly_name="Meter B"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
# 2 devices exist
|
||||||
|
with Session(engine) as session:
|
||||||
|
count = session.query(ModbusDevice).count()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Delete Meter A (no readings)
|
||||||
|
resp_del = client.delete(
|
||||||
|
f"/api/modbus/devices/{uuid_a}",
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_del.status_code == 204
|
||||||
|
|
||||||
|
# 1 device remains
|
||||||
|
with Session(engine) as session:
|
||||||
|
count = session.query(ModbusDevice).count()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_each_item_has_recorded_at_and_payload(modbus_client):
|
||||||
|
client, engine = modbus_client
|
||||||
|
_login(client)
|
||||||
|
device = _make_device(engine)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
_make_reading(engine, device.id, now, {"voltage": 230.0, "current": 1.5})
|
||||||
|
|
||||||
|
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
item = items[0]
|
||||||
|
assert "recorded_at" in item
|
||||||
|
assert "payload" in item
|
||||||
|
assert item["payload"]["voltage"] == 230.0
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
"""Tests for M5-T09: exposed_entities table + ExposableEntity/provider framework.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. ExposableEntity dataclass: fields, key stability.
|
||||||
|
2. Provider registry: register_provider, get_providers.
|
||||||
|
3. build_catalog: entity enumeration, device grouping, metadata from profile,
|
||||||
|
toggle defaults (enabled=False), binary_sensor presence.
|
||||||
|
4. Migration shape: upgrade creates the table + unique index; downgrade drops it.
|
||||||
|
5. ORM round-trip: toggle insert/read/update; default enabled=False.
|
||||||
|
6. APP_BASELINE_REVISION matches the new Alembic head.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 _make_modbus_device(session: Session, *, friendly_name: str, uuid: str) -> object:
|
||||||
|
"""Insert a ModbusDevice row into a test DB session and return it."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.models.modbus import ModbusDevice
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
device = ModbusDevice(
|
||||||
|
uuid=uuid,
|
||||||
|
friendly_name=friendly_name,
|
||||||
|
host="192.168.1.1",
|
||||||
|
port=502,
|
||||||
|
unit_id=1,
|
||||||
|
profile="sdm120",
|
||||||
|
poll_interval_s=5,
|
||||||
|
enabled=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.flush()
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def expose_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to the current Alembic head (includes new toggle table)."""
|
||||||
|
db_path = tmp_path / "expose_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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. ExposableEntity / DeviceInfo dataclass tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposable_entity_fields():
|
||||||
|
"""ExposableEntity must have all required fields with correct defaults."""
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
|
||||||
|
device = DeviceInfo(identifiers=("modbus", "abc-uuid"), name="Test Device")
|
||||||
|
entity = ExposableEntity(
|
||||||
|
key="modbus.abc-uuid.voltage",
|
||||||
|
component="sensor",
|
||||||
|
device=device,
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="Test Device Voltage",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entity.key == "modbus.abc-uuid.voltage"
|
||||||
|
assert entity.component == "sensor"
|
||||||
|
assert entity.device.identifiers == ("modbus", "abc-uuid")
|
||||||
|
assert entity.device.name == "Test Device"
|
||||||
|
assert entity.device_class == "voltage"
|
||||||
|
assert entity.unit == "V"
|
||||||
|
assert entity.name == "Test Device Voltage"
|
||||||
|
assert entity.value_getter is None # default
|
||||||
|
assert entity.state_class is None # default
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposable_entity_with_value_getter():
|
||||||
|
"""ExposableEntity.value_getter stores and calls the provided callable.
|
||||||
|
|
||||||
|
value_getter now accepts a Session argument (updated in T11-rework-1).
|
||||||
|
"""
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
device = DeviceInfo(identifiers=("modbus", "dev1"), name="D1")
|
||||||
|
# The value_getter receives a Session; use a lambda that ignores it.
|
||||||
|
getter = lambda _sess: 230.5 # noqa: E731
|
||||||
|
entity = ExposableEntity(
|
||||||
|
key="modbus.dev1.voltage",
|
||||||
|
component="sensor",
|
||||||
|
device=device,
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="D1 Voltage",
|
||||||
|
value_getter=getter,
|
||||||
|
)
|
||||||
|
assert entity.value_getter is not None
|
||||||
|
eng = create_engine("sqlite:///:memory:")
|
||||||
|
with Session(eng) as session:
|
||||||
|
assert entity.value_getter(session) == 230.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_key_stability_uses_uuid_not_id():
|
||||||
|
"""Keys must be derived from uuid (stable), never from auto-increment DB ids."""
|
||||||
|
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||||
|
|
||||||
|
uuid_val = "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
key = f"modbus.{uuid_val}.voltage"
|
||||||
|
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Stable Device")
|
||||||
|
entity = ExposableEntity(
|
||||||
|
key=key,
|
||||||
|
component="sensor",
|
||||||
|
device=device,
|
||||||
|
device_class="voltage",
|
||||||
|
unit="V",
|
||||||
|
name="Stable Device Voltage",
|
||||||
|
)
|
||||||
|
# Key is uuid-based: it must not contain any integer-only segment that
|
||||||
|
# looks like an auto-increment id (i.e., it contains the full uuid string).
|
||||||
|
assert uuid_val in entity.key
|
||||||
|
assert "modbus" in entity.key
|
||||||
|
assert "voltage" in entity.key
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Provider registry tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_provider_as_decorator():
|
||||||
|
"""register_provider used as a decorator must add the callable to the registry."""
|
||||||
|
from app.integrations.expose import get_providers, register_provider
|
||||||
|
|
||||||
|
before = len(get_providers())
|
||||||
|
|
||||||
|
@register_provider
|
||||||
|
def _dummy_provider(session):
|
||||||
|
return []
|
||||||
|
|
||||||
|
after_providers = get_providers()
|
||||||
|
assert len(after_providers) == before + 1
|
||||||
|
assert _dummy_provider in after_providers
|
||||||
|
|
||||||
|
# Clean up: remove the dummy from the module-level registry.
|
||||||
|
from app.integrations import expose as _expose_mod
|
||||||
|
_expose_mod._REGISTRY.remove(_dummy_provider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_provider_direct_call():
|
||||||
|
"""register_provider called directly must also register the callable."""
|
||||||
|
from app.integrations.expose import get_providers, register_provider
|
||||||
|
from app.integrations import expose as _expose_mod
|
||||||
|
|
||||||
|
def _another_dummy(session):
|
||||||
|
return []
|
||||||
|
|
||||||
|
before = len(get_providers())
|
||||||
|
returned = register_provider(_another_dummy)
|
||||||
|
assert returned is _another_dummy # returns the provider unchanged
|
||||||
|
assert len(get_providers()) == before + 1
|
||||||
|
|
||||||
|
# Clean up.
|
||||||
|
_expose_mod._REGISTRY.remove(_another_dummy)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. build_catalog tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_empty_with_no_devices(expose_db):
|
||||||
|
"""build_catalog with no enabled devices must return an empty list."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
# The modbus provider will find no enabled devices; other providers may
|
||||||
|
# add entries only if registered. Since we only register the modbus
|
||||||
|
# provider at module load, the result should be empty.
|
||||||
|
assert catalog == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_produces_entities_for_enabled_device(expose_db):
|
||||||
|
"""build_catalog must yield entities (including binary_sensor) for each enabled device."""
|
||||||
|
from app.integrations.expose import build_catalog, CatalogEntry
|
||||||
|
|
||||||
|
# Insert a test device.
|
||||||
|
test_uuid = "aaaaaaaa-0000-0000-0000-000000000001"
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="SDM120 Test", uuid=test_uuid)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
assert len(catalog) > 0, "Expected at least one entity for the enabled device"
|
||||||
|
|
||||||
|
keys = [entry.entity.key for entry in catalog]
|
||||||
|
|
||||||
|
# Must contain a 'modbus.<uuid>.online' binary_sensor.
|
||||||
|
online_key = f"modbus.{test_uuid}.online"
|
||||||
|
assert online_key in keys, f"Expected online entity key {online_key!r} in catalog"
|
||||||
|
|
||||||
|
# Must contain at least one sensor entity (voltage is always in sdm120 profile).
|
||||||
|
voltage_key = f"modbus.{test_uuid}.voltage"
|
||||||
|
assert voltage_key in keys, f"Expected voltage entity key {voltage_key!r} in catalog"
|
||||||
|
|
||||||
|
# Verify CatalogEntry type.
|
||||||
|
assert all(isinstance(e, CatalogEntry) for e in catalog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_default_enabled_false(expose_db):
|
||||||
|
"""Entities with no toggle row must default to enabled=False."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
test_uuid = "aaaaaaaa-0000-0000-0000-000000000002"
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="SDM120 B", uuid=test_uuid)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
# No toggle rows inserted → all must be disabled.
|
||||||
|
assert all(not entry.enabled for entry in catalog), (
|
||||||
|
"All catalog entries must default to enabled=False when no toggle row exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_respects_toggle_enabled(expose_db):
|
||||||
|
"""An entity with a toggle row enabled=True must appear as enabled in the catalog."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
test_uuid = "aaaaaaaa-0000-0000-0000-000000000003"
|
||||||
|
voltage_key = f"modbus.{test_uuid}.voltage"
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="SDM120 C", uuid=test_uuid)
|
||||||
|
# Enable the voltage entity explicitly.
|
||||||
|
toggle = ExposedEntityToggle(
|
||||||
|
key=voltage_key,
|
||||||
|
enabled=True,
|
||||||
|
updated_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
session.add(toggle)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
enabled_keys = {entry.entity.key for entry in catalog if entry.enabled}
|
||||||
|
assert voltage_key in enabled_keys, (
|
||||||
|
f"Expected {voltage_key!r} to be enabled after toggling"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other entities (no toggle row) must still default to disabled.
|
||||||
|
online_key = f"modbus.{test_uuid}.online"
|
||||||
|
disabled_keys = {entry.entity.key for entry in catalog if not entry.enabled}
|
||||||
|
assert online_key in disabled_keys, f"Expected {online_key!r} to remain disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_device_grouping(expose_db):
|
||||||
|
"""All entities for a device must share the same DeviceInfo (device grouping)."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
test_uuid = "aaaaaaaa-0000-0000-0000-000000000004"
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="SDM120 D", uuid=test_uuid)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
device_entities = [e for e in catalog if test_uuid in e.entity.key]
|
||||||
|
assert len(device_entities) > 0
|
||||||
|
|
||||||
|
# All entities for this device must use the same device identifiers tuple.
|
||||||
|
identifiers_set = {e.entity.device.identifiers for e in device_entities}
|
||||||
|
assert len(identifiers_set) == 1, (
|
||||||
|
"All entities for one device must share the same DeviceInfo identifiers"
|
||||||
|
)
|
||||||
|
assert identifiers_set.pop() == ("modbus", test_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_metric_metadata_from_profile(expose_db):
|
||||||
|
"""Entity device_class and unit must match the sdm120 profile's metrics."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.integrations.modbus.profiles import load_profile
|
||||||
|
|
||||||
|
test_uuid = "aaaaaaaa-0000-0000-0000-000000000005"
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="SDM120 E", uuid=test_uuid)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
metric_map = {m.key: m for m in profile.metrics}
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
sensor_entities = [
|
||||||
|
e for e in catalog
|
||||||
|
if e.entity.component == "sensor" and test_uuid in e.entity.key
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in sensor_entities:
|
||||||
|
# Extract metric key from entity key: "modbus.<uuid>.<metric_key>"
|
||||||
|
_, _, metric_key = entry.entity.key.split(".", 2)
|
||||||
|
if metric_key in metric_map:
|
||||||
|
metric_spec = metric_map[metric_key]
|
||||||
|
assert entry.entity.device_class == (metric_spec.device_class or None), (
|
||||||
|
f"device_class mismatch for {metric_key!r}: "
|
||||||
|
f"expected {metric_spec.device_class!r}, "
|
||||||
|
f"got {entry.entity.device_class!r}"
|
||||||
|
)
|
||||||
|
assert entry.entity.unit == metric_spec.unit, (
|
||||||
|
f"unit mismatch for {metric_key!r}: "
|
||||||
|
f"expected {metric_spec.unit!r}, got {entry.entity.unit!r}"
|
||||||
|
)
|
||||||
|
assert entry.entity.component == metric_spec.ha_component, (
|
||||||
|
f"component mismatch for {metric_key!r}: "
|
||||||
|
f"expected {metric_spec.ha_component!r}, "
|
||||||
|
f"got {entry.entity.component!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_online_binary_sensor_present(expose_db):
|
||||||
|
"""Each enabled device must produce exactly one binary_sensor 'online' entity."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
uuid1 = "aaaaaaaa-0000-0000-0000-000000000006"
|
||||||
|
uuid2 = "aaaaaaaa-0000-0000-0000-000000000007"
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="Device F1", uuid=uuid1)
|
||||||
|
_make_modbus_device(session, friendly_name="Device F2", uuid=uuid2)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
binary_sensors = [e for e in catalog if e.entity.component == "binary_sensor"]
|
||||||
|
binary_sensor_keys = {e.entity.key for e in binary_sensors}
|
||||||
|
|
||||||
|
assert f"modbus.{uuid1}.online" in binary_sensor_keys
|
||||||
|
assert f"modbus.{uuid2}.online" in binary_sensor_keys
|
||||||
|
# No sensor must masquerade as binary_sensor and vice versa.
|
||||||
|
assert all("online" in e.entity.key for e in binary_sensors), (
|
||||||
|
"All binary_sensor entities should be 'online' entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_has_non_sensor_component(expose_db):
|
||||||
|
"""The catalog must contain at least one entity with component != 'sensor'."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
test_uuid = "aaaaaaaa-0000-0000-0000-000000000008"
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
_make_modbus_device(session, friendly_name="SDM120 G", uuid=test_uuid)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
non_sensor = [e for e in catalog if e.entity.component != "sensor"]
|
||||||
|
assert len(non_sensor) >= 1, (
|
||||||
|
"Expected at least one non-sensor component (binary_sensor 'online')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_only_includes_enabled_devices(expose_db):
|
||||||
|
"""Disabled devices must not contribute entities to the catalog."""
|
||||||
|
from app.models.modbus import ModbusDevice
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
enabled_uuid = "aaaaaaaa-0000-0000-0000-000000000009"
|
||||||
|
disabled_uuid = "aaaaaaaa-0000-0000-0000-00000000000a"
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
enabled_device = ModbusDevice(
|
||||||
|
uuid=enabled_uuid,
|
||||||
|
friendly_name="Enabled Device",
|
||||||
|
host="10.0.0.1",
|
||||||
|
port=502,
|
||||||
|
unit_id=1,
|
||||||
|
profile="sdm120",
|
||||||
|
poll_interval_s=5,
|
||||||
|
enabled=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
disabled_device = ModbusDevice(
|
||||||
|
uuid=disabled_uuid,
|
||||||
|
friendly_name="Disabled Device",
|
||||||
|
host="10.0.0.2",
|
||||||
|
port=502,
|
||||||
|
unit_id=2,
|
||||||
|
profile="sdm120",
|
||||||
|
poll_interval_s=5,
|
||||||
|
enabled=False,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add_all([enabled_device, disabled_device])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
all_keys = {e.entity.key for e in catalog}
|
||||||
|
assert any(enabled_uuid in k for k in all_keys), "Enabled device must be in catalog"
|
||||||
|
assert not any(disabled_uuid in k for k in all_keys), (
|
||||||
|
"Disabled device must NOT be in catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_key_uses_uuid_not_db_id(expose_db):
|
||||||
|
"""Entity keys must contain the device uuid, not the integer DB primary key."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
test_uuid = "bbbbbbbb-1111-0000-0000-000000000001"
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
device = _make_modbus_device(session, friendly_name="UUID Key Test", uuid=test_uuid)
|
||||||
|
session.commit()
|
||||||
|
db_id = device.id # type: ignore[union-attr]
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
entity_keys = [e.entity.key for e in catalog if test_uuid in e.entity.key]
|
||||||
|
assert len(entity_keys) > 0, "Expected entities for the test device"
|
||||||
|
|
||||||
|
# Keys must contain the uuid string and not be keyed by integer id only.
|
||||||
|
for key in entity_keys:
|
||||||
|
assert test_uuid in key, f"Key {key!r} must contain the uuid {test_uuid!r}"
|
||||||
|
# Ensure the integer DB id does not appear where the uuid should be.
|
||||||
|
parts = key.split(".")
|
||||||
|
assert str(db_id) not in parts[1:2], (
|
||||||
|
f"Key {key!r} must not use the integer DB id {db_id} in the uuid slot"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Migration shape tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposed_entity_toggle_table_exists_after_upgrade(expose_db):
|
||||||
|
"""exposed_entity_toggle table must exist after upgrade to head."""
|
||||||
|
inspector = inspect(expose_db)
|
||||||
|
assert "exposed_entity_toggle" in inspector.get_table_names(), (
|
||||||
|
"exposed_entity_toggle table missing after upgrade to head"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposed_entity_toggle_columns(expose_db):
|
||||||
|
"""exposed_entity_toggle must have id, key, enabled, updated_at columns."""
|
||||||
|
inspector = inspect(expose_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("exposed_entity_toggle")}
|
||||||
|
|
||||||
|
assert "id" in columns
|
||||||
|
assert "key" in columns
|
||||||
|
assert "enabled" in columns
|
||||||
|
assert "updated_at" in columns
|
||||||
|
|
||||||
|
# id must be non-nullable.
|
||||||
|
assert not columns["id"]["nullable"]
|
||||||
|
# key must be non-nullable.
|
||||||
|
assert not columns["key"]["nullable"]
|
||||||
|
# enabled must be non-nullable.
|
||||||
|
assert not columns["enabled"]["nullable"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposed_entity_toggle_key_unique_constraint(expose_db):
|
||||||
|
"""exposed_entity_toggle.key must have a unique constraint."""
|
||||||
|
inspector = inspect(expose_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("exposed_entity_toggle")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "key" in unique_cols, "key column must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposed_entity_toggle_key_unique_index(expose_db):
|
||||||
|
"""exposed_entity_toggle must have a unique index on key."""
|
||||||
|
inspector = inspect(expose_db)
|
||||||
|
indexes = {idx["name"]: idx for idx in inspector.get_indexes("exposed_entity_toggle")}
|
||||||
|
assert "ix_exposed_entity_toggle_key" in indexes, (
|
||||||
|
"Index ix_exposed_entity_toggle_key missing"
|
||||||
|
)
|
||||||
|
assert indexes["ix_exposed_entity_toggle_key"]["unique"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_removes_toggle_table(tmp_path: Path):
|
||||||
|
"""downgrade -1 from head must drop the exposed_entity_toggle table cleanly."""
|
||||||
|
db_path = tmp_path / "downgrade_expose_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)
|
||||||
|
assert "exposed_entity_toggle" in inspector.get_table_names()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# Downgrade one step removes the exposed_entity_toggle revision.
|
||||||
|
command.downgrade(alembic_cfg, "-1")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
table_names = inspector.get_table_names()
|
||||||
|
assert "exposed_entity_toggle" not in table_names, (
|
||||||
|
"exposed_entity_toggle should be gone after downgrade -1"
|
||||||
|
)
|
||||||
|
# Previous tables must still exist.
|
||||||
|
assert "modbus_device" in table_names
|
||||||
|
assert "modbus_reading" in table_names
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. ORM round-trip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_expose_toggle_in_base_metadata():
|
||||||
|
"""Base.metadata.tables must include exposed_entity_toggle."""
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
# Importing the model registers it with Base.metadata.
|
||||||
|
from app.models.expose import ExposedEntityToggle # noqa: F401
|
||||||
|
|
||||||
|
assert "exposed_entity_toggle" in Base.metadata.tables, (
|
||||||
|
"exposed_entity_toggle not in Base.metadata after model import"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expose_toggle_insert_and_read(expose_db):
|
||||||
|
"""ExposedEntityToggle can be inserted and retrieved correctly."""
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
key = "modbus.test-uuid.voltage"
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
toggle = ExposedEntityToggle(key=key, enabled=True, updated_at=now)
|
||||||
|
session.add(toggle)
|
||||||
|
session.commit()
|
||||||
|
toggle_id = toggle.id
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
fetched = session.get(ExposedEntityToggle, toggle_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.key == key
|
||||||
|
assert fetched.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_expose_toggle_default_enabled_is_false(expose_db):
|
||||||
|
"""ExposedEntityToggle.enabled must default to False after INSERT (column default).
|
||||||
|
|
||||||
|
SQLAlchemy applies ``default=False`` at INSERT time, not at Python object
|
||||||
|
construction time. We verify the column default by inserting a row without
|
||||||
|
providing ``enabled`` and reading it back.
|
||||||
|
"""
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
toggle = ExposedEntityToggle(key="default.enabled.test", updated_at=now)
|
||||||
|
session.add(toggle)
|
||||||
|
session.commit()
|
||||||
|
toggle_id = toggle.id
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
fetched = session.get(ExposedEntityToggle, toggle_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.enabled is False, (
|
||||||
|
"ExposedEntityToggle.enabled must default to False (not True, not None)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expose_toggle_key_uniqueness_enforced(expose_db):
|
||||||
|
"""Inserting two rows with the same key must raise an IntegrityError."""
|
||||||
|
import sqlalchemy.exc
|
||||||
|
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
key = "modbus.dup-uuid.power"
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
session.add(ExposedEntityToggle(key=key, enabled=False, updated_at=now))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
session.add(ExposedEntityToggle(key=key, enabled=True, updated_at=now))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_expose_toggle_update(expose_db):
|
||||||
|
"""Updating an existing toggle row must persist the new enabled value."""
|
||||||
|
from app.models.expose import ExposedEntityToggle
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
key = "modbus.update-uuid.current"
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
toggle = ExposedEntityToggle(key=key, enabled=False, updated_at=now)
|
||||||
|
session.add(toggle)
|
||||||
|
session.commit()
|
||||||
|
toggle_id = toggle.id
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
toggle = session.get(ExposedEntityToggle, toggle_id)
|
||||||
|
toggle.enabled = True
|
||||||
|
toggle.updated_at = datetime.now(tz=timezone.utc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
fetched = session.get(ExposedEntityToggle, toggle_id)
|
||||||
|
assert fetched.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. APP_BASELINE_REVISION
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_baseline_revision_matches_new_head(tmp_path: Path):
|
||||||
|
"""APP_BASELINE_REVISION must equal the Alembic head after the new migration."""
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
# 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}"
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
|||||||
|
"""Tests for scripts/modbus_cli.py.
|
||||||
|
|
||||||
|
Acceptance criteria covered
|
||||||
|
----------------------------
|
||||||
|
1. ``modbus_cli read`` (via mock) prints decoded engineering values for all
|
||||||
|
profile metrics; columns are present for each metric key.
|
||||||
|
2. ``modbus_cli read`` exits non-zero on connection failure.
|
||||||
|
3. ``modbus_cli probe --fc 4`` prints raw register values in hex; optionally
|
||||||
|
decodes float32 pairs.
|
||||||
|
4. ``modbus_cli probe`` exits non-zero on connection failure.
|
||||||
|
5. CLI has NO write-register sub-command (only ``read`` and ``probe`` exist).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is available when tests are run from the repo root.
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
import scripts.modbus_cli as cli # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ok_response(registers: list[int]) -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.isError.return_value = False
|
||||||
|
resp.registers = registers
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_error_response() -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.isError.return_value = True
|
||||||
|
resp.__str__ = lambda self: "ExceptionResponse(2)"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI: read sub-command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadCommand:
|
||||||
|
"""``python -m scripts.modbus_cli read ...`` with mocked driver."""
|
||||||
|
|
||||||
|
def _make_sdm120_registers(self) -> dict[int, int]:
|
||||||
|
"""Return a minimal register map covering all 8 sdm120 core metrics."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
def pack(value: float) -> tuple[int, int]:
|
||||||
|
raw = struct.pack(">f", value)
|
||||||
|
hi = (raw[0] << 8) | raw[1]
|
||||||
|
lo = (raw[2] << 8) | raw[3]
|
||||||
|
return hi, lo
|
||||||
|
|
||||||
|
pairs: dict[int, tuple[int, int]] = {
|
||||||
|
0x0000: pack(230.2), # voltage
|
||||||
|
0x0006: pack(1.3), # current
|
||||||
|
0x000C: pack(295.0), # active_power
|
||||||
|
0x001E: pack(0.98), # power_factor
|
||||||
|
0x0046: pack(50.0), # frequency
|
||||||
|
0x0048: pack(123.4), # import_energy
|
||||||
|
0x004A: pack(0.0), # export_energy
|
||||||
|
0x0156: pack(123.4), # total_energy
|
||||||
|
}
|
||||||
|
regs: dict[int, int] = {}
|
||||||
|
for addr, (hi, lo) in pairs.items():
|
||||||
|
regs[addr] = hi
|
||||||
|
regs[addr + 1] = lo
|
||||||
|
return regs
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_read_prints_decoded_values(self, mock_client_cls: MagicMock, capsys) -> None:
|
||||||
|
"""``read`` prints all profile metric keys with values."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
|
||||||
|
regs = self._make_sdm120_registers()
|
||||||
|
# sdm120 has 2 blocks; simulate both responses.
|
||||||
|
# Block 1: 0x0000, count=0x0060 → we need 0x60=96 registers
|
||||||
|
block1 = [regs.get(i, 0) for i in range(0x0000, 0x0060)]
|
||||||
|
# Block 2: 0x0156, count=0x0004 → 4 registers
|
||||||
|
block2 = [regs.get(i, 0) for i in range(0x0156, 0x015A)]
|
||||||
|
mock_client.read_input_registers.side_effect = [
|
||||||
|
_make_ok_response(block1),
|
||||||
|
_make_ok_response(block2),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Run the CLI.
|
||||||
|
cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1",
|
||||||
|
"--profile", "sdm120"])
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
output = captured.out
|
||||||
|
|
||||||
|
# All eight core metric keys must appear in the output.
|
||||||
|
for key in ("voltage", "current", "active_power", "power_factor",
|
||||||
|
"frequency", "import_energy", "export_energy", "total_energy"):
|
||||||
|
assert key in output, f"Missing metric key '{key}' in output:\n{output}"
|
||||||
|
|
||||||
|
# The decoded voltage should be close to 230.2.
|
||||||
|
assert "230." in output, f"Expected voltage ~230.2 in output:\n{output}"
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_read_exits_nonzero_on_connection_failure(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Connection failure causes ``sys.exit(1)``."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = False # Cannot connect.
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1",
|
||||||
|
"--profile", "sdm120"])
|
||||||
|
assert exc_info.value.code != 0
|
||||||
|
|
||||||
|
def test_read_exits_nonzero_for_missing_profile(self, capsys) -> None:
|
||||||
|
"""Unknown profile name causes sys.exit(1)."""
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1",
|
||||||
|
"--profile", "nonexistent_profile_xyz"])
|
||||||
|
assert exc_info.value.code != 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI: probe sub-command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeCommand:
|
||||||
|
"""``python -m scripts.modbus_cli probe ...`` with mocked pymodbus."""
|
||||||
|
|
||||||
|
@patch("scripts.modbus_cli.ModbusTcpClient" if False else "pymodbus.client.ModbusTcpClient")
|
||||||
|
def _run_probe(self, mock_cls, args: list[str], regs: list[int]):
|
||||||
|
"""Helper: runs probe with a mocked client returning *regs*."""
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_probe_prints_raw_registers(self, mock_driver_cls, capsys) -> None:
|
||||||
|
# probe uses its own client import, not the driver's — patch it there.
|
||||||
|
pass # see next test for the direct approach
|
||||||
|
|
||||||
|
def _setup_mock_client(self, regs: list[int]) -> MagicMock:
|
||||||
|
"""Return a mock ModbusTcpClient that returns *regs* for both FC03 and FC04."""
|
||||||
|
mock_cls = MagicMock()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
ok_response = _make_ok_response(regs)
|
||||||
|
mock_client.read_input_registers.return_value = ok_response
|
||||||
|
mock_client.read_holding_registers.return_value = ok_response
|
||||||
|
return mock_cls
|
||||||
|
|
||||||
|
def test_probe_fc4_prints_hex_values(self, capsys) -> None:
|
||||||
|
"""FC04 probe prints at least two hex addresses and values."""
|
||||||
|
mock_cls = self._setup_mock_client([0x4366, 0x3334])
|
||||||
|
|
||||||
|
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||||
|
cli.main([
|
||||||
|
"probe",
|
||||||
|
"--host", "10.0.0.1",
|
||||||
|
"--port", "502",
|
||||||
|
"--unit", "1",
|
||||||
|
"--fc", "4",
|
||||||
|
"--address", "0x0000",
|
||||||
|
"--count", "2",
|
||||||
|
])
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
output = captured.out
|
||||||
|
# Expect hex addresses and values in the output.
|
||||||
|
assert "0x0000" in output.lower() or "0000" in output
|
||||||
|
assert "4366" in output.upper()
|
||||||
|
assert "3334" in output.upper()
|
||||||
|
|
||||||
|
def test_probe_fc4_with_float32_decode(self, capsys) -> None:
|
||||||
|
"""--decode float32 adds a float32 decode section to output."""
|
||||||
|
mock_cls = self._setup_mock_client([0x4366, 0x3334])
|
||||||
|
|
||||||
|
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||||
|
cli.main([
|
||||||
|
"probe",
|
||||||
|
"--host", "10.0.0.1",
|
||||||
|
"--port", "502",
|
||||||
|
"--unit", "1",
|
||||||
|
"--fc", "4",
|
||||||
|
"--address", "0x0000",
|
||||||
|
"--count", "2",
|
||||||
|
"--decode", "float32",
|
||||||
|
])
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
output = captured.out
|
||||||
|
# The float decode section should show ~230.2.
|
||||||
|
assert "230." in output, f"Expected ~230.2 in float decode output:\n{output}"
|
||||||
|
|
||||||
|
def test_probe_exits_nonzero_on_connection_failure(self, capsys) -> None:
|
||||||
|
"""Connection failure causes non-zero exit."""
|
||||||
|
mock_cls = MagicMock()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = False
|
||||||
|
|
||||||
|
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cli.main([
|
||||||
|
"probe",
|
||||||
|
"--host", "10.0.0.1",
|
||||||
|
"--port", "502",
|
||||||
|
"--unit", "1",
|
||||||
|
"--fc", "4",
|
||||||
|
"--address", "0x0000",
|
||||||
|
"--count", "2",
|
||||||
|
])
|
||||||
|
assert exc_info.value.code != 0
|
||||||
|
|
||||||
|
def test_probe_fc3_also_works(self, capsys) -> None:
|
||||||
|
"""FC03 (holding registers) is supported by probe."""
|
||||||
|
mock_cls = self._setup_mock_client([0x0001, 0x0002])
|
||||||
|
|
||||||
|
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||||
|
cli.main([
|
||||||
|
"probe",
|
||||||
|
"--host", "10.0.0.1",
|
||||||
|
"--port", "502",
|
||||||
|
"--unit", "1",
|
||||||
|
"--fc", "3",
|
||||||
|
"--address", "0x0014",
|
||||||
|
"--count", "2",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Verify holding-register read was called (not input-register read).
|
||||||
|
mock_client = mock_cls.return_value
|
||||||
|
mock_client.read_holding_registers.assert_called_once()
|
||||||
|
mock_client.read_input_registers.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI: no write sub-commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoWriteSubcommands:
|
||||||
|
"""The CLI must not expose any write-register sub-commands."""
|
||||||
|
|
||||||
|
def test_no_write_subcommand_in_parser(self) -> None:
|
||||||
|
"""The argument parser has no 'write', 'set', or 'force' sub-commands."""
|
||||||
|
parser = cli.build_parser()
|
||||||
|
# Access the subparsers group.
|
||||||
|
subparsers_actions = [
|
||||||
|
action
|
||||||
|
for action in parser._actions
|
||||||
|
if hasattr(action, "_name_parser_map")
|
||||||
|
]
|
||||||
|
assert len(subparsers_actions) == 1
|
||||||
|
available_commands = set(subparsers_actions[0]._name_parser_map.keys())
|
||||||
|
# Only read-only commands must exist.
|
||||||
|
assert "read" in available_commands
|
||||||
|
assert "probe" in available_commands
|
||||||
|
# No write-related commands.
|
||||||
|
for name in available_commands:
|
||||||
|
assert name not in {"write", "set", "force", "write-register", "set-register"}, (
|
||||||
|
f"Write sub-command '{name}' found in CLI — it must not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_read_and_probe_subcommands(self) -> None:
|
||||||
|
"""Exactly two sub-commands are available: read and probe."""
|
||||||
|
parser = cli.build_parser()
|
||||||
|
subparsers_actions = [
|
||||||
|
action
|
||||||
|
for action in parser._actions
|
||||||
|
if hasattr(action, "_name_parser_map")
|
||||||
|
]
|
||||||
|
available = set(subparsers_actions[0]._name_parser_map.keys())
|
||||||
|
assert available == {"read", "probe"}, (
|
||||||
|
f"Expected only {{read, probe}}, got {available}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Tests for app/integrations/modbus/driver.py.
|
||||||
|
|
||||||
|
All tests use mocks — no real hardware or network connection is required.
|
||||||
|
|
||||||
|
Acceptance criteria covered
|
||||||
|
----------------------------
|
||||||
|
1. ``registers_to_float(0x4366, 0x3334)`` decodes to ~230.2 (byte/word order correct).
|
||||||
|
2. ``read_blocks`` returns the expected register map on a successful response.
|
||||||
|
3. ``read_blocks`` raises ``ModbusConnectionError`` when the client cannot connect.
|
||||||
|
4. ``read_blocks`` raises ``ModbusConnectionError`` when ``ConnectionException`` is raised.
|
||||||
|
5. ``read_blocks`` raises ``ModbusResponseError`` when the response is a Modbus error frame.
|
||||||
|
6. ``read_blocks`` raises ``ModbusResponseError`` when register count mismatches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.integrations.modbus.driver import (
|
||||||
|
ModbusConnectionError,
|
||||||
|
ModbusDriverError,
|
||||||
|
ModbusResponseError,
|
||||||
|
read_blocks,
|
||||||
|
registers_to_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# registers_to_float
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistersToFloat:
|
||||||
|
"""Big-endian float32 decoding."""
|
||||||
|
|
||||||
|
def test_voltage_reference_value(self) -> None:
|
||||||
|
"""PDF example: 0x4366,0x3334 → 230.2 V."""
|
||||||
|
result = registers_to_float(0x4366, 0x3334)
|
||||||
|
# IEEE-754 nearest float32 for 230.2 is ~230.20001220703125
|
||||||
|
assert math.isclose(result, 230.2, rel_tol=1e-5), f"Got {result}"
|
||||||
|
|
||||||
|
def test_demand_time_reference_value(self) -> None:
|
||||||
|
"""PDF example: 0x3F80,0x0000 → 1.0."""
|
||||||
|
result = registers_to_float(0x3F80, 0x0000)
|
||||||
|
assert math.isclose(result, 1.0, rel_tol=1e-7)
|
||||||
|
|
||||||
|
def test_network_node_reference_value(self) -> None:
|
||||||
|
"""PDF example: 0x4270,0x0000 → 60.0."""
|
||||||
|
result = registers_to_float(0x4270, 0x0000)
|
||||||
|
assert math.isclose(result, 60.0, rel_tol=1e-7)
|
||||||
|
|
||||||
|
def test_zero(self) -> None:
|
||||||
|
result = registers_to_float(0x0000, 0x0000)
|
||||||
|
assert result == 0.0
|
||||||
|
|
||||||
|
def test_word_order_matters(self) -> None:
|
||||||
|
"""Swapping hi/lo gives a different (wrong) value."""
|
||||||
|
correct = registers_to_float(0x4366, 0x3334)
|
||||||
|
swapped = registers_to_float(0x3334, 0x4366)
|
||||||
|
assert correct != swapped
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# read_blocks — helpers for building mock pymodbus responses
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ok_response(registers: list[int]) -> MagicMock:
|
||||||
|
"""Build a fake successful read_input_registers response."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.isError.return_value = False
|
||||||
|
resp.registers = registers
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_error_response() -> MagicMock:
|
||||||
|
"""Build a fake Modbus exception response."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.isError.return_value = True
|
||||||
|
resp.__str__ = lambda self: "ExceptionResponse(2)"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadBlocks:
|
||||||
|
"""Unit tests for read_blocks using a mocked ModbusTcpClient."""
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_single_block_returns_register_map(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""Successful single-block read returns correct {addr: value} map."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_ok_response(
|
||||||
|
[0x4366, 0x3334, 0x3F80, 0x0000]
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks = [{"start": 0x0000, "count": 4}]
|
||||||
|
result = read_blocks("127.0.0.1", 502, 1, blocks)
|
||||||
|
|
||||||
|
assert result == {0: 0x4366, 1: 0x3334, 2: 0x3F80, 3: 0x0000}
|
||||||
|
mock_client.read_input_registers.assert_called_once_with(
|
||||||
|
0x0000, count=4, device_id=1
|
||||||
|
)
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_multiple_blocks_merged(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""Multiple blocks are read and merged into a single dict."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
|
||||||
|
# First block: 2 registers at 0x0000
|
||||||
|
resp1 = _make_ok_response([0x4366, 0x3334])
|
||||||
|
# Second block: 2 registers at 0x0156
|
||||||
|
resp2 = _make_ok_response([0x447A, 0x0000])
|
||||||
|
mock_client.read_input_registers.side_effect = [resp1, resp2]
|
||||||
|
|
||||||
|
blocks = [{"start": 0x0000, "count": 2}, {"start": 0x0156, "count": 2}]
|
||||||
|
result = read_blocks("127.0.0.1", 502, 1, blocks)
|
||||||
|
|
||||||
|
assert result[0x0000] == 0x4366
|
||||||
|
assert result[0x0001] == 0x3334
|
||||||
|
assert result[0x0156] == 0x447A
|
||||||
|
assert result[0x0157] == 0x0000
|
||||||
|
assert mock_client.read_input_registers.call_count == 2
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_connect_failure_raises_connection_error(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""connect() returning False raises ModbusConnectionError."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = False
|
||||||
|
|
||||||
|
with pytest.raises(ModbusConnectionError, match="Could not connect"):
|
||||||
|
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_connection_exception_raises_connection_error(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""pymodbus ConnectionException during connect() → ModbusConnectionError."""
|
||||||
|
from pymodbus.exceptions import ConnectionException
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.side_effect = ConnectionException("timeout")
|
||||||
|
|
||||||
|
with pytest.raises(ModbusConnectionError, match="failed"):
|
||||||
|
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_modbus_exception_response_raises_response_error(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Modbus exception frame from device raises ModbusResponseError."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_error_response()
|
||||||
|
|
||||||
|
with pytest.raises(ModbusResponseError, match="exception"):
|
||||||
|
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_register_count_mismatch_raises_response_error(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Response with wrong register count raises ModbusResponseError."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
# Requested 4 but only got 2
|
||||||
|
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
|
||||||
|
|
||||||
|
with pytest.raises(ModbusResponseError, match="Expected 4 registers"):
|
||||||
|
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 4}])
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_connection_exception_during_read_raises_connection_error(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""ConnectionException during register read → ModbusConnectionError."""
|
||||||
|
from pymodbus.exceptions import ConnectionException
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.side_effect = ConnectionException("dropped")
|
||||||
|
|
||||||
|
with pytest.raises(ModbusConnectionError, match="Lost connection"):
|
||||||
|
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_client_always_closed_on_error(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""close() is always called even when an exception propagates."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_error_response()
|
||||||
|
|
||||||
|
with pytest.raises(ModbusDriverError):
|
||||||
|
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_ok_response([0x0001, 0x0002])
|
||||||
|
|
||||||
|
read_blocks("10.0.0.1", 502, unit_id=5, blocks=[{"start": 0, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.read_input_registers.assert_called_once_with(
|
||||||
|
0, count=2, device_id=5
|
||||||
|
)
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""Tests for M5-T02: modbus_device + modbus_reading tables and ORM models.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. Migration shape: upgrade to head creates both tables with correct columns,
|
||||||
|
constraints, and indexes; downgrade -1 cleanly removes them.
|
||||||
|
2. ORM metadata: Base.metadata.tables contains both tables; FK is RESTRICT;
|
||||||
|
uuid column is unique and auto-generated.
|
||||||
|
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
|
||||||
|
4. Basic ORM round-trip: insert + retrieve, uuid auto-population, nullable fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
from app.models.modbus import ModbusDevice, ModbusReading
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def modbus_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to the current Alembic head."""
|
||||||
|
db_path = tmp_path / "modbus_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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Migration shape tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_tables_exist_after_upgrade(modbus_db):
|
||||||
|
"""Both modbus_device and modbus_reading tables must exist after upgrade to head."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
table_names = inspector.get_table_names()
|
||||||
|
assert "modbus_device" in table_names, "modbus_device table missing after upgrade"
|
||||||
|
assert "modbus_reading" in table_names, "modbus_reading table missing after upgrade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_device_columns(modbus_db):
|
||||||
|
"""modbus_device must have all required columns with correct nullability."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("modbus_device")}
|
||||||
|
|
||||||
|
expected_non_nullable = {
|
||||||
|
"id", "uuid", "friendly_name", "transport", "host", "port",
|
||||||
|
"unit_id", "profile", "poll_interval_s", "enabled",
|
||||||
|
"created_at", "updated_at",
|
||||||
|
}
|
||||||
|
expected_nullable = {"last_poll_at", "last_poll_ok"}
|
||||||
|
|
||||||
|
for col_name in expected_non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"Column {col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
for col_name in expected_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert columns[col_name]["nullable"], f"Column {col_name} should be nullable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_columns(modbus_db):
|
||||||
|
"""modbus_reading must have id, device_id, recorded_at, payload with correct nullability."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("modbus_reading")}
|
||||||
|
|
||||||
|
expected_non_nullable = {"id", "device_id", "recorded_at", "payload"}
|
||||||
|
for col_name in expected_non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"Column {col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_device_uuid_is_unique(modbus_db):
|
||||||
|
"""modbus_device.uuid must have a unique constraint."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("modbus_device")
|
||||||
|
unique_cols = [
|
||||||
|
col
|
||||||
|
for uc in unique_constraints
|
||||||
|
for col in uc["column_names"]
|
||||||
|
]
|
||||||
|
assert "uuid" in unique_cols, "uuid column must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_fk_to_device(modbus_db):
|
||||||
|
"""modbus_reading.device_id must have a FK referencing modbus_device.id."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
fks = inspector.get_foreign_keys("modbus_reading")
|
||||||
|
assert len(fks) == 1, f"Expected 1 FK on modbus_reading, got {len(fks)}"
|
||||||
|
fk = fks[0]
|
||||||
|
assert fk["referred_table"] == "modbus_device"
|
||||||
|
assert "device_id" in fk["constrained_columns"]
|
||||||
|
assert "id" in fk["referred_columns"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_composite_index_exists(modbus_db):
|
||||||
|
"""Composite index (device_id, recorded_at) must exist on modbus_reading."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
indexes = {idx["name"]: idx for idx in inspector.get_indexes("modbus_reading")}
|
||||||
|
assert "ix_modbus_reading_device_recorded" in indexes, (
|
||||||
|
"Composite index ix_modbus_reading_device_recorded missing"
|
||||||
|
)
|
||||||
|
composite_idx = indexes["ix_modbus_reading_device_recorded"]
|
||||||
|
assert "device_id" in composite_idx["column_names"]
|
||||||
|
assert "recorded_at" in composite_idx["column_names"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_recorded_at_index_exists(modbus_db):
|
||||||
|
"""Individual index on recorded_at must exist on modbus_reading."""
|
||||||
|
inspector = inspect(modbus_db)
|
||||||
|
indexes = {idx["name"]: idx for idx in inspector.get_indexes("modbus_reading")}
|
||||||
|
# The ORM-level index=True creates ix_modbus_reading_recorded_at
|
||||||
|
assert "ix_modbus_reading_recorded_at" in indexes, (
|
||||||
|
"Index ix_modbus_reading_recorded_at missing from modbus_reading"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_removes_modbus_tables(tmp_path: Path):
|
||||||
|
"""Downgrading past the modbus migration must drop both modbus tables cleanly.
|
||||||
|
|
||||||
|
We downgrade to the explicit revision ``20260621_08_totp`` (the down_revision
|
||||||
|
of the modbus tables migration), so the test stays correct as new revisions
|
||||||
|
are added on top. Using an explicit target rather than ``-2`` avoids drift
|
||||||
|
with chain growth (same fix pattern as T02 applied to the TOTP downgrade test).
|
||||||
|
"""
|
||||||
|
db_path = tmp_path / "downgrade_modbus_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)
|
||||||
|
assert "modbus_device" in inspector.get_table_names()
|
||||||
|
assert "modbus_reading" in inspector.get_table_names()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# Downgrade to the explicit down_revision of the modbus tables migration
|
||||||
|
# ("20260621_08_totp"), consistent with how T02 fixed the TOTP downgrade test.
|
||||||
|
# Using an explicit target instead of "-2" ensures the test stays correct even
|
||||||
|
# as new revisions are added on top of this one (no drift with chain growth).
|
||||||
|
command.downgrade(alembic_cfg, "20260621_08_totp")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
table_names = inspector.get_table_names()
|
||||||
|
assert "modbus_device" not in table_names, "modbus_device should be gone after downgrade"
|
||||||
|
assert "modbus_reading" not in table_names, "modbus_reading should be gone after downgrade"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. ORM metadata checks (Base.metadata)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_metadata_contains_modbus_tables():
|
||||||
|
"""Base.metadata.tables must include both new modbus tables."""
|
||||||
|
assert "modbus_device" in Base.metadata.tables, "modbus_device not in Base.metadata"
|
||||||
|
assert "modbus_reading" in Base.metadata.tables, "modbus_reading not in Base.metadata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_fk_ondelete_restrict():
|
||||||
|
"""The FK from modbus_reading.device_id to modbus_device.id must be ON DELETE RESTRICT."""
|
||||||
|
reading_table = Base.metadata.tables["modbus_reading"]
|
||||||
|
fk_columns = {
|
||||||
|
col.name: col
|
||||||
|
for col in reading_table.columns
|
||||||
|
}
|
||||||
|
device_id_col = fk_columns["device_id"]
|
||||||
|
assert device_id_col.foreign_keys, "device_id must have a foreign key"
|
||||||
|
fk = next(iter(device_id_col.foreign_keys))
|
||||||
|
assert fk.ondelete == "RESTRICT", (
|
||||||
|
f"FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_device_uuid_unique_constraint_in_metadata():
|
||||||
|
"""ModbusDevice.uuid column must be declared unique in ORM metadata."""
|
||||||
|
device_table = Base.metadata.tables["modbus_device"]
|
||||||
|
uuid_col = device_table.columns["uuid"]
|
||||||
|
assert uuid_col.unique, "ModbusDevice.uuid 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_modbus_device_uuid_auto_generated(modbus_db):
|
||||||
|
"""uuid must be auto-populated when not explicitly provided."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name="Test Meter",
|
||||||
|
host="192.168.1.100",
|
||||||
|
profile="sdm120",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.commit()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
fetched = session.get(ModbusDevice, device_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.uuid is not None, "uuid should be auto-populated"
|
||||||
|
assert len(fetched.uuid) == 36, "uuid should be a standard UUID4 string (36 chars)"
|
||||||
|
# Verify it's a valid UUID4 format
|
||||||
|
import uuid as _uuid_mod
|
||||||
|
parsed = _uuid_mod.UUID(fetched.uuid)
|
||||||
|
assert parsed.version == 4, "uuid should be version 4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_device_defaults(modbus_db):
|
||||||
|
"""Default values for port, unit_id, poll_interval_s, enabled, transport must apply."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name="Default Test",
|
||||||
|
host="10.0.0.1",
|
||||||
|
profile="sdm120",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.commit()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
fetched = session.get(ModbusDevice, device_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.port == 502
|
||||||
|
assert fetched.unit_id == 1
|
||||||
|
assert fetched.poll_interval_s == 5
|
||||||
|
assert fetched.enabled is True
|
||||||
|
assert fetched.transport == "tcp"
|
||||||
|
assert fetched.last_poll_at is None
|
||||||
|
assert fetched.last_poll_ok is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_device_two_unique_uuids(modbus_db):
|
||||||
|
"""Two independently created devices must have different auto-generated UUIDs."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
d1 = ModbusDevice(
|
||||||
|
friendly_name="Device 1", host="10.0.0.1", profile="sdm120",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
|
d2 = ModbusDevice(
|
||||||
|
friendly_name="Device 2", host="10.0.0.2", profile="sdm120",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
|
session.add_all([d1, d2])
|
||||||
|
session.commit()
|
||||||
|
assert d1.uuid != d2.uuid, "Two devices must receive distinct UUIDs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_insert_and_retrieve(modbus_db):
|
||||||
|
"""A ModbusReading can be inserted with a JSON payload and retrieved correctly."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
sample_payload = {
|
||||||
|
"voltage": 230.2,
|
||||||
|
"current": 1.3,
|
||||||
|
"active_power": 295.0,
|
||||||
|
"power_factor": 0.98,
|
||||||
|
"frequency": 50.0,
|
||||||
|
"import_energy": 123.4,
|
||||||
|
"export_energy": 0.0,
|
||||||
|
"total_energy": 123.4,
|
||||||
|
}
|
||||||
|
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name="SDM120 AC",
|
||||||
|
host="192.168.1.100",
|
||||||
|
profile="sdm120",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
reading = ModbusReading(
|
||||||
|
device_id=device.id,
|
||||||
|
recorded_at=now,
|
||||||
|
payload=sample_payload,
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
reading_id = reading.id
|
||||||
|
|
||||||
|
with Session(modbus_db) as session:
|
||||||
|
fetched = session.get(ModbusReading, reading_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.recorded_at is not None
|
||||||
|
assert fetched.payload == sample_payload
|
||||||
|
assert fetched.payload["voltage"] == 230.2
|
||||||
|
assert fetched.payload["frequency"] == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_restrict_prevents_device_deletion(tmp_path: Path):
|
||||||
|
"""Deleting a device with readings must fail due to ON DELETE RESTRICT.
|
||||||
|
|
||||||
|
SQLite only enforces FK constraints when ``PRAGMA foreign_keys = ON`` is set
|
||||||
|
on the connection, so we build a dedicated engine that enables this pragma.
|
||||||
|
"""
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from sqlalchemy import event as sa_event
|
||||||
|
|
||||||
|
db_path = tmp_path / "restrict_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})
|
||||||
|
|
||||||
|
# Enable FK enforcement for every connection on this engine.
|
||||||
|
@sa_event.listens_for(engine, "connect")
|
||||||
|
def _enable_fk(dbapi_conn, _rec):
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name="Restricted Device",
|
||||||
|
host="10.0.0.1",
|
||||||
|
profile="sdm120",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
reading = ModbusReading(
|
||||||
|
device_id=device.id,
|
||||||
|
recorded_at=now,
|
||||||
|
payload={"voltage": 230.0},
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
# Attempting to delete the device should raise an IntegrityError.
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(
|
||||||
|
text("DELETE FROM modbus_device WHERE id = :did"),
|
||||||
|
{"did": device_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_reading_restrict_enforced_via_app_engine(tmp_path: Path):
|
||||||
|
"""FK RESTRICT is enforced by the *default* app engine (db.py) without any manual pragma.
|
||||||
|
|
||||||
|
After enabling ``PRAGMA foreign_keys=ON`` in the db.py connect event, the app
|
||||||
|
engine must enforce ON DELETE RESTRICT on modbus_reading.device_id without
|
||||||
|
callers having to set the pragma themselves. This test uses the real app engine
|
||||||
|
obtained via ``app.db.get_engine`` (routed through the lru_cache, just like the
|
||||||
|
running app) so it exercises the exact production code path.
|
||||||
|
"""
|
||||||
|
import sqlalchemy.exc
|
||||||
|
|
||||||
|
from app.db import _get_engine, reset_db_caches
|
||||||
|
|
||||||
|
db_path = tmp_path / "app_engine_restrict_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
# Obtain engine through the same lru_cache path that production uses.
|
||||||
|
# Clear caches first so we get a fresh engine pointing at our tmp DB.
|
||||||
|
reset_db_caches()
|
||||||
|
engine = _get_engine(db_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name="App-Engine RESTRICT Test",
|
||||||
|
host="10.0.0.2",
|
||||||
|
profile="sdm120",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
reading = ModbusReading(
|
||||||
|
device_id=device.id,
|
||||||
|
recorded_at=now,
|
||||||
|
payload={"voltage": 230.0},
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
session.commit()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
# Without manual pragma, the app engine (db.py) must still enforce RESTRICT.
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(
|
||||||
|
text("DELETE FROM modbus_device WHERE id = :did"),
|
||||||
|
{"did": device_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
reset_db_caches()
|
||||||
|
engine.dispose()
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""Tests for M5-T04: Modbus polling service (app/services/modbus_poll.py).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
1. poll_all_enabled_devices inserts +N reading rows with correct payload
|
||||||
|
when the driver returns known register data.
|
||||||
|
2. A device whose driver raises leaves last_poll_ok=False; other devices
|
||||||
|
in the same sweep are still polled and their readings are stored.
|
||||||
|
3. modbus_polling_enabled=False causes the entire sweep to be skipped
|
||||||
|
(no readings inserted, no driver calls made).
|
||||||
|
4. Per-device interval: a device whose last_poll_at is too recent is
|
||||||
|
skipped; one whose interval has elapsed is polled.
|
||||||
|
5. poll_device returns None on driver failure and does not propagate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
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.config import Settings
|
||||||
|
from app.models.modbus import ModbusDevice, ModbusReading
|
||||||
|
from app.services.modbus_poll import poll_all_enabled_devices, poll_device
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers and 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 poll_db(tmp_path: Path):
|
||||||
|
"""Temporary SQLite DB upgraded to Alembic head, yields (engine, session_factory)."""
|
||||||
|
db_path = tmp_path / "poll_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_device(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
friendly_name: str = "Test Meter",
|
||||||
|
host: str = "10.0.0.1",
|
||||||
|
port: int = 502,
|
||||||
|
unit_id: int = 1,
|
||||||
|
profile: str = "sdm120",
|
||||||
|
poll_interval_s: int = 5,
|
||||||
|
enabled: bool = True,
|
||||||
|
last_poll_at: datetime | None = None,
|
||||||
|
last_poll_ok: bool | None = None,
|
||||||
|
) -> ModbusDevice:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
device = ModbusDevice(
|
||||||
|
friendly_name=friendly_name,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
unit_id=unit_id,
|
||||||
|
profile=profile,
|
||||||
|
poll_interval_s=poll_interval_s,
|
||||||
|
enabled=enabled,
|
||||||
|
last_poll_at=last_poll_at,
|
||||||
|
last_poll_ok=last_poll_ok,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
session.commit()
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# Known payload that matches what decode() should produce for a minimal SDM120 profile.
|
||||||
|
KNOWN_PAYLOAD: dict[str, float] = {
|
||||||
|
"voltage": 230.20001220703125,
|
||||||
|
"current": 1.2999999523162842,
|
||||||
|
"active_power": 295.0,
|
||||||
|
"power_factor": 0.9800000190734863,
|
||||||
|
"frequency": 50.0,
|
||||||
|
"import_energy": 123.4000015258789,
|
||||||
|
"export_energy": 0.0,
|
||||||
|
"total_energy": 123.4000015258789,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bootstrap_settings(**overrides) -> Settings:
|
||||||
|
"""Return a Settings instance with test defaults and optional overrides."""
|
||||||
|
return Settings(
|
||||||
|
_env_file=None,
|
||||||
|
app_database_url="sqlite:///:memory:",
|
||||||
|
auth_bootstrap_password="test",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Successful poll inserts +N readings with correct payload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_all_inserts_readings_for_enabled_devices(poll_db):
|
||||||
|
"""poll_all_enabled_devices inserts one reading per enabled device with correct payload."""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
d1 = _make_device(session, friendly_name="Meter A", host="10.0.0.1")
|
||||||
|
d2 = _make_device(session, friendly_name="Meter B", host="10.0.0.2")
|
||||||
|
|
||||||
|
# Verify baseline: no readings yet.
|
||||||
|
count_before = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(count_before) == 0
|
||||||
|
|
||||||
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
||||||
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
||||||
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
||||||
|
):
|
||||||
|
mock_profile = MagicMock()
|
||||||
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
||||||
|
mock_load.return_value = mock_profile
|
||||||
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
mock_decode.return_value = KNOWN_PAYLOAD
|
||||||
|
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 2, f"Expected 2 readings, got {len(readings)}"
|
||||||
|
|
||||||
|
# Both readings should have the known payload.
|
||||||
|
for reading in readings:
|
||||||
|
assert reading.payload == KNOWN_PAYLOAD, (
|
||||||
|
f"Payload mismatch for reading id={reading.id}: {reading.payload}"
|
||||||
|
)
|
||||||
|
assert reading.recorded_at is not None
|
||||||
|
|
||||||
|
# Devices should be marked last_poll_ok=True.
|
||||||
|
session.expire_all()
|
||||||
|
fetched_d1 = session.get(ModbusDevice, d1.id)
|
||||||
|
fetched_d2 = session.get(ModbusDevice, d2.id)
|
||||||
|
assert fetched_d1.last_poll_ok is True
|
||||||
|
assert fetched_d2.last_poll_ok is True
|
||||||
|
assert fetched_d1.last_poll_at is not None
|
||||||
|
assert fetched_d2.last_poll_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. One device fails — others still succeed; failed device gets last_poll_ok=False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_all_isolates_device_failure(poll_db):
|
||||||
|
"""A driver error for one device must not prevent other devices from being polled."""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
d_ok = _make_device(session, friendly_name="OK Meter", host="10.0.0.1")
|
||||||
|
d_fail = _make_device(session, friendly_name="Broken Meter", host="10.0.0.2")
|
||||||
|
|
||||||
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def fake_read_blocks(host, port, unit_id, blocks, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if host == "10.0.0.2":
|
||||||
|
raise ConnectionError("Cannot connect to broken meter")
|
||||||
|
return {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
||||||
|
patch("app.services.modbus_poll.driver.read_blocks", side_effect=fake_read_blocks),
|
||||||
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
||||||
|
):
|
||||||
|
mock_profile = MagicMock()
|
||||||
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
||||||
|
mock_load.return_value = mock_profile
|
||||||
|
mock_decode.return_value = KNOWN_PAYLOAD
|
||||||
|
|
||||||
|
# Must not raise even though one device fails.
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
|
||||||
|
# Only the OK meter should have a reading.
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 1, f"Expected 1 reading (OK meter only), got {len(readings)}"
|
||||||
|
assert readings[0].device_id == d_ok.id
|
||||||
|
|
||||||
|
# driver was called for both devices (fail attempt included).
|
||||||
|
assert call_count["n"] == 2
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
fetched_ok = session.get(ModbusDevice, d_ok.id)
|
||||||
|
fetched_fail = session.get(ModbusDevice, d_fail.id)
|
||||||
|
assert fetched_ok.last_poll_ok is True
|
||||||
|
assert fetched_fail.last_poll_ok is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. modbus_polling_enabled=False → entire sweep is skipped
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_all_skipped_when_polling_disabled(poll_db):
|
||||||
|
"""When modbus_polling_enabled=False, no devices are polled and no readings are inserted."""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
_make_device(session, friendly_name="Should Be Skipped", host="10.0.0.1")
|
||||||
|
|
||||||
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=False)
|
||||||
|
|
||||||
|
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
mock_read.assert_not_called()
|
||||||
|
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 0, "No readings should be inserted when polling is disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_all_skipped_via_env_var(poll_db, monkeypatch):
|
||||||
|
"""MODBUS_POLLING_ENABLED=false env var disables polling via Settings override."""
|
||||||
|
monkeypatch.setenv("MODBUS_POLLING_ENABLED", "false")
|
||||||
|
# Build settings after env var is set.
|
||||||
|
bootstrap = Settings(_env_file=None, app_database_url="sqlite:///:memory:")
|
||||||
|
|
||||||
|
assert bootstrap.modbus_polling_enabled is False, (
|
||||||
|
"MODBUS_POLLING_ENABLED=false should yield modbus_polling_enabled=False"
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
_make_device(session, friendly_name="Env-gated Meter", host="10.0.0.1")
|
||||||
|
|
||||||
|
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
mock_read.assert_not_called()
|
||||||
|
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Per-device interval: skip if recently polled, poll if interval elapsed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_all_respects_per_device_interval(poll_db):
|
||||||
|
"""Devices polled more recently than poll_interval_s are skipped; others are polled."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
# Device polled just 1 second ago with a 30-second interval → should be SKIPPED.
|
||||||
|
d_recent = _make_device(
|
||||||
|
session,
|
||||||
|
friendly_name="Recent Meter",
|
||||||
|
host="10.0.0.1",
|
||||||
|
poll_interval_s=30,
|
||||||
|
last_poll_at=now - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
# Device polled 60 seconds ago with a 30-second interval → should be POLLED.
|
||||||
|
d_due = _make_device(
|
||||||
|
session,
|
||||||
|
friendly_name="Due Meter",
|
||||||
|
host="10.0.0.2",
|
||||||
|
poll_interval_s=30,
|
||||||
|
last_poll_at=now - timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
# Device never polled before → should be POLLED.
|
||||||
|
d_new = _make_device(
|
||||||
|
session,
|
||||||
|
friendly_name="New Meter",
|
||||||
|
host="10.0.0.3",
|
||||||
|
poll_interval_s=30,
|
||||||
|
last_poll_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
||||||
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
||||||
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
||||||
|
):
|
||||||
|
mock_profile = MagicMock()
|
||||||
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
||||||
|
mock_load.return_value = mock_profile
|
||||||
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
mock_decode.return_value = {"voltage": 230.2}
|
||||||
|
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 2, (
|
||||||
|
f"Expected 2 readings (due + new), got {len(readings)}: "
|
||||||
|
f"{[r.device_id for r in readings]}"
|
||||||
|
)
|
||||||
|
polled_device_ids = {r.device_id for r in readings}
|
||||||
|
assert d_due.id in polled_device_ids, "Due meter should have been polled"
|
||||||
|
assert d_new.id in polled_device_ids, "New meter should have been polled"
|
||||||
|
assert d_recent.id not in polled_device_ids, "Recent meter should have been skipped"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. poll_device returns None on failure, does not propagate exception
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_device_returns_none_on_driver_failure(poll_db):
|
||||||
|
"""poll_device swallows driver exceptions and returns None."""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
device = _make_device(session, friendly_name="Failing Device", host="10.0.0.9")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
||||||
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
||||||
|
):
|
||||||
|
mock_profile = MagicMock()
|
||||||
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
||||||
|
mock_load.return_value = mock_profile
|
||||||
|
mock_read.side_effect = RuntimeError("Simulated driver failure")
|
||||||
|
|
||||||
|
# Must NOT raise.
|
||||||
|
result = poll_device(session, device)
|
||||||
|
|
||||||
|
assert result is None, "poll_device should return None on failure"
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
fetched = session.get(ModbusDevice, device.id)
|
||||||
|
assert fetched.last_poll_ok is False
|
||||||
|
assert fetched.last_poll_at is not None
|
||||||
|
|
||||||
|
# No reading row was created.
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_device_returns_reading_on_success(poll_db):
|
||||||
|
"""poll_device returns the inserted ModbusReading on success."""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
device = _make_device(session, friendly_name="Success Device", host="10.0.0.5")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
||||||
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
||||||
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
||||||
|
):
|
||||||
|
mock_profile = MagicMock()
|
||||||
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
||||||
|
mock_load.return_value = mock_profile
|
||||||
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
mock_decode.return_value = KNOWN_PAYLOAD
|
||||||
|
|
||||||
|
result = poll_device(session, device)
|
||||||
|
|
||||||
|
assert result is not None, "poll_device should return a ModbusReading on success"
|
||||||
|
assert isinstance(result, ModbusReading)
|
||||||
|
assert result.device_id == device.id
|
||||||
|
assert result.payload == KNOWN_PAYLOAD
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
fetched = session.get(ModbusDevice, device.id)
|
||||||
|
assert fetched.last_poll_ok is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Disabled devices are not polled
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_all_skips_disabled_devices(poll_db):
|
||||||
|
"""Devices with enabled=False must not be polled."""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
_make_device(session, friendly_name="Disabled Meter", host="10.0.0.1", enabled=False)
|
||||||
|
|
||||||
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
||||||
|
|
||||||
|
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
mock_read.assert_not_called()
|
||||||
|
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 0, "Disabled devices must not produce readings"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Rollback on commit failure: no reading survives a failed poll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_device_commit_failure_leaves_no_reading(poll_db):
|
||||||
|
"""When session.commit() raises after session.add(reading), the reading must NOT persist.
|
||||||
|
|
||||||
|
Scenario (cross-device): two devices, device A's success-branch commit fails,
|
||||||
|
device B's poll succeeds normally. Expected outcome:
|
||||||
|
- 1 reading row total (device B only).
|
||||||
|
- device A: last_poll_ok=False, no reading row.
|
||||||
|
- device B: last_poll_ok=True, 1 reading row.
|
||||||
|
|
||||||
|
This guards against the "zombie reading" bug where a pending ModbusReading
|
||||||
|
added in the success branch (before commit failed) survived in the session
|
||||||
|
and was flushed by device B's subsequent commit.
|
||||||
|
"""
|
||||||
|
with Session(poll_db) as session:
|
||||||
|
d_fail = _make_device(session, friendly_name="Commit-Fail Meter", host="10.0.0.1")
|
||||||
|
d_ok = _make_device(session, friendly_name="OK Meter", host="10.0.0.2")
|
||||||
|
|
||||||
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
||||||
|
|
||||||
|
# Track how many times commit() is called so we can make only the
|
||||||
|
# first call (device A's success-branch commit) raise while letting
|
||||||
|
# the subsequent calls (device A's failure-state commit, device B's
|
||||||
|
# success commit) succeed normally.
|
||||||
|
original_commit = session.commit
|
||||||
|
commit_call_count = {"n": 0}
|
||||||
|
|
||||||
|
def patched_commit():
|
||||||
|
commit_call_count["n"] += 1
|
||||||
|
if commit_call_count["n"] == 1:
|
||||||
|
raise RuntimeError("Simulated commit failure (DB write error)")
|
||||||
|
return original_commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
||||||
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
||||||
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
||||||
|
patch.object(session, "commit", side_effect=patched_commit),
|
||||||
|
):
|
||||||
|
mock_profile = MagicMock()
|
||||||
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
||||||
|
mock_load.return_value = mock_profile
|
||||||
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
mock_decode.return_value = KNOWN_PAYLOAD
|
||||||
|
|
||||||
|
# Must not raise despite the first commit failing.
|
||||||
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
||||||
|
|
||||||
|
# Only device B (OK Meter) should have a reading — device A's reading
|
||||||
|
# must have been rolled back and must NOT have been committed via
|
||||||
|
# device B's subsequent commit (the "zombie reading" scenario).
|
||||||
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
||||||
|
assert len(readings) == 1, (
|
||||||
|
f"Expected exactly 1 reading (OK Meter only), got {len(readings)}: "
|
||||||
|
f"device_ids={[r.device_id for r in readings]}"
|
||||||
|
)
|
||||||
|
assert readings[0].device_id == d_ok.id, (
|
||||||
|
"The surviving reading must belong to the OK Meter, not the commit-fail device"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
fetched_fail = session.get(ModbusDevice, d_fail.id)
|
||||||
|
fetched_ok = session.get(ModbusDevice, d_ok.id)
|
||||||
|
|
||||||
|
assert fetched_fail.last_poll_ok is False, (
|
||||||
|
"Commit-fail device must be marked last_poll_ok=False"
|
||||||
|
)
|
||||||
|
assert fetched_ok.last_poll_ok is True, (
|
||||||
|
"OK Meter must be marked last_poll_ok=True"
|
||||||
|
)
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Tests for app/integrations/modbus/profiles.py.
|
||||||
|
|
||||||
|
Acceptance criteria covered
|
||||||
|
----------------------------
|
||||||
|
1. ``load_profile("sdm120")`` succeeds and returns a valid ``ModbusProfile``.
|
||||||
|
2. ``decode(profile, registers)`` maps known register values to correct keys/values.
|
||||||
|
3. ``load_profile`` raises a recognisable error for a missing profile.
|
||||||
|
4. ``load_profile`` raises a recognisable error for a profile that fails validation.
|
||||||
|
5. ``list_profiles()`` returns at least one entry containing ``"sdm120"``.
|
||||||
|
6. ``decode`` silently skips metrics whose registers are absent from the map.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.integrations.modbus.profiles import (
|
||||||
|
ModbusProfile,
|
||||||
|
ProfileNotFoundError,
|
||||||
|
ProfileValidationError,
|
||||||
|
decode,
|
||||||
|
list_profiles,
|
||||||
|
load_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_profile — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadProfileSdm120:
|
||||||
|
"""Validate that the shipped sdm120.yaml loads and validates correctly."""
|
||||||
|
|
||||||
|
def test_load_returns_modbus_profile(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
assert isinstance(profile, ModbusProfile)
|
||||||
|
|
||||||
|
def test_name_and_description(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
assert profile.name == "sdm120"
|
||||||
|
assert "SDM120" in profile.description or "sdm120" in profile.description.lower()
|
||||||
|
|
||||||
|
def test_function_code_is_4(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
assert profile.function_code == 4
|
||||||
|
|
||||||
|
def test_word_and_byte_order_big(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
assert profile.word_order == "big"
|
||||||
|
assert profile.byte_order == "big"
|
||||||
|
|
||||||
|
def test_has_at_least_two_blocks(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
assert len(profile.blocks) >= 2
|
||||||
|
|
||||||
|
def test_has_core_metrics(self) -> None:
|
||||||
|
"""All eight core metrics are present."""
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
keys = {m.key for m in profile.metrics}
|
||||||
|
expected = {
|
||||||
|
"voltage",
|
||||||
|
"current",
|
||||||
|
"active_power",
|
||||||
|
"power_factor",
|
||||||
|
"frequency",
|
||||||
|
"import_energy",
|
||||||
|
"export_energy",
|
||||||
|
"total_energy",
|
||||||
|
}
|
||||||
|
assert expected.issubset(keys), f"Missing keys: {expected - keys}"
|
||||||
|
|
||||||
|
def test_voltage_address_is_0x0000(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
voltage = next(m for m in profile.metrics if m.key == "voltage")
|
||||||
|
assert voltage.address == 0x0000
|
||||||
|
|
||||||
|
def test_frequency_address_is_0x0046(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
freq = next(m for m in profile.metrics if m.key == "frequency")
|
||||||
|
assert freq.address == 0x0046
|
||||||
|
|
||||||
|
def test_total_energy_address_is_0x0156(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
te = next(m for m in profile.metrics if m.key == "total_energy")
|
||||||
|
assert te.address == 0x0156
|
||||||
|
|
||||||
|
def test_energy_metrics_have_state_class(self) -> None:
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
energy_keys = {"import_energy", "export_energy", "total_energy"}
|
||||||
|
for m in profile.metrics:
|
||||||
|
if m.key in energy_keys:
|
||||||
|
assert m.state_class == "total_increasing", (
|
||||||
|
f"{m.key} should have state_class='total_increasing'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_deployment_fields_in_profile(self) -> None:
|
||||||
|
"""Profile YAML must not contain unit_id or friendly_name."""
|
||||||
|
profile_path = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "app/integrations/modbus/profiles/sdm120.yaml"
|
||||||
|
)
|
||||||
|
with profile_path.open() as fh:
|
||||||
|
raw = yaml.safe_load(fh)
|
||||||
|
assert "unit_id" not in raw, "unit_id is a deployment field, not a protocol field"
|
||||||
|
assert "friendly_name" not in raw, "friendly_name is a deployment field"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_profile — error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadProfileErrors:
|
||||||
|
def test_missing_profile_raises_not_found(self) -> None:
|
||||||
|
with pytest.raises(ProfileNotFoundError, match="nonexistent_profile"):
|
||||||
|
load_profile("nonexistent_profile")
|
||||||
|
|
||||||
|
def test_invalid_yaml_content_raises_validation_error(self, tmp_path: Path) -> None:
|
||||||
|
"""A profile missing required fields raises ProfileValidationError."""
|
||||||
|
bad_yaml = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
name: bad_profile
|
||||||
|
description: Missing required fields
|
||||||
|
# function_code, word_order, byte_order, blocks, metrics are all absent
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
bad_path = tmp_path / "bad_profile.yaml"
|
||||||
|
bad_path.write_text(bad_yaml)
|
||||||
|
|
||||||
|
# Temporarily patch the profiles directory to include our bad file.
|
||||||
|
fake_dir = tmp_path
|
||||||
|
with patch("app.integrations.modbus.profiles._PROFILES_DIR", fake_dir):
|
||||||
|
with pytest.raises(ProfileValidationError, match="bad_profile"):
|
||||||
|
load_profile("bad_profile")
|
||||||
|
|
||||||
|
def test_wrong_type_in_yaml_raises_validation_error(self, tmp_path: Path) -> None:
|
||||||
|
"""A profile with wrong field types raises ProfileValidationError."""
|
||||||
|
bad_yaml = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
name: typed_wrong
|
||||||
|
description: Wrong type for function_code
|
||||||
|
function_code: "four" # should be int
|
||||||
|
word_order: big
|
||||||
|
byte_order: big
|
||||||
|
blocks: []
|
||||||
|
metrics: []
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
bad_path = tmp_path / "typed_wrong.yaml"
|
||||||
|
bad_path.write_text(bad_yaml)
|
||||||
|
|
||||||
|
with patch("app.integrations.modbus.profiles._PROFILES_DIR", tmp_path):
|
||||||
|
with pytest.raises(ProfileValidationError, match="typed_wrong"):
|
||||||
|
load_profile("typed_wrong")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# decode — register-to-value mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecode:
|
||||||
|
"""Verify decode() maps known register pairs to correct float values."""
|
||||||
|
|
||||||
|
def _make_registers(self, items: dict[int, tuple[int, int]]) -> dict[int, int]:
|
||||||
|
"""Build a register map from ``{address: (hi, lo)}`` pairs."""
|
||||||
|
regs: dict[int, int] = {}
|
||||||
|
for addr, (hi, lo) in items.items():
|
||||||
|
regs[addr] = hi
|
||||||
|
regs[addr + 1] = lo
|
||||||
|
return regs
|
||||||
|
|
||||||
|
def test_voltage_decoded_correctly(self) -> None:
|
||||||
|
"""0x4366,0x3334 at address 0x0000 → ~230.2V."""
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
registers = self._make_registers({0x0000: (0x4366, 0x3334)})
|
||||||
|
# Only voltage registers provided; others will be skipped.
|
||||||
|
result = decode(profile, registers)
|
||||||
|
assert "voltage" in result
|
||||||
|
assert math.isclose(result["voltage"], 230.2, rel_tol=1e-5)
|
||||||
|
|
||||||
|
def test_total_energy_decoded_correctly(self) -> None:
|
||||||
|
"""Float32 at address 0x0156 decoded as total_energy."""
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
# 0x447A0000 → 1000.0
|
||||||
|
registers = self._make_registers({0x0156: (0x447A, 0x0000)})
|
||||||
|
result = decode(profile, registers)
|
||||||
|
assert "total_energy" in result
|
||||||
|
assert math.isclose(result["total_energy"], 1000.0, rel_tol=1e-5)
|
||||||
|
|
||||||
|
def test_missing_registers_skipped(self) -> None:
|
||||||
|
"""Metrics whose register addresses are absent are not in the output."""
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
# Only supply voltage registers; all others missing.
|
||||||
|
registers = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
result = decode(profile, registers)
|
||||||
|
assert "voltage" in result
|
||||||
|
assert "current" not in result
|
||||||
|
assert "active_power" not in result
|
||||||
|
|
||||||
|
def test_full_sdm120_payload_keys(self) -> None:
|
||||||
|
"""All 8 core keys appear when all registers are supplied."""
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
# Supply a minimal register map covering all 8 core metrics.
|
||||||
|
regs = self._make_registers(
|
||||||
|
{
|
||||||
|
0x0000: (0x4366, 0x3334), # voltage ~230.2V
|
||||||
|
0x0006: (0x3F80, 0x0000), # current ~1.0A
|
||||||
|
0x000C: (0x4393, 0x8000), # active_power ~295.0W
|
||||||
|
0x001E: (0x3F7A, 0xE148), # power_factor ~0.98
|
||||||
|
0x0046: (0x4248, 0x0000), # frequency ~50.0Hz
|
||||||
|
0x0048: (0x42F6, 0x6666), # import_energy ~123.2kWh
|
||||||
|
0x004A: (0x0000, 0x0000), # export_energy ~0.0kWh
|
||||||
|
0x0156: (0x42F6, 0x6666), # total_energy ~123.2kWh
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = decode(profile, regs)
|
||||||
|
expected_keys = {
|
||||||
|
"voltage", "current", "active_power", "power_factor",
|
||||||
|
"frequency", "import_energy", "export_energy", "total_energy",
|
||||||
|
}
|
||||||
|
assert expected_keys == set(result.keys())
|
||||||
|
|
||||||
|
def test_decode_does_not_modify_registers(self) -> None:
|
||||||
|
"""decode() must not mutate the input register dict."""
|
||||||
|
profile = load_profile("sdm120")
|
||||||
|
original = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||||
|
registers = dict(original)
|
||||||
|
decode(profile, registers)
|
||||||
|
assert registers == original
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListProfiles:
|
||||||
|
def test_contains_sdm120(self) -> None:
|
||||||
|
profiles = list_profiles()
|
||||||
|
names = [name for name, _ in profiles]
|
||||||
|
assert "sdm120" in names
|
||||||
|
|
||||||
|
def test_returns_list_of_tuples(self) -> None:
|
||||||
|
profiles = list_profiles()
|
||||||
|
assert isinstance(profiles, list)
|
||||||
|
for item in profiles:
|
||||||
|
assert isinstance(item, tuple)
|
||||||
|
assert len(item) == 2
|
||||||
|
name, description = item
|
||||||
|
assert isinstance(name, str)
|
||||||
|
assert isinstance(description, str)
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
"""Tests for M5-T10: MqttManager + POST /api/config/mqtt/test.
|
||||||
|
|
||||||
|
All paho interaction is mocked — no real broker is required.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.integrations.mqtt import MqttManager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers / fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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,
|
||||||
|
ha_discovery_prefix: str = "homeassistant",
|
||||||
|
):
|
||||||
|
"""Return a simple namespace that acts like a Settings object for MqttManager tests."""
|
||||||
|
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
|
||||||
|
s.ha_discovery_prefix = ha_discovery_prefix
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.is_configured
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_is_configured_returns_false_when_disabled() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_enabled=False, mqtt_broker_host="broker.test")
|
||||||
|
assert manager.is_configured(settings) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_configured_returns_false_when_host_empty() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_enabled=True, mqtt_broker_host="")
|
||||||
|
assert manager.is_configured(settings) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_configured_returns_true_when_enabled_and_host_set() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_enabled=True, mqtt_broker_host="broker.test")
|
||||||
|
assert manager.is_configured(settings) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.connect — no-op when not configured
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_connect_is_noop_when_disabled() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_enabled=False)
|
||||||
|
# Should not raise and should not create a client
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client") as mock_client_cls:
|
||||||
|
manager.connect(settings)
|
||||||
|
mock_client_cls.assert_not_called()
|
||||||
|
assert manager._client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_is_noop_when_host_empty() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_broker_host="")
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client") as mock_client_cls:
|
||||||
|
manager.connect(settings)
|
||||||
|
mock_client_cls.assert_not_called()
|
||||||
|
assert manager._client is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.connect — calls paho correctly when configured
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_connect_creates_paho_client_with_version2() -> None:
|
||||||
|
"""connect() must pass CallbackAPIVersion.VERSION2 to paho.Client."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
|
||||||
|
with patch("app.integrations.mqtt.mqtt.CallbackAPIVersion"):
|
||||||
|
manager.connect(settings)
|
||||||
|
# Verify Client was instantiated with VERSION2
|
||||||
|
mock_cls.assert_called_once()
|
||||||
|
call_kwargs = mock_cls.call_args
|
||||||
|
# First positional arg or 'callback_api_version' kwarg
|
||||||
|
assert call_kwargs is not None
|
||||||
|
|
||||||
|
# loop_start must have been called
|
||||||
|
mock_client.loop_start.assert_called_once()
|
||||||
|
# connect must have been called with the right host/port
|
||||||
|
mock_client.connect.assert_called_once_with(
|
||||||
|
host="broker.test", port=1883, keepalive=60
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_sets_credentials_when_username_provided() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
mock_client.username_pw_set.assert_called_once_with(
|
||||||
|
username="user", password="s3cr3t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_skips_credentials_when_username_empty() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_username="", mqtt_password="")
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
mock_client.username_pw_set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_calls_tls_set_when_tls_enabled() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_tls_enabled=True)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
mock_client.tls_set.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.connect — connection failure does not raise
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_connect_does_not_raise_on_connection_failure() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.side_effect = OSError("Connection refused")
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
# Must not raise — failures are caught and logged
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
# loop_stop should have been called to clean up the thread
|
||||||
|
mock_client.loop_stop.assert_called()
|
||||||
|
# Client reference should not be stored
|
||||||
|
assert manager._client is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.disconnect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_disconnect_stops_loop_and_clears_client() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
manager._client = mock_client # ensure it's set
|
||||||
|
|
||||||
|
manager.disconnect()
|
||||||
|
|
||||||
|
mock_client.disconnect.assert_called()
|
||||||
|
mock_client.loop_stop.assert_called()
|
||||||
|
assert manager._client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_is_noop_when_not_connected() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
# Should not raise when called before any connect
|
||||||
|
manager.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.publish
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_publish_passes_topic_payload_retain_to_paho() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
|
manager.connect(settings)
|
||||||
|
# Simulate connected state
|
||||||
|
manager._connected = True
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
manager.publish("test/topic", '{"key": "value"}', retain=True, qos=1)
|
||||||
|
|
||||||
|
mock_client.publish.assert_called_once_with(
|
||||||
|
"test/topic", payload='{"key": "value"}', qos=1, retain=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_is_noop_when_not_connected() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
# No connect — should silently skip
|
||||||
|
manager.publish("topic", "payload", retain=False)
|
||||||
|
# No exception raised
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_does_not_raise_on_paho_error() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.publish.side_effect = RuntimeError("network error")
|
||||||
|
manager._client = mock_client
|
||||||
|
manager._connected = True
|
||||||
|
|
||||||
|
# Must not raise
|
||||||
|
manager.publish("topic", "payload")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MqttManager.reconnect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_reconnect_stops_old_client_and_starts_new_one() -> None:
|
||||||
|
"""reconnect() must tear down the existing client and establish a fresh one."""
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
# Simulate an already-connected client
|
||||||
|
old_client = MagicMock()
|
||||||
|
manager._client = old_client
|
||||||
|
manager._connected = True
|
||||||
|
|
||||||
|
new_client = MagicMock()
|
||||||
|
new_client.connect.return_value = None
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=new_client):
|
||||||
|
manager.reconnect(settings)
|
||||||
|
|
||||||
|
# Old client must have been stopped
|
||||||
|
old_client.disconnect.assert_called()
|
||||||
|
old_client.loop_stop.assert_called()
|
||||||
|
|
||||||
|
# New client must have been connected
|
||||||
|
new_client.loop_start.assert_called_once()
|
||||||
|
new_client.connect.assert_called_once_with(
|
||||||
|
host="broker.test", port=1883, keepalive=60
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/config/mqtt/test — session + CSRF guards
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MQTT_TEST_URL = "/api/config/mqtt/test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_unauthenticated_returns_401(client: TestClient) -> None:
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
|
||||||
|
_login(client)
|
||||||
|
response = client.post(_MQTT_TEST_URL)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
|
||||||
|
_login(client)
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": ""})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/config/mqtt/test — three-state responses
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_RUN_MQTT_TEST_PATH = "app.api.routes.api.config._run_mqtt_test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_success_returns_200(client: TestClient) -> None:
|
||||||
|
_login(client)
|
||||||
|
with patch(_RUN_MQTT_TEST_PATH, return_value=None) as mock_run:
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||||
|
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["result"] == "success"
|
||||||
|
assert "message" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_config_error_returns_400(client: TestClient) -> None:
|
||||||
|
_login(client)
|
||||||
|
from app.api.routes.api.config import _MqttConfigurationError
|
||||||
|
|
||||||
|
with patch(_RUN_MQTT_TEST_PATH, side_effect=_MqttConfigurationError("Host not configured")):
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.json()
|
||||||
|
assert body["result"] == "config-error"
|
||||||
|
assert "Host not configured" in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_connection_error_returns_502(client: TestClient) -> None:
|
||||||
|
_login(client)
|
||||||
|
from app.api.routes.api.config import _MqttConnectionError
|
||||||
|
|
||||||
|
with patch(_RUN_MQTT_TEST_PATH, side_effect=_MqttConnectionError("connection refused")):
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
body = response.json()
|
||||||
|
assert body["result"] == "failed"
|
||||||
|
assert "connection refused" in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_response_does_not_echo_password(client: TestClient) -> None:
|
||||||
|
"""MQTT password must not appear in any test response body."""
|
||||||
|
_login(client)
|
||||||
|
from app.api.routes.api.config import _MqttConnectionError
|
||||||
|
|
||||||
|
with patch(_RUN_MQTT_TEST_PATH, side_effect=_MqttConnectionError("error: [redacted]")):
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "token"})
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
# Actual password "s3cr3t" must not appear
|
||||||
|
assert "s3cr3t" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_test_empty_host_config_error(client: TestClient) -> None:
|
||||||
|
"""When broker host is empty, endpoint should return 400 config-error."""
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# The real _run_mqtt_test raises _MqttConfigurationError for empty host.
|
||||||
|
# We don't mock it here — we let the real implementation run.
|
||||||
|
# The default settings have mqtt_broker_host="" so this should 400.
|
||||||
|
response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.json()
|
||||||
|
assert body["result"] == "config-error"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _run_mqtt_test unit tests (lower-level, mock paho entirely)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_run_mqtt_test_raises_config_error_when_host_empty() -> None:
|
||||||
|
from app.api.routes.api.config import _run_mqtt_test, _MqttConfigurationError
|
||||||
|
|
||||||
|
settings = _make_settings(mqtt_broker_host="")
|
||||||
|
with pytest.raises(_MqttConfigurationError, match="host"):
|
||||||
|
_run_mqtt_test(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_mqtt_test_raises_connection_error_on_os_error() -> None:
|
||||||
|
from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError
|
||||||
|
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.side_effect = OSError("Connection refused")
|
||||||
|
|
||||||
|
# _run_mqtt_test imports paho.mqtt.client locally inside the function body,
|
||||||
|
# so we patch via the real module path (paho.mqtt.client.Client).
|
||||||
|
with patch("paho.mqtt.client.Client", return_value=mock_client):
|
||||||
|
with pytest.raises(_MqttConnectionError, match="Cannot reach broker"):
|
||||||
|
_run_mqtt_test(settings)
|
||||||
|
|
||||||
|
# loop_stop must be called in the finally block
|
||||||
|
mock_client.loop_stop.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_mqtt_test_raises_connection_error_on_timeout() -> None:
|
||||||
|
"""If connected_event never fires, should raise _MqttConnectionError."""
|
||||||
|
from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError
|
||||||
|
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.return_value = None
|
||||||
|
|
||||||
|
class _NeverSetEvent:
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return False # simulate timeout
|
||||||
|
|
||||||
|
def set(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _make_event():
|
||||||
|
return _NeverSetEvent()
|
||||||
|
|
||||||
|
with patch("paho.mqtt.client.Client", return_value=mock_client):
|
||||||
|
with patch("threading.Event", side_effect=_make_event):
|
||||||
|
with pytest.raises(_MqttConnectionError, match="timed out"):
|
||||||
|
_run_mqtt_test(settings)
|
||||||
@@ -262,8 +262,10 @@ def test_downgrade_removes_totp_columns_and_table(tmp_path: Path):
|
|||||||
assert "totp_enabled" in auth_user_cols
|
assert "totp_enabled" in auth_user_cols
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
# Downgrade one step (removes TOTP migration).
|
# Downgrade to the revision before TOTP (removes TOTP migration).
|
||||||
command.downgrade(alembic_cfg, "-1")
|
# We use an explicit target rather than "-1" so the test stays correct even
|
||||||
|
# as new revisions are added on top of the TOTP revision.
|
||||||
|
command.downgrade(alembic_cfg, "20260621_07_auth_login_throttle")
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user