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
This commit is contained in:
2026-06-21 20:46:12 +02:00
parent 9da88db221
commit 0cb94d85ec
5 changed files with 886 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
# 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 现实。