22 Commits
Author SHA1 Message Date
tliu93 1ffdaebbbd M6: add Tibber API + NL energy pricing + DSMR reference doc
frontend / frontend (push) Successful in 1m54s
pytest / test (push) Failing after 32s
2026-06-23 19:56:02 +02:00
tliu93 c64693b8e5 M6: add design doc — generic pricing layer (manual/tibber) + DSMR cost engine 2026-06-23 19:53:26 +02:00
tliu93 c89d083942 M5: add read-only per-meter readings tabs to Records page; English-only UI strings 2026-06-22 22:16:55 +02:00
tliu93 6090b98ab0 M5: enable SQLite foreign_keys pragma for DB-level FK enforcement 2026-06-22 20:45:47 +02:00
tliu93 489cd7df3d M5: default Modbus polling to off (opt-in) to avoid unnecessary DB writes 2026-06-22 20:28:12 +02:00
tliu93 35cdc7f22a M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion 2026-06-22 20:11:54 +02:00
tliu93 75d685f04d M5: roll Energy chart window for live auto-refresh (English label); render boolean config fields as switches 2026-06-22 19:40:18 +02:00
tliu93 935e68846c M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle 2026-06-22 19:18:29 +02:00
tliu93 4767a7af60 M5-T13: document Modbus/Energy/MQTT, mark M5 done, finalize OpenAPI 2026-06-22 16:10:11 +02:00
tliu93 45d87f36f7 M5-T12: add expose toggle API and Home Assistant Expose settings panel 2026-06-22 15:50:49 +02:00
tliu93 35b95f5c88 M5-T11: add HA discovery and state publishing wired to polling 2026-06-22 15:32:43 +02:00
tliu93 9d05f5ea62 M5-T10: add paho MQTT client with lifespan connect/reconnect and mqtt/test endpoint 2026-06-22 15:06:30 +02:00
tliu93 360cddc05b M5-T09: add exposed_entities table and ExposableEntity provider framework 2026-06-22 14:50:40 +02:00
tliu93 14b846549e M5-T08: add MQTT, HA Discovery, and Modbus polling config fields 2026-06-22 14:27:21 +02:00
tliu93 68165f0e01 M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows 2026-06-22 14:17:59 +02:00
tliu93 2dc469274a M5-T06: add Energy device management UI and sidebar entry 2026-06-22 13:52:33 +02:00
tliu93 702a1cec00 M5-T05: add Modbus JSON API (device CRUD, readings, metrics, test) 2026-06-22 13:37:48 +02:00
tliu93 49d15d8ffe M5-T04: add Modbus poll service and APScheduler polling job 2026-06-22 13:24:11 +02:00
tliu93 c89cea4953 M5-T03: add Modbus TCP driver, YAML profile framework, sdm120, modbus_cli 2026-06-22 13:03:50 +02:00
tliu93 d9c82e39fb M5-T02: add modbus_device and modbus_reading tables and models 2026-06-22 12:45:50 +02:00
tliu93 eac7860b51 M5-T01B: render ConfigPage sections as Mantine Accordion 2026-06-22 12:34:04 +02:00
tliu93 da7d0f9d62 M5-T01: refactor AppLayout from top bar to sidebar navigation 2026-06-22 12:26:52 +02:00
70 changed files with 19231 additions and 200 deletions
+17
View File
@@ -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
+92 -4
View File
@@ -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 integrationREST 通道)
- 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、payloadJSON,如 `{"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 entitydevice_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 字段一致:
+2
View File
@@ -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")
+189 -2
View File
@@ -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
+221
View File
@@ -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")
+442
View File
@@ -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}")
+17
View File
@@ -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",
+1
View File
@@ -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
+350
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Modbus integration package — driver, profile loader, and decoder."""
+203
View File
@@ -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]
+231
View File
@@ -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 3000130095 (0x00000x005F): voltage through max export demand
- { start: 0x0000, count: 0x0060 }
# Registers 3034330346 (0x01560x0159): 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
+257
View File
@@ -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
View File
@@ -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)
+49
View File
@@ -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
)
+114
View File
@@ -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)
+7
View File
@@ -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
+95
View File
@@ -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]
+154
View File
@@ -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
+33 -3
View File
@@ -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,
} }
+408
View File
@@ -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
)
+209
View File
@@ -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)
+69 -6
View File
@@ -19,31 +19,36 @@
- `main.py` - `main.py`
- FastAPI app factory - FastAPI app factory
- lifespan - lifespanAPScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布)
- 基础路由注册 - 基础路由注册
- `config.py` - `config.py`
- 环境变量驱动的 settings - 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项)
- `db.py` - `db.py`
- 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engineSQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session` - 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engineSQLite 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 schemasM5 新增 `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 adapterREST 通道,原有)
- 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_idModbus 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 MqttManagerlifespan 长连接,配置变更可重连)
├─► Discovery configretained
│ topic: <prefix>/<component>/<node>/<object>/config
│ 内容:device 块(identifiers=uuid)、state_topic、unique_iduuid+key)、
│ namefriendly_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
+14 -14
View File
@@ -258,7 +258,7 @@ Phase CMQTT / 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 CMQTT / 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 CMQTT / 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 CMQTT / 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 CMQTT / 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 CMQTT / 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 APIdevice CRUD + readings + metrics + test ### M5-T05 — Modbus JSON APIdevice 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 CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid` - **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`
### M5-T06 — 前端:设备管理 UIEnergy 视图) ### M5-T06 — 前端:设备管理 UIEnergy 视图)
- **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 CMQTT / 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 CMQTT / 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 CMQTT / 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 CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: `key` 稳定(用设备 `uuid` + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。 - **Reviewer checklist**: `key` 稳定(用设备 `uuid` + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。
### M5-T10 — MQTT 客户端(paholifespan 连接 + 重连) ### M5-T10 — MQTT 客户端(paholifespan 连接 + 重连)
- **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 CMQTT / 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 configretained)并周期推 state;采集轮询后推最新值。 - **Context**: 把 enabled 实体发成 HA discovery configretained)并周期推 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 CMQTT / 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 CMQTT / 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/` 无未提交差异。
+483
View File
@@ -0,0 +1,483 @@
# M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算(含 Tibber 动态 / 固定合同两种 profile)
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 **M5MQTT + 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. 现状(实现者可据此工作,不必通读全仓库)
**单库 + AlembicM5 完成态)**
- `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`(默认未勾不暴露)。
**后台调度(APSchedulerUTC**
- `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 • nameUI 自由填) │
│ • 有哪些字段、单位、计费语义 • 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`(默认 falseopt-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 CET12: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 blob10s 降采样 |
| **`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 | 当前周期/当前档买价(€/kWhmonetary | HA 当前单价 |
| `sell_price_now` | sensor | 当前卖价(€/kWh | |
| `import_cost_total` | sensor | 累计 import_cost`total_increasing`monetarycurrency | 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` 默认 falseTibber 抓取/计费由 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 ingestblob/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 DAPI + 前端)
M6-T09 Energy 数据 APIprices/costs/summary/dsmr-latest/recompute (Depends T05,T07)
└─► M6-T10 前端:合同管理 + 价格/费用视图 + Tibber 测试 (Depends T04,T09)
收尾
M6-T11 文档 + OpenAPI + roadmapDepends 全部)
```
`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` 为 secretbool=checkbox、数值=number`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。
- **Out of scope**: 不建客户端/采集;不动既有字段;不放任何价格常数。
- **Acceptance criteria**:
- [ ] 新项在 `GET /api/config` 分 sectiontoken 为 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=totalenergy_taxsell_adjust`,进出口求和。
- **Out of scope**: 不连真实 APItibber 策略只读 `tibber_price` 表,由 T05 填);不写采集/调度/接口。
- **Acceptance criteria**:
- [ ] 单测:`load_profile("manual"/"tibber")` 校验通过;缺字段/类型错抛可识别错;`validate_values` 正确判合规。
- [ ] 单测:manual 策略双费率出价正确(`import = Δd1×buy_dal + Δd2×buy_normal` 等);tibber 策略 `buy=total`、`sell=totalenergy_taxsell_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 403schema 经 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/r2Decimal);`compute_period(session, t0)`(取 active 版本 + strategyT03/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**: 不发 MQTTT08);不加 HTTPT09,备 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 + brokerTibber 部分待合同生效),不进自动化。
1. **合同(先用 manual,立即可用)**:Energy 页新建一个 `manual` 合同,按表单填双费率/能源税/固定费/抵扣,激活。
2. **DSMR 落库**:开 `dsmr_ingest_enabled`、确认 topicMQTT Explorer 看 `dsmr/json` 有消息;等若干 10 秒 → `GET /api/energy/dsmr/latest` 有最新整帧;`dsmr_reading` 约每 10 秒一行(整帧 blob)。
3. **费用计算**:跨过一个整刻钟边界 → `GET /api/energy/costs` 出现该周期 import/export/cost/revenue/netmanual 双费率分档对得上手算);`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.11082026 第一档含 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 为 EURdemo 为 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 key2018 历史日)**`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 分钟价(换成自己账户 tokendemo 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_HOURLYdemo2018-11-0296 节点)**
```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 + 用户合同)
> 所有金额**含 VATincl. 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 第一档(010000 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` 为 UTCZ**`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 ReaderHA 集成):`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
View File
@@ -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 采集**pymodbusAPScheduler 后台 job 轮询所有 enabled 设备;per-device `last_poll_at`/`last_poll_ok`;全局开关 `MODBUS_POLLING_ENABLED`CONFIG_FIELDS)。首个 profileSDM120 单相电表。
- **手工 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 调用)
+386 -3
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -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>
+1045
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -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')
})
})
+171
View File
@@ -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()
})
})
+303
View File
@@ -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>
)
}
+241
View File
@@ -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' }),
})
})
})
})
+263
View File
@@ -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>
)
}
+375
View File
@@ -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)
})
})
+330
View File
@@ -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>
)
}
+120
View File
@@ -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')
})
})
+40
View File
@@ -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)
}
+495
View File
@@ -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')
})
})
+249
View File
@@ -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-refreshcapable 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,
})
}
+321 -2
View File
@@ -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)
})
}) })
+162 -32
View File
@@ -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>
+394
View File
@@ -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()
})
})
})
+614
View File
@@ -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>
)
}
+301
View File
@@ -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()
})
})
})
+138
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1064 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -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):
+338
View File
@@ -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
View File
@@ -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}"
)
+522
View File
@@ -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}"
)
+822
View File
@@ -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
+689
View File
@@ -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
+289
View File
@@ -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}"
)
+232
View File
@@ -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
)
+464
View File
@@ -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()
+465
View File
@@ -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"
)
+259
View File
@@ -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)
+433
View File
@@ -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)
+4 -2
View File
@@ -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)