Files
home-automation/docs/design/m5-iot-energy.md
T
tliu93 0cb94d85ec docs(design): plan M4 login-hardening (first) + M5 IoT/energy
- Add docs/references/SDM120-Modbus-Protocol.md extracted from the SDM120 PDF
- M4 login hardening: brute-force exponential backoff, CLI escape hatch
  (reset-password/unlock/disable-totp), optional TOTP 2FA
- M5 IoT/energy: Modbus-TCP SDM120 polling + energy tables, MQTT/HA
  Discovery expose framework, frontend sidebar + energy view
- Add manual acceptance walkthroughs (M4 lock/unlock; M5 energy_cli read)
- Index both milestones in docs/design/README.md
2026-06-21 20:46:12 +02:00

442 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M5 — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 M1 单库 + M2 React SPA 之上。
> 配套参考:电表协议见 [`../references/SDM120-Modbus-Protocol.md`](../references/SDM120-Modbus-Protocol.md)。
## 1. 目标
给后端接入家庭 IoT 生态,并新增一条能耗采集链路:
1. **Modbus/Energy 采集**:通过 Modbus-TCP 网关周期读取电表(首个 profile 为 SDM120 单相),解码为工程量,存入单库的能耗表。后台静默轮询,支持多电表。
2. **MQTT + Home Assistant Discovery**:后端作为 MQTT 发布方,按"**可勾选暴露**"的方式把数据以 HA Discovery 自动注册成 device/entity(不止 sensor)。
3. **前端侧边栏**:把现有顶栏改成侧边导航,承载新的 Energy 视图(电表管理 + 最新读数 + 走势图)。
> 三段有依赖关系,按 §6 的 `Depends` 顺序推进:A(侧边栏,独立)→ B(Energy 后端 + 前端)→ CMQTT/Discovery,消费 B 的数据)。
## 2. 现状(实现者可据此工作,不必通读全仓库)
**单库数据层**M1 完成态)
- `app/db.py``class Base(DeclarativeBase)`,绑 `settings.app_database_url` 的 cached engineWAL 已开),`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
- 模型都继承同一 `Base``app/models/{auth,config,public_ip,location,poo}.py`
- 单 Alembic 链 `alembic_app/`head = `20260611_06_merge_location_poo_tables`(见 `alembic_app/versions/`);`alembic_app/env.py` 逐个 import 所有模型。
- 迁移命名惯例:`YYYYMMDD_NN_<desc>.py``revision` / `down_revision` 串链。
- `scripts/app_db_adopt.py` 常量 `APP_BASELINE_REVISION` 指向当前 head`scripts/run_migrations.py` 负责把 app 库升到 head。
**配置系统(扁平 KV,自动渲染)**
- `app/config.py``class Settings(BaseSettings)`,每个配置项一个带类型的字段 + 默认值。
- `app/services/config_page.py``CONFIG_FIELDS: tuple[ConfigField, ...]` 是**注册表**`section / env_name / setting_attr / label / secret / input_type`);`build_config_sections`(读,secret 回空串)、`save_config_updates`(写,空 secret 保留旧值)、`build_runtime_settings`DB override 合并进 Settings)、`_settings_payload`(把 Settings 摊平成 dict,新字段要在此补一行)。
- `app/api/routes/api/config.py``GET/PUT /api/config`session + CSRF 保护;非法值 422 且不写库。
- 前端 `frontend/src/pages/ConfigPage.tsx`**通用渲染**——按 section 分组、按 `input_type`/`secret` 渲染输入框。**新增标量配置项零前端改动**(追加 `CONFIG_FIELDS` + `Settings` 字段 + `_settings_payload` 一行即可)。
- ⚠️ 扁平 KV **装不下"电表列表"和"逐实体勾选"**——这两者走专用表 + 专用 API + 自定义 UI(见 §3.2 / §3.4)。
**后台调度(APScheduler**
- `app/main.py` lifespan`BackgroundScheduler(timezone="UTC")``scheduler.add_job(_run_scheduled_public_ip_check, IntervalTrigger(hours=4), id=..., max_instances=1, coalesce=True)``scheduler.start()``yield``scheduler.shutdown(wait=False)`
- 周期任务惯例:一个**同步** wrapper 自己开/关 session`session = get_session_local()(); try: service(session, ...) finally: session.close()`service 内部不抛崩溃。
**Home Assistant 现状(REST,不碰 MQTT**
- `app/integrations/homeassistant.py``HomeAssistantClient.publish_sensor()``POST /api/states/{entity}`)、`trigger_webhook()`
- 入站 webhook `app/api/routes/homeassistant.py``POST /homeassistant/publish`envelope `target/action/content`)。
- 新 MQTT Discovery 与此**并行、不冲突**,是第二条独立通道。
**前端(M2**
- React + react-router v6 + Mantine + TanStack Query + `openapi-fetch` 生成的类型化 client`frontend/src/api/client.ts` + `schema.d.ts`)。
- `frontend/src/App.tsx``AppLayout`(当前是**顶栏**),包住所有受保护页;路由 `/`(HomePage 地图)、`/config``/records``/login``/change-password` 不带 layout。
- 数据请求惯例:`useQuery`/`useMutation` + `apiClient.GET/POST/...`(见 `frontend/src/records/hooks.ts`)。
- **无图表库**(只有 Leaflet 地图);走势图需新引入 **Recharts**
## 3. 目标架构
### 3.1 Modbus / Energy 采集
- **传输:仅 Modbus-TCP**(用户的 Waveshare RTU↔TCP 网关,RJ45 以太网;服务器无串口)。用 **pymodbus**`ModbusTcpClient`,由它处理封帧 / CRC / 超时重试 / float 解码。
- 网关若开"Modbus TCP"协议转换 → pymodbus 默认 framer 直连。
- 网关若是透传(RTU-over-TCP,裸 RTU 帧含 CRC)→ pymodbus 用 RTU framer over TCP。
- **二者实现时对一次即可确定**(连上读 Voltage 寄存器验证),不影响表结构与上层。
- **协议知识在代码(profile/driver),部署信息在 DBmeters 表)**
- `app/integrations/modbus.py`:薄封装 pymodbus 的连接 + 块读 + 大端 float32 解码(word/byte 都大端,高寄存器在前)。
- `app/integrations/energy_profiles.py`device profile 定义"读哪些寄存器、解码成哪个工程量"。首个 profile = `sdm120`(寄存器地址见参考文档 §4)。
- profile 输出统一的 `dict[metric_name -> value]`,由 service 落到读数表对应列;profile 不认识的冷门量丢进 `extra` JSON。
- **只读**:不写电表配置寄存器(改 Meter ID/波特率有通信中断风险)。
- **轮询**:每个电表一个 `poll_interval_s`(默认 5s),APScheduler 一个 job 扫所有 `enabled` 电表,逐表块读→解码→落库。9600 总线上多表串行,5s/几个表余量充足。
- 全局开关 `ENERGY_POLLING_ENABLED`CONFIG_FIELDS)可一键停采。
- **两级周期 / 降采样 / 保留是后续杠杆**(见 §10),本里程碑用单周期读全。
### 3.2 数据模型(新增两张表,单库 app 链)
**`energy_meters`**(电表定义,CRUD 管理)
| 列 | 类型 | 说明 |
| --- | --- | --- |
| id | int PK | |
| slug | str unique | 稳定标识(用于 MQTT entity key / HA device identifier|
| name | str | 显示名 |
| transport | str | 现仅 `'tcp'` |
| host | str | 网关 IP |
| port | int | 网关端口(默认 502|
| unit_id | int | Modbus 从机地址 = 电表 Meter ID(默认 1|
| profile | str | 设备 profile(首个 `'sdm120'`|
| poll_interval_s | int | 采样周期(默认 5|
| enabled | bool | 是否轮询 |
| created_at / updated_at | datetime | |
**`energy_readings`**(一张相位感知宽表,一行 = 一个电表的一次采样)
| 列 | 类型 | 说明 |
| --- | --- | --- |
| id | int PK | |
| meter_id | int FK→energy_meters.id | **ON DELETE RESTRICT**(见 §4 删除语义)|
| recorded_at | datetime (UTC) | 采样时刻,索引 |
| voltage_l1 / l2 / l3 | float null | 单相电表只填 l1 |
| current_l1 / l2 / l3 | float null | 单相电表只填 l1 |
| active_power_l1 / l2 / l3 | float null | 三相按相填;单相留空(用 total)|
| total_active_power | float null | W(单相=该电表有功功率)|
| apparent_power | float null | VA |
| reactive_power | float null | var |
| power_factor | float null | |
| frequency | float null | Hz |
| import_active_energy | float null | kWh(累计)|
| export_active_energy | float null | kWh(累计)|
| total_active_energy | float null | kWh(累计)|
| extra | JSON null | 冷门/型号特有量(demands、maxima、无功电能、line-to-line 等)|
- 索引:`(meter_id, recorded_at)`
- **SDM120 单相映射**`voltage_l1`←电压、`current_l1`←电流、`total_active_power`←有功功率、`apparent_power`/`reactive_power`/`power_factor`/`frequency``import/export/total_active_energy`←对应电能寄存器;l2/l3 与 `active_power_l1..l3` 留空。
- 三相 profile 以后填 l1/l2/l3 + total**无需改 schema**。
### 3.3 MQTT + HA Discovery(通用 expose 框架)
HA MQTT Discovery 模型 = **device → entities**:往 `<prefix>/<component>/<node>/<object>/config` 发 retained 消息定义一个 entityconfig 内 `device.identifiers` 相同的 entity 归到同一个 HA device 卡片下;之后往 `state_topic` 推值。
- **可暴露实体目录由 provider 动态产出**
- `app/integrations/expose.py`:定义 `ExposableEntity``key`(稳定)、`component`sensor/binary_sensor/switch…)、`device`(归属,决定 HA device 分组)、`device_class``unit`、取值来源)+ 一个 provider 注册表。
- **Energy provider**:每个 `enabled` 电表 = 一个 HA **device**,其各工程量 = 一组 sensor entity(带 `device_class=voltage/current/power/energy/frequency` 与单位),外加一个 `binary_sensor`「meter online」(轮询成功/失败)——这就是"**不止 sensor**"的体现。
- 其它 provider(如 public-ip、poo)可后续挂入,本里程碑至少接 energy + 一个示例。
- **`exposed_entities` 表**:只存"逐 key 的开关"`key` unique + `enabled` + `updated_at`)。目录本身由 provider 计算,表只记被勾选的状态(默认未勾 = 不暴露)。
- **MQTT 客户端**`app/integrations/mqtt.py`**paho-mqtt**`loop_start()` 后台线程;在 lifespan 起/停;支持配置变更后**重连 + 重发 discovery**。
- **发布时机**
- discovery configretained):连接成功时、目录/勾选变更时全量发;取消勾选时发空 payload 清除该 entity。
- state:energy 在每次轮询后推最新值;另有一个周期 job 兜底重发所有 enabled 实体的 state + availability(在线)topic。
- **配置**(走现有扁平 CONFIG_FIELDS):`MQTT_ENABLED``MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD(secret)``MQTT_TLS_ENABLED``HA_DISCOVERY_ENABLED``HA_DISCOVERY_PREFIX`(默认 `homeassistant`)。
### 3.4 前端
- **侧边栏**:把 `AppLayout` 从顶栏重构为侧边导航(Mantine `AppShell` 或 flex sidebar),导航项:Home / Records / Energy / Config + 主题切换 + 注销;当前路由高亮;移动端可折叠。仅改 `App.tsx`+ 可抽 `AppSidebar`/`NavItem` 组件),各页面主体不动。
- **Energy 视图**(新页 `/energy`):
- 电表管理:列表 + 新建/编辑/删除(删除有二次确认;后端对有读数的电表拒删,引导改用"禁用")。
- 最新读数卡片(每电表当前各工程量)。
- 走势图:用 **Recharts** 画时间序列(电压/电流/功率/电能),时间范围选择,取数走 readings API(窗口 + 上限)。
- **Expose 设置**:设置页内一块「Home Assistant Expose」——列出可暴露实体目录、逐项勾选、显示 MQTT/Discovery 连接状态、一个"重新发布 discovery"按钮。
## 4. API 契约(M5 要落地的端点)
> 全部 `/api` 前缀、session + CSRF(写)保护、JSON 进出。schema 经 `export_openapi.py` 固化入库。
| 分组 | 端点 | 用途 |
| --- | --- | --- |
| Energy | `GET /api/energy/meters` | 列出电表 |
| Energy | `POST /api/energy/meters` | 新建电表 |
| Energy | `GET /api/energy/meters/{id}` | 单个电表 |
| Energy | `PATCH /api/energy/meters/{id}` | 修改电表(含 enable/disable|
| Energy | `DELETE /api/energy/meters/{id}` | 删除电表;**有读数时 409**,引导改 disable |
| Energy | `GET /api/energy/meters/{id}/latest` | 该电表最新一条读数 |
| Energy | `GET /api/energy/meters/{id}/readings` | 时间范围读数(`start/end/limit`limit 有上限),供走势图 |
| Energy | `POST /api/energy/meters/{id}/test` | 即时试读一次(验证网关连通/地址),不落库 |
| Expose | `GET /api/expose` | 返回可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 |
| Expose | `PUT /api/expose` | 设置逐 key 勾选(map key→bool|
| Expose | `POST /api/expose/republish` | 手动重发 discovery |
| 配置 | `POST /api/config/mqtt/test` | 测试 broker 连接(仿 SMTP 测试三态)|
> MQTT broker / discovery 的**标量配置**复用现有 `GET/PUT /api/config`(只新增 CONFIG_FIELDS,不新增端点)。
## 5. 已锁定决策(讨论后拍板)
1. **里程碑编排**:一个 M5 文档分三段,`Depends` 串顺序(A 侧边栏 → B Energy → C MQTT)。
2. **电表定义 = 专用 `energy_meters` 表 + CRUD API/UI**(扁平 KV 装不下多电表)。
3. **读数 = 一张相位感知宽表 `energy_readings`,单 per-meter 周期(默认 5s)每 tick 读全部**`extra` JSON 兜底;为三相预留 l1/l2/l3 列。两级周期/降采样为后续杠杆。
4. **Modbus 仅 TCP**pymodbusframer 配网关模式,**只读**采集。
5. **协议知识在代码 profile,部署信息在 DB**;首个 profile `sdm120`
6. **MQTT = 通用 expose 框架**:provider 动态产出可暴露实体目录,`exposed_entities` 只存逐 key 开关;支持 sensor/binary_sensor/switch 等多 component;电表自动注册(每表一 device、各量为 entity + 一个 online binary_sensor)。
7. **MQTT 库 = paho-mqtt**,lifespan 长连接,配置变更后重连 + 重发 discovery。
8. **MQTT broker/discovery 标量配置走现有扁平 CONFIG_FIELDS**(自动渲染);电表清单与 expose 勾选走专用表 + 自定义 UI。
9. **图表库 = Recharts**(封在自包含组件后,仿 M2 对 Leaflet 的隔离)。
10. **删除电表安全**FK `ON DELETE RESTRICT`,有读数拒删(避免一键删表丢历史);"停用"用 `enabled=false`
> 项目定位:个人自用、家庭特化、不开源——可按单用户场景简化,不过度抽象。
## 6. 任务依赖图
```
Phase A(独立,可最先做)
M5-T01 [structural] 侧边栏布局重构
Phase BEnergy
M5-T02 [schema] energy_meters + energy_readings 表 + 模型
├─► M5-T03 Modbus 驱动 + sdm120 profilepymodbus,纯模块)
│ └─► M5-T04 Energy service + APScheduler 轮询(接 lifespan
└─► M5-T05 Energy JSON APImeters CRUD + readings 查询 + test
└─► M5-T06 前端:电表管理 UI(依赖 T01 侧栏 + T05 API
└─► M5-T07 前端:读数展示 + Recharts 走势图(依赖 T05;引入 recharts
Phase CMQTT / Discovery,依赖 B 的 energy 数据与 provider 接口)
M5-T08 MQTT/Discovery 配置项(CONFIG_FIELDS
M5-T09 [schema] exposed_entities 表 + ExposableEntity/provider 框架(energy provider
├─► M5-T10 MQTT 客户端(paholifespan 连接 + 重连 + config/mqtt/test
│ └─► M5-T11 Discovery 发布 + state 发布(连 T04 轮询推 state
└─► M5-T12 前端:Expose 勾选 UI + /api/expose API
收尾
M5-T13 文档 + OpenAPI + roadmap 收尾(依赖全部)
```
`T01``T02``T08` 无前置可先开。
---
## 7. 原子任务(任务卡)
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
### M5-T01 — 侧边栏布局重构 `[structural]`
- **Status**: `todo` · **Depends**: none
- **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`(导航断言)
- **Steps**:
1. 用 Mantine `AppShell`(或 flex sidebar)重构 `AppLayout`:左侧竖直导航(Home/Records/Config + 主题切换 + 注销),`<Outlet/>` 在右。
2. 当前路由高亮(`useLocation` 比对 `pathname`);移动端可折叠(burger)。
3. 导航项图标沿用 `react-feather`;样式走 Mantine(暗色模式自动适配)。
4. 不在本任务加 Energy 项(页面还不存在,T06 加),保持导航无死链。
- **Out of scope / 不要碰**: 不改各页面主体;不动鉴权(SessionProvider/ProtectedRoute);不引入图表库。
- **Acceptance criteria**:
- [ ] 受保护页都在侧边栏布局内;`/login``/change-password` 不带布局(与现状一致)。
- [ ] 当前路由在侧栏高亮;移动端宽度下可折叠/展开。
- [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
### M5-T02 — `energy_meters` + `energy_readings` 表与模型 `[schema]`
- **Status**: `todo` · **Depends**: none
- **Context**: 单库 app 链新增两表,建出 §3.2 结构。本任务只建 schema + 模型,不写采集/接口。
- **Files**: `create app/models/energy.py``EnergyMeter``EnergyReading`,继承 `app.db.Base`);`create alembic_app/versions/<date>_07_energy_tables.py``modify alembic_app/env.py`import 新模型);`modify scripts/app_db_adopt.py``APP_BASELINE_REVISION` → 新 head);`create tests/test_energy_models.py`
- **Steps**:
1. 模型按 §3.2 列定义(`Mapped[...]` 2.0 风格,`extra` 用 JSON 列且 nullable);`EnergyReading.meter_id` FK→`energy_meters.id`**`ondelete="RESTRICT"`**`slug` unique。
2. 新 revision`down_revision` = 当前 head`upgrade()``op.create_table` 建两表 + 索引 `(meter_id, recorded_at)``downgrade()` 反向 drop。
3. 更新 `APP_BASELINE_REVISION`
- **Out of scope / 不要碰**: 不写 pymodbus/采集(T03/T04);不加路由(T05);不动其它模型。
- **Acceptance criteria**:
- [ ] 全新临时 app 库 upgrade 到 head 后含 `energy_meters``energy_readings` 及索引;`downgrade -1` 干净回滚。
- [ ] `Base.metadata.tables` 含两新表;FK 为 RESTRICT。
- [ ] `APP_BASELINE_REVISION` == 新 head。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 列/约束与 §3.2 一致;`extra` 为 JSON nullable;链上单 head`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。
### M5-T03 — Modbus 驱动 + `sdm120` profile
- **Status**: `todo` · **Depends**: M5-T02
- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 SDM120 寄存器 profile。纯模块,mock client 单测。
- **Files**: `create app/integrations/modbus.py``app/integrations/energy_profiles.py``scripts/energy_cli.py``modify requirements.in`/`requirements.txt`(加 `pymodbus`,重新锁定);`create tests/test_modbus_driver.py``tests/test_energy_profiles.py``tests/test_energy_cli.py`
- **Steps**:
1. `modbus.py``read_meter(host, port, unit_id, blocks) -> dict[int,int]`(块读 input registersFC04),用 `ModbusTcpClient`;超时/连接失败抛明确异常;大端 float32 解码 helper`registers_to_float`,高寄存器在前)。framer 选择留可配置/可探测。
2. `energy_profiles.py``SDM120_PROFILE` 描述要读的寄存器块(参考文档 §4 地址)+ 每个工程量如何从寄存器对解码并映射到 `EnergyReading` 列名;`decode(raw_registers) -> dict[col -> value]`,未映射的丢 `extra`
3. profile 注册表 `PROFILES = {"sdm120": SDM120_PROFILE}`
4. `scripts/energy_cli.py``python -m scripts.energy_cli read --host H --port P --unit U --profile sdm120`):连网关、按 profile 读一次、解码后**把各工程量打印成可读结果**(表格/JSON);纯命令行、**不依赖 DB / 不需先配电表**,用于现场验证网关连通与读数。连接失败给清晰报错 + 非零退出。
- **Out of scope / 不要碰**: 不连真实硬件(单测用 mock/fake 返回已知寄存器字节);不写调度(T04);不写电表配置寄存器。
- **Acceptance criteria**:
- [ ] 单测:给定 `0x4366,0x3334` 解码为 `230.2`(参考文档实例);字序/字节序正确。
- [ ] 单测:`SDM120_PROFILE.decode(...)` 把已知寄存器映射到正确列名与值;冷门量进 `extra`
- [ ] 连接失败/超时抛可识别异常,不静默返回错值。
- [ ] `python -m scripts.energy_cli read ...` 能(对 mock/真实网关)打印解码后的各工程量;连接失败非零退出。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;无任何写寄存器路径;`requirements.txt` 已同步锁定 `pymodbus`
### M5-T04 — Energy service + APScheduler 轮询
- **Status**: `todo` · **Depends**: M5-T03
- **Context**: 周期扫所有 enabled 电表,调用 driver 读+解码,落 `energy_readings`。仿 public-ip 的同步 job 模式。
- **Files**: `create app/services/energy.py``modify app/main.py`lifespan 注册 job);`create tests/test_energy_poll.py`
- **Steps**:
1. `energy.py``poll_meter(session, meter) -> EnergyReading | None`(按 profile 读+解码+插入一行,记录成功/失败用于 online 状态);`poll_all_enabled_meters(session)` 遍历 enabled 电表;service 内吞异常并日志,不让 job 崩。
2. `main.py`:加同步 wrapper `_run_scheduled_energy_poll`(自管 session),`add_job(IntervalTrigger(seconds=...), id="energy-poll", max_instances=1, coalesce=True)`。周期取**最小 per-meter interval 或一个基础 tick**(实现可用单一基础 tick + 各表按自身 interval 取模决定本 tick 是否读,保持 job 简单);受全局 `ENERGY_POLLING_ENABLED` 控制。
3. 失败的电表记录 online=false(供 T11 暴露),不影响其它电表。
- **Out of scope / 不要碰**: 不发 MQTTT11);不加 HTTP 路由(T05);不引入两级周期(后续杠杆)。
- **Acceptance criteria**:
- [ ] 单测:mock driver 返回已知值,`poll_all_enabled_meters``energy_readings` 精确 +N 行、列值正确。
- [ ] 单测:某电表读失败时其它电表仍正常落库,job 不抛。
- [ ] `ENERGY_POLLING_ENABLED=false` 时不轮询。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job。
### M5-T05 — Energy JSON APImeters CRUD + readings + test
- **Status**: `todo` · **Depends**: M5-T02
- **Context**: 给前端提供电表 CRUD、最新读数、时间范围读数、即时试读。
- **Files**: `create app/api/routes/api/energy.py``app/schemas/energy.py``modify app/main.py`(注册路由);`create tests/test_api_energy.py`
- **Steps**:
1. meters`GET`(list)/`POST`/`GET{id}`/`PATCH{id}`/`DELETE{id}`session+CSRF`slug` 唯一校验;`DELETE` 有读数 → 409。
2. readings`GET {id}/latest``GET {id}/readings``start/end/limit`limit 有上限防全表导出,按 `recorded_at` 升序)。
3. `POST {id}/test`:用 driver 即时读一次返回解码值(或错误),**不落库**。
- **Out of scope / 不要碰**: 不在此处发 MQTT;不写采集逻辑(复用 T03/T04 的 driver/service)。
- **Acceptance criteria**:
- [ ] CRUD 行为正确:创建/改/删行数精确;删有读数的电表返回 409;未登录 401、缺 CSRF 403。
- [ ] readings 时间范围 + limit 上限生效;latest 返回最新一条。
- [ ] schema 经 OpenAPI 固化入库。
- [ ] 校验闸门全绿(含 `openapi/` 重导出)。
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(meter_id, recorded_at)` 索引;test 端点确不落库。
### M5-T06 — 前端:电表管理 UI
- **Status**: `todo` · **Depends**: M5-T01, M5-T05
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;电表增删改 + 试读。
- **Files**: `create frontend/src/pages/EnergyPage.tsx``frontend/src/energy/MeterForm.tsx``frontend/src/energy/hooks.ts``modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`Energy 项);`create` 对应 `*.test.tsx`
- **Steps**: `useQuery`/`useMutation` 接 meters API;列表 + 新建/编辑表单 + 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {id}/test` 显示结果。
- **Out of scope / 不要碰**: 走势图在 T07;不碰其它页面。
- **Acceptance criteria**:
- [ ] 能增/改/删电表并即时刷新;删除有二次确认;409 有友好提示。
- [ ] 侧栏出现 Energy 入口、`/energy` 可达。
- [ ] 前端闸门全绿。
- **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。
### M5-T07 — 前端:读数展示 + Recharts 走势图
- **Status**: `todo` · **Depends**: M5-T05(数据), M5-T06(页面壳)
- **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` 对应测试
- **Steps**: 最新读数卡片(接 `latest`);时间范围选择 + 折线图(电压/电流/功率/电能),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。
- **Out of scope / 不要碰**: 不做服务端降采样(后续);不改后端。
- **Acceptance criteria**:
- [ ] 最新读数与走势图渲染正确;时间范围只取窗口数据(不拉全量)。
- [ ] Recharts 封装自包含、仅此处 import。
- [ ] 前端闸门全绿(`build` 通过,注意 chunk 体积提示)。
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理。
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS
- **Status**: `todo` · **Depends**: none
- **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`
- **Steps**: 加字段 `mqtt_enabled``mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled``ha_discovery_enabled``ha_discovery_prefix`(默认 `homeassistant`)、`energy_polling_enabled`CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Energy」section`_settings_payload` 补齐对应行。
- **Out of scope / 不要碰**: 不建 MQTT 客户端(T10);不动 expose 表(T09)。
- **Acceptance criteria**:
- [ ] 新配置项在 `GET /api/config` 出现且分 sectionpassword 为 secret(回空、留空保留);port 为 number。
- [ ] 非法值(端口非数字)422 不写库。
- [ ] 校验闸门全绿(OpenAPI 若变化则重导出)。
- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。
### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]`
- **Status**: `todo` · **Depends**: M5-T02
- **Context**: 建"可暴露实体目录"的抽象与开关存储;energy provider 把电表映射成 device/entities。
- **Files**: `create app/integrations/expose.py``ExposableEntity`、provider 协议、注册表、energy 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**:
1. `ExposableEntity``key/component/device/device_class/unit/value_getter`+ provider 接口 `enumerate(session) -> list[ExposableEntity]`
2. energy provider:每个电表 → 一个 deviceidentifier=slug),各工程量 → sensor entity(带 device_class/unit),加一个 `binary_sensor` online。
3. `build_catalog(session)` 合并所有 provider 的实体 + 各自 `enabled`(来自 toggle 表,缺省 false)。
4. migration 建 toggle 表;更新 baseline 常量。
- **Out of scope / 不要碰**: 不发 MQTTT11);不加 HTTPT12)。
- **Acceptance criteria**:
- [ ] 单测:建若干电表后 `build_catalog` 产出每表对应 entity(含 online binary_sensor+ 正确 device 分组、device_class、unit。
- [ ] toggle 表 migration 可升/降;缺省 enabled=false。
- [ ] 至少含一个非 sensor componentonline binary_sensor)。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: `key` 稳定(电表用 slug,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算而非写死。
### M5-T10 — MQTT 客户端(paholifespan 连接 + 重连)
- **Status**: `todo` · **Depends**: M5-T08
- **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`
- **Steps**:
1. `MqttManager``is_configured()``connect()/disconnect()/reconnect(settings)``publish(topic, payload, retain)`paho `loop_start()` 后台线程;未配置/未启用则 no-op。
2. lifespan:启用则 connectshutdown disconnect。
3. 配置保存后若 MQTT 设置变化 → 触发 manager 重连(在 config 保存路径加 hook 或保存后比对)。
4. `POST /api/config/mqtt/test`:用提交/现存配置试连,返回三态(success/config-error/failed),仿 SMTP 测试。
- **Out of scope / 不要碰**: 不构建 discovery/state 消息(T11)。
- **Acceptance criteria**:
- [ ] 单测(fake broker/paho mock):configured 时 connect 调用正确;未配置 no-oppublish 透传 topic/payload/retain。
- [ ] `POST /api/config/mqtt/test` 三态有明确返回;session+CSRF 保护。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。
### M5-T11 — Discovery 发布 + state 发布
- **Status**: `todo` · **Depends**: M5-T09, M5-T10
- **Context**: 把 enabled 实体发成 HA discovery configretained)并周期推 stateenergy 轮询后推最新值。
- **Files**: `create app/services/ha_discovery.py``modify app/services/energy.py`(轮询后推 state)、`app/main.py`state 周期 job + 连接后/勾选变更后发 discovery)、`app/api/routes/api/...``/api/expose/republish` 在 T12 接,本任务提供 service);`create tests/test_ha_discovery.py`
- **Steps**:
1. `build_discovery_payload(entity)` → HA 规范 config`<prefix>/<component>/<node>/<object>/config`,含 `device` 块、`state_topic``device_class``unit_of_measurement``availability`)。
2. `publish_discovery(session)`:对 enabled 实体发 retained config;对取消勾选的发空 payload 清除。
3. `publish_states(session)`:取各实体当前值发 state;energy 在 `poll_meter` 成功后顺带推该表实体 state + online。
4. lifespan:连接成功 / 目录或勾选变更后 `publish_discovery`;周期 job 兜底 `publish_states` + availability。
- **Out of scope / 不要碰**: 不做前端(T12);不改采集解码逻辑。
- **Acceptance criteria**:
- [ ] 单测:discovery payload 符合 HA 结构(device 分组正确、topic/ device_class/unit 正确);取消勾选发空 payload。
- [ ] 单测:energy 轮询成功后推对应 state topic;失败推 online=false。
- [ ] discovery 用 retained。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 仅发 enabled 实体;entity 唯一标识稳定;MQTT 未启用时整链 no-op;不阻塞轮询。
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
- **Status**: `todo` · **Depends**: M5-T09, M5-T11
- **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` 前端测试
- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
- **Out of scope / 不要碰**: 不改采集/发布逻辑(T11)。
- **Acceptance criteria**:
- [ ] `GET/PUT /api/expose` 正确读写勾选;session+CSRFOpenAPI 固化。
- [ ] 勾选变更后(或点重新发布)触发 discovery 重发。
- [ ] 前端能勾选并显示状态;前后端闸门全绿。
- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置;republish 真触发 T11;类型化 client。
### M5-T13 — 文档 + OpenAPI + roadmap 收尾
- **Status**: `todo` · **Depends**: 全部
- **Files**: `modify README.md`Energy/MQTT 段、新依赖)、`docs/roadmap.md`M5 行 + 把"MQTT/IoT"从"下一阶段"毕业、新增 Modbus/Energy 方向)、`docs/architecture-overview.md`(新增 MQTT 通道与能耗采集);`modify docs/design/README.md`(列入 m5);`run python scripts/export_openapi.py` 并提交 `openapi/`
- **Acceptance criteria**:
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 无残留旧描述;OpenAPI 已入库。
---
## 8. 前端校验闸门(前端任务每次结束都要全绿)
`frontend/` 下:
```bash
npm ci
npm run lint
npm run typecheck
npm run test
npm run build # 必须产出 dist;留意 chunk 体积告警
```
- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`
- 新增前端依赖(recharts)须提交 `package.json` + `package-lock.json`
## 9. 构建上下文完整性(M1 教训)
- 本里程碑**不删/移文件**,但新增了 Python 依赖(`pymodbus``paho-mqtt`)与前端依赖(`recharts`):必须同步 `requirements.in/.txt` 的重新锁定与 `package-lock.json`,否则镜像构建会缺包。
- 新增源文件都在 `app/``scripts/``frontend/` 既有 COPY 范围内,无需改 `Dockerfile``COPY``scripts/energy_cli.py` 须在镜像里可 `python -m scripts.energy_cli` 调用(确认 `scripts/` 已进镜像);`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
- 发版前置走查(见 CLAUDE.md):真起 app 跑一次轮询、真连一次 broker、前端 Energy 视图人工瞄一眼渲染,再打 tag。
## 10. 后续杠杆(本里程碑不做,文档留痕)
- **两级采样周期**:瞬时量(V/I/P/PF/Hz)快、累计电能慢——9600 总线吃紧或要把功率压到 5s 以下时再开(meters 表加 `energy_interval_s`,读数表已含全部列,非破坏性变更)。
- **保留 / 降采样**:5s 采样长期行数大(≈630 万行/年/表),加定期降采样或保留窗口任务(独立于本里程碑)。
- **更多 device profile**:三相电表(如 SDM630)按 §3.2 填 l1/l2/l3 + total,新增 profile 即可。
- **更多 expose provider**public-ip / poo 等挂入 expose 框架。
- **写电表配置**:当前只读;如需经 MQTT/UI 控制设备(switch 类),再单独评估安全边界。
## 11. 人工验收 walkthrough(实现完成后)
> 重点:用一条命令行命令读到电表数据并展示结果。可用 `docker compose` 起环境,命令在容器内或容器外跑均可。
**前提**:网关(Waveshare RTU↔TCP)已上电接入网络,电表 Meter ID 已知(默认 1)。
**1) 命令行直接试读(不依赖 DB,最快验证)**
- 容器内:`docker compose exec <app> python -m scripts.energy_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
- 或容器外:`source .venv/bin/activate && python -m scripts.energy_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
- 预期:打印解码后的工程量(电压/电流/有功功率/功率因数/频率/导入导出电能等),数值合理;连不上则清晰报错。
**2)(可选)经 API 试读已配置的电表**
- 先在前端 Energy 页或 `POST /api/energy/meters` 建一个电表;
- `POST /api/energy/meters/{id}/test` 即时试读,返回解码值(不落库)。
**3)(可选)验证后台轮询落库**
- 确认 `ENERGY_POLLING_ENABLED=true` 且电表 `enabled`;等一个采样周期;
- 看前端 Energy 视图的最新读数/走势图,或查 `GET /api/energy/meters/{id}/readings` 有新行。
## 12. 里程碑完成定义(DoD)
- 后端能按 per-meter 周期静默轮询 Modbus-TCP 电表、解码落 `energy_readings`,支持多电表 CRUD。
- MQTT 启用时,勾选的实体以 HA Discovery 注册成 device/entities(含非 sensor),state 周期发布;配置变更可重连重发。
- 前端侧边栏可切换功能;Energy 视图能管理电表、看最新读数与走势图;设置页可勾选 expose。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 已入库。
- README / architecture / roadmap / design 索引反映 M5 现实。