# M5 — IoT 集成与能耗采集（Modbus 设备采集 + MQTT/HA Discovery + 前端侧边栏）

> 阅读前提：先读 [`README.md`](./README.md)（协作模型、任务卡格式、校验闸门、数据安全红线）。本里程碑建立在 M1 单库 + M2 React SPA 之上。
> 配套参考：电表协议见 [`../references/SDM120-Modbus-Protocol.md`](../references/SDM120-Modbus-Protocol.md)。

## 1. 目标

给后端接入家庭 IoT 生态，并新增一条通用的 Modbus 设备采集链路（首个落地的领域是能耗）：

1. **Modbus 设备采集（通用管道）**：通过 Modbus-TCP 网关周期读取挂在网关后面的 Modbus slave 设备（首个 profile 为 SDM120 单相电表），按设备的 **YAML profile** 解码为工程量，存入单库的**通用读数表**。后台静默轮询，支持多设备、多 profile。
2. **MQTT + Home Assistant Discovery**：后端作为 MQTT 发布方，按"**可勾选暴露**"的方式把数据以 HA Discovery 自动注册成 device/entity（不止 sensor）。
3. **前端侧边栏 + Energy 视图**：把现有顶栏改成侧边导航，承载首个领域视图 **Energy**（设备管理 + 最新读数 + 走势图）。

> **命名分层（本里程碑的核心决策）**：**存储 / 采集 / API 是通用的 `modbus_*`**（不锁死在"电表"上，以后接别的 Modbus 设备无需改表）；**面向用户的呈现是领域特定的**——本里程碑只落第一个领域视图 **Energy**，消费通用 device 数据、用电表视角展示。

> 三段有依赖关系，按 §6 的 `Depends` 顺序推进：A（侧边栏，独立）→ B（Modbus 采集后端 + Energy 前端）→ C（MQTT/Discovery，消费 B 的数据）。

## 2. 现状（实现者可据此工作，不必通读全仓库）

**单库数据层**（M1 完成态）
- `app/db.py`：`class Base(DeclarativeBase)`，绑 `settings.app_database_url` 的 cached engine（WAL 已开），`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.0 两层数据模型（本里程碑的地基决策）

把"设备是什么、怎么读"与"读到了什么"彻底分成两层，**协议知识与部署信息分离**：

```
┌─────────────────────────────────────────────────────────────┐
│ 协议层（固定，随代码走）         部署层（可配置，落 DB）       │
│ ── YAML profile ──────────       ── modbus_device 行 ──────   │
│ • 读哪些寄存器（FC/地址/块）     • friendly_name（可改）       │
│ • 每个量：key / 类型 / 解码      • host / port（网关）          │
│ • 每个量：unit / device_class    • unit_id（=电表 Meter ID，   │
│   / ha_component                   设备上可设，故落 DB）        │
│ • 唯一真相源，喂给"读/存/HA注册" • profile 名（选哪个 YAML）    │
│                                  • uuid / poll_interval / enabled│
└─────────────────────────────────────────────────────────────┘
        ▲ 多个 device 可共享同一 profile         │
        └─────────────────────────────────────────┘
                          │ 采集
                          ▼
            ── modbus_reading 行（通用遥测）──
            device_id · recorded_at · payload(JSON blob)
```

- **profile = 仓库内只读 YAML**（如 `app/integrations/modbus/profiles/sdm120.yaml`），随镜像打包、纳入版本控制，**只描述固定协议知识**：读哪些寄存器、怎么解码、每个量的 `key`/`unit`/`device_class`/`ha_component`。**绝不**放 friendly_name / unit_id 这类每设备各异的可配置项。
- **可配置项落 `modbus_device` 行**，由前端 UI 设置：friendly_name、host、port、**unit_id（Modbus 从机地址，设备面板上可改）**、选用的 profile 名、poll_interval、enabled。
- **多设备共享一个 profile**：例如两块 SDM120 都 `profile="sdm120"`，一块 friendly_name="SDM120 空调"、unit_id=1，另一块 friendly_name="SDM120 服务器"、unit_id=2——共用同一份解码规则，部署参数各自独立。
- **读数存通用 JSON `payload`**，不再用固定列。profile 是解释器：知道 payload 里有哪些 key、各自单位与 device_class。
  - 聚合不必搬到后端硬算：**SQLite 用 `json_extract` 在 SQL 端做 `AVG`/`MAX`/`GROUP BY`**（走势图都是带时间窗的查询，被 `(device_id, recorded_at)` 索引圈住，只扫窗内行）。
  - 真有某个量需要高频聚合 → 后续给该量加 **generated column + 表达式索引**（非破坏性，要哪个补哪个），无需一开始把表锁成固定列。

### 3.1 Modbus 采集

- **传输：仅 Modbus-TCP**（用户的 Waveshare RTU↔TCP 网关，RJ45 以太网；服务器无串口）。用 **pymodbus** 的 `ModbusTcpClient`，由它处理封帧 / CRC / 超时重试 / float 解码。
  - 角色厘清：**我们的后端 = Modbus client/master**；**网关 = TCP server**（在 "Modbus TCP" 模式下终结 TCP 再转 RTU）；**电表 = 网关后面的 RTU slave**，由 `unit_id` 寻址。`modbus_device` 一行同时装下网关地址（host:port）与 slave 地址（unit_id）。
  - 网关若开"Modbus TCP"协议转换 → pymodbus 默认 framer 直连。
  - 网关若是透传（RTU-over-TCP，裸 RTU 帧含 CRC）→ pymodbus 用 RTU framer over TCP。
  - **二者实现时对一次即可确定**（连上读 Voltage 寄存器验证），不影响表结构与上层。
- **协议知识在 YAML profile，部署信息在 DB**（见 §3.0）：
  - `app/integrations/modbus/driver.py`：薄封装 pymodbus 的连接 + 块读 + 大端 float32 解码（word/byte 都大端，高寄存器在前）。
  - `app/integrations/modbus/profiles.py`：YAML profile 的**加载 + 校验 + 解码 + 实体枚举**——纯"数据 + 函数"，**不做 OOP 抽象基类/继承**。`load_profile(name) -> ModbusProfile`（pydantic 模型，启动期校验）、`decode(profile, registers) -> dict[key -> value]`、`enumerate_entities(device, profile) -> list[ExposableEntity]`（供 §3.3）。
  - `app/integrations/modbus/profiles/*.yaml`：每个设备型号一个 YAML。首个 = `sdm120.yaml`（寄存器地址见参考文档 §4）。
  - profile 输出统一的 `dict[key -> value]`，由 service 整个塞进 `modbus_reading.payload`（JSON）；profile 没列出的量根本不读。
- **只读**：不写电表配置寄存器（改 Meter ID/波特率有通信中断风险）。
- **轮询**：每个设备一个 `poll_interval_s`（默认 5s），APScheduler 一个 job 扫所有 `enabled` 设备，逐设备块读→解码→落库。9600 总线上多设备串行，5s 余量充足。
  - 全局开关 `MODBUS_POLLING_ENABLED`（CONFIG_FIELDS）可一键停采。
  - 采集成功/失败写入设备的 `last_poll_at` / `last_poll_ok`（供 §3.3 的 online binary_sensor）。
  - **两级周期 / 降采样 / 保留是后续杠杆**（见 §10），本里程碑用单周期读全。

**`sdm120.yaml` 示例（只含固定协议知识）**

```yaml
name: sdm120
description: Eastron SDM120 single-phase energy meter
function_code: 4          # input registers (FC04)
word_order: big           # 高寄存器在前
byte_order: big
blocks:                   # 块读，减少 Modbus 事务
  - { start: 0x0000, count: 0x0060 }   # 30001..30095 连续段
  - { start: 0x0156, count: 0x0004 }   # total active/reactive energy
metrics:
  - { key: voltage,            address: 0x0000, type: float32, unit: V,   device_class: voltage,    ha_component: sensor }
  - { key: current,            address: 0x0006, type: float32, unit: A,   device_class: current,    ha_component: sensor }
  - { key: active_power,       address: 0x000C, type: float32, unit: W,   device_class: power,      ha_component: sensor }
  - { key: power_factor,       address: 0x001E, type: float32, unit: "",  device_class: power_factor, ha_component: sensor }
  - { key: frequency,          address: 0x0046, type: float32, unit: Hz,  device_class: frequency,  ha_component: sensor }
  - { key: import_energy,      address: 0x0048, type: float32, unit: kWh, device_class: energy, state_class: total_increasing, ha_component: sensor }
  - { key: export_energy,      address: 0x004A, type: float32, unit: kWh, device_class: energy, state_class: total_increasing, ha_component: sensor }
  - { key: total_energy,       address: 0x0156, type: float32, unit: kWh, device_class: energy, state_class: total_increasing, ha_component: sensor }
# 冷门量（demands / maxima / 无功电能…）可后续按需补进 metrics；未列出的就不读。
```

> 注意 YAML 里**没有** unit_id / friendly_name——那些是 `modbus_device` 行各自带的。

### 3.2 数据模型（新增两张通用表，单库 app 链）

**`modbus_device`**（设备定义 = 部署/可配置项，CRUD 管理）

| 列 | 类型 | 说明 |
| --- | --- | --- |
| id | int PK | 内部代理主键（FK join 用）|
| uuid | str unique | uuid4 生成的**稳定内部身份**；也作 API 路径键与 HA `unique_id` 的锚 |
| friendly_name | str | 显示名（可改；改名重发 discovery，HA 显示名跟着变）|
| transport | str | 现仅 `'tcp'` |
| host | str | 网关 IP |
| port | int | 网关端口（默认 502）|
| unit_id | int | Modbus 从机地址 = 电表 Meter ID（设备面板可改，默认 1）|
| profile | str | 用哪个 YAML profile（首个 `'sdm120'`）|
| poll_interval_s | int | 采样周期（默认 5）|
| enabled | bool | 是否轮询 |
| last_poll_at | datetime null | 最近一次轮询时刻（online 判定）|
| last_poll_ok | bool null | 最近一次轮询成败（online 判定）|
| created_at / updated_at | datetime | |

**`modbus_reading`**（通用遥测表，一行 = 一个设备的一次采样）

| 列 | 类型 | 说明 |
| --- | --- | --- |
| id | int PK | |
| device_id | int FK→modbus_device.id | **ON DELETE RESTRICT**（见 §5 删除语义）|
| recorded_at | datetime (UTC) | 采样时刻，**真实列、带索引**（所有查询按它走时间窗）|
| payload | JSON | profile 解码出的全部工程量 `{key: value}`；无固定列 |

- 索引：`(device_id, recorded_at)`。
- **SDM120 单相 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}`——key 由 `sdm120.yaml` 的 `metrics[].key` 决定。
- **接入新设备型号无需改表**：换 profile，payload 里的 key 集合随之变；表结构不动。
- **三相电表**以后用三相 profile，payload 里多几个相位 key（如 `voltage_l1/l2/l3`），仍是同一张表。

### 3.3 MQTT + HA Discovery（通用 expose 框架，元数据由 profile 派生）

HA MQTT Discovery 模型 = **device → entities**：往 `<prefix>/<component>/<node>/<object>/config` 发 retained 消息定义一个 entity；config 内 `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 注册表。
  - **Modbus/energy provider**：每个 `enabled` 设备 = 一个 HA **device**（`identifiers` 用设备 `uuid`），其各工程量 = 一组 sensor entity——**`device_class`/`unit`/`component` 直接取自该设备 profile 的 `metrics[]`**（不再单独维护一份映射），外加一个 `binary_sensor`「online」（取 `last_poll_ok`）——这就是"**不止 sensor**"的体现。
  - 其它 provider（如 public-ip、poo）可后续挂入，本里程碑只接 Modbus/energy provider。
- **HA 实体身份锚定（Z2M 模型）**：discovery config 的 **`unique_id` 用设备 `uuid` + 量的 `key` 派生**（稳定，不随改名变）；可见的 **`name` 用 friendly_name**。改 friendly_name → 重发 discovery → HA 显示名跟着变、但 `unique_id` 不变故历史不丢。topic 的 object_id 也用 uuid 派生（稳定、丑无所谓）。
- **`exposed_entities` 表**：只存"逐 key 的开关"（`key` unique + `enabled` + `updated_at`）。目录本身由 provider 计算，表只记被勾选的状态（默认未勾 = 不暴露）。
- **MQTT 客户端**：`app/integrations/mqtt.py` 用 **paho-mqtt**，`loop_start()` 后台线程；在 lifespan 起/停；支持配置变更后**重连 + 重发 discovery**。
- **发布时机**：
  - discovery config（retained）：连接成功时、目录/勾选变更时全量发；取消勾选时发空 payload 清除该 entity。
  - state：采集在每次轮询后推最新值；另有一个周期 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`，首个领域视图，消费通用 `/api/modbus` 数据）：
  - 设备管理：列表 + 新建/编辑/删除（删除有二次确认；后端对有读数的设备拒删，引导改用"禁用"）。新建/编辑表单里设 friendly_name、host、port、unit_id、profile（下拉选 `sdm120` 等）、poll_interval、enabled。
  - 最新读数卡片（每设备当前各工程量；字段标签/单位取自 profile 的 metrics 元数据，见 `/metrics` 端点）。
  - 走势图：用 **Recharts** 画时间序列（电压/电流/功率/电能），时间范围选择，取数走 readings API（窗口 + 上限），从 `payload` 里按 key 取序列。
- **Expose 设置**：设置页内一块「Home Assistant Expose」——列出可暴露实体目录、逐项勾选、显示 MQTT/Discovery 连接状态、一个"重新发布 discovery"按钮。

## 4. API 契约（M5 要落地的端点）

> 全部 `/api` 前缀、session + CSRF（写）保护、JSON 进出。schema 经 `export_openapi.py` 固化入库。路径键用设备 `uuid`（稳定、非自增）。

| 分组 | 端点 | 用途 |
| --- | --- | --- |
| Modbus | `GET /api/modbus/devices` | 列出设备 |
| Modbus | `POST /api/modbus/devices` | 新建设备 |
| Modbus | `GET /api/modbus/devices/{uuid}` | 单个设备 |
| Modbus | `PATCH /api/modbus/devices/{uuid}` | 修改设备（含 enable/disable）|
| Modbus | `DELETE /api/modbus/devices/{uuid}` | 删除设备；**有读数时 409**，引导改 disable |
| Modbus | `GET /api/modbus/devices/{uuid}/metrics` | 该设备 profile 的量目录（key/label/unit/device_class），供前端渲染卡片与图表标签 |
| Modbus | `GET /api/modbus/devices/{uuid}/latest` | 该设备最新一条读数（payload）|
| Modbus | `GET /api/modbus/devices/{uuid}/readings` | 时间范围读数（`start/end/limit`，limit 有上限），返回 `recorded_at + payload`，供走势图 |
| Modbus | `POST /api/modbus/devices/{uuid}/test` | 即时试读一次（验证网关连通/地址），返回解码 payload，不落库 |
| Modbus | `GET /api/modbus/profiles` | 列出可用 profile 名 + 描述（前端建设备时的下拉选项）|
| 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 **并发布一条测试消息**（MQTT Explorer 可见），仿 SMTP 测试三态 |

> MQTT broker / discovery 的**标量配置**复用现有 `GET/PUT /api/config`（只新增 CONFIG_FIELDS，不新增端点）。

## 5. 已锁定决策（讨论后拍板）

1. **里程碑编排**：一个 M5 文档分三段，`Depends` 串顺序（A 侧边栏 → B Modbus/Energy → C MQTT）。
2. **两层数据模型，协议与部署分离**：`modbus_device`（部署/可配置项：friendly_name、host、port、unit_id、profile 名、poll、enabled）+ `modbus_reading`（通用遥测：device_id、recorded_at、`payload` JSON）。取代原"宽表 + 固定列"。
3. **读数 = JSON `payload`，无固定列**；聚合走 SQLite `json_extract`（DB 端做 AVG/MAX/GROUP BY），热点量后补 generated column + 表达式索引。两级周期/降采样为后续杠杆。
4. **协议知识 = 仓库内只读 YAML profile**（声明式"数据 + 函数"，无 OOP 继承，pydantic 启动期校验）；YAML 携带寄存器 + 每个量的 key/unit/device_class/ha_component，是**唯一真相源**，同时驱动"读 / 存 / HA 注册"。多设备可共享一个 profile。首个 profile `sdm120`。
5. **Modbus 仅 TCP**，pymodbus，framer 配网关模式，**只读**采集。设备是网关后面的 slave，`unit_id` 寻址（设备面板可改，故落 DB）。
6. **命名分层（方案 C）**：存储/采集/API 一律通用 `modbus_*`、`/api/modbus/devices`；前端首个领域视图叫 **Energy**（消费通用 device 数据）。
7. **UUID = 内部生成（uuid4）的稳定身份**：既做内部索引/ API 路径键，也做 HA discovery `unique_id` 的锚；friendly_name 可改，改名重发 discovery、HA 显示名跟着变（Z2M 模型，历史不丢）。
8. **MQTT = 通用 expose 框架**：provider 动态产出可暴露实体目录，**实体元数据（device_class/unit/component）从 profile 派生**；`exposed_entities` 只存逐 key 开关；支持 sensor/binary_sensor/switch 等多 component；设备自动注册（每设备一 device、各量为 entity + 一个 online binary_sensor）。
9. **MQTT 库 = paho-mqtt**，lifespan 长连接，配置变更后重连 + 重发 discovery。
10. **MQTT broker/discovery 标量配置走现有扁平 CONFIG_FIELDS**（自动渲染）；设备清单与 expose 勾选走专用表 + 自定义 UI。
11. **图表库 = Recharts**（封在自包含组件后，仿 M2 对 Leaflet 的隔离）。
12. **删除设备安全**：FK `ON DELETE RESTRICT`，有读数拒删（避免一键删表丢历史）；"停用"用 `enabled=false`。
13. **Config 页用 Accordion 分区**（进页见大类、逐类展开），**不在 config 页内再放第二个 side nav**——避免与主侧栏（T01）的"双抽屉"冲突；纯前端、独立任务 M5-T01B。
14. **CLI 手工测试工具为"受控手工链路验证"而设**（设备接市电、非随时在线，不进自动化）：`scripts/modbus_cli` 提供 `read`（profile 解码）与 `probe`（手工指定请求内容、看原始回复）两个子命令，**一律只读**（仅 FC03/04），不暴露写寄存器。
15. **MQTT/HA 发布链路的手工验证走 UI + 外部工具，不另做 CLI**：Config 页「发送测试消息」（`mqtt/test` 发一条到测试 topic）→ 在 **MQTT Explorer** 查看；Expose 勾选实体 + 开 `HA_DISCOVERY_ENABLED` + 「重新发布 discovery」（`/api/expose/republish`）→ 到 **Home Assistant** 查看。不通则迭代配置再重发。

> 项目定位：个人自用、家庭特化、不开源——可按单用户场景简化，不过度抽象。

## 6. 任务依赖图

```
Phase A（独立，可最先做，纯前端）
  M5-T01  [structural] 侧边栏布局重构
  M5-T01B Config 页分区折叠（Accordion）   ← 与 T01 互不依赖，可并行/先后任意

Phase B（Modbus 采集 + Energy 前端）
  M5-T02 [schema] modbus_device + modbus_reading 表 + 模型
   ├─► M5-T03 Modbus 驱动 + YAML profile 框架（pymodbus + sdm120.yaml，纯模块）
   │     └─► M5-T04 采集 service + APScheduler 轮询（接 lifespan，落 payload）
   └─► M5-T05 Modbus JSON API（device CRUD + readings + metrics + test）
            └─► M5-T06 前端：设备管理 UI（依赖 T01 侧栏 + T05 API）
            └─► M5-T07 前端：读数展示 + Recharts 走势图（依赖 T05；引入 recharts）

Phase C（MQTT / Discovery，依赖 B 的设备数据与 provider 接口）
  M5-T08 MQTT/Discovery 配置项（CONFIG_FIELDS）
  M5-T09 [schema] exposed_entities 表 + ExposableEntity/provider 框架（modbus provider 从 profile 派生）
   ├─► M5-T10 MQTT 客户端（paho，lifespan 连接 + 重连 + config/mqtt/test）
   │     └─► M5-T11 Discovery 发布 + state 发布（连 T04 轮询推 state）
   └─► M5-T12 前端：Expose 勾选 UI + /api/expose API

收尾
  M5-T13 文档 + OpenAPI + roadmap 收尾（依赖全部）
```

`T01`、`T01B`、`T02`、`T08` 无前置可先开。

---

## 7. 原子任务（任务卡）

> 后端任务沿用校验闸门（`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库）。前端任务闸门见 §8。新增依赖（`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`）须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。

### M5-T01 — 侧边栏布局重构 `[structural]`
- **Status**: `done` · **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-T01B — Config 页分区折叠（Accordion）
- **Status**: `done` · **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`（折叠/展开断言）
- **Steps**:
  1. 用 Mantine `Accordion` 包住现有"按 section 分组"的渲染：每个 config section = 一个 `Accordion.Item`（标题 = section 名，面板 = 该 section 的字段表单）。
  2. 默认折叠（或首个展开）；**保留现有保存逻辑与 config API 不变**（M2 的整页/按 section 保存语义照旧，本任务不碰后端）。
  3. 预留：将来 T12 的「Home Assistant Expose」自定义面板也作为一个 `Accordion.Item` 接入，保持一致。
  4. 移动端单列堆叠即可，**不引入第二个侧栏/抽屉**。
- **Out of scope / 不要碰**: 不改后端 config API/字段（`CONFIG_FIELDS` / `_settings_payload`）；不动主侧栏（T01）；不改保存语义；不在 config 页内放任何 app 级 side nav。
- **Acceptance criteria**:
  - [ ] ConfigPage 以 accordion 呈现，每大类可独立展开/折叠；字段渲染与保存行为与现状一致。
  - [ ] 与主侧栏无视觉/交互冲突（单列内容；移动端不出现双抽屉）。
  - [ ] 前端闸门全绿（`lint`/`typecheck`/`test`/`build`）。
- **Reviewer checklist**: 仅改 ConfigPage（+其测试），未碰 config 后端或主 layout；保存逻辑无回归；页面内**无第二个 app 级 sidebar**（accordion 是内容、非 chrome）。

### M5-T02 — `modbus_device` + `modbus_reading` 表与模型 `[schema]`
- **Status**: `done` · **Depends**: none
- **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`
- **Steps**:
  1. 模型按 §3.2 列定义（`Mapped[...]` 2.0 风格）：`ModbusDevice` 含 `uuid`(unique)、`friendly_name`、`transport`、`host`、`port`、`unit_id`、`profile`、`poll_interval_s`、`enabled`、`last_poll_at`/`last_poll_ok`(nullable)、时间戳；`uuid` 用 `default` 生成 uuid4 字符串。`ModbusReading` 含 `device_id` FK→`modbus_device.id`（**`ondelete="RESTRICT"`**）、`recorded_at`、`payload`（JSON 列，非 null）。
  2. 新 revision：`down_revision` = 当前 head；`upgrade()` 用 `op.create_table` 建两表 + 索引 `(device_id, recorded_at)`；`downgrade()` 反向 drop。
  3. 更新 `APP_BASELINE_REVISION`。
- **Out of scope / 不要碰**: 不写 pymodbus/采集（T03/T04）；不加路由（T05）；不动其它模型。
- **Acceptance criteria**:
  - [ ] 全新临时 app 库 upgrade 到 head 后含 `modbus_device`、`modbus_reading` 及索引；`downgrade -1` 干净回滚。
  - [ ] `Base.metadata.tables` 含两新表；FK 为 RESTRICT；`uuid` 唯一且自动生成。
  - [ ] `APP_BASELINE_REVISION` == 新 head。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 列/约束与 §3.2 一致；`payload` 为 JSON 列；`recorded_at` 为真实索引列（非塞进 payload）；链上单 head；`env.py` 已 import 新模型（否则 autogenerate/建表漏表）。

### M5-T03 — Modbus 驱动 + YAML profile 框架（`sdm120`）
- **Status**: `done` · **Depends**: M5-T02
- **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`
- **Steps**:
  1. `driver.py`：`read_blocks(host, port, unit_id, blocks) -> dict[int,int]`（按 profile 的 blocks 块读 input registers，FC04），用 `ModbusTcpClient`；超时/连接失败抛明确异常；大端 float32 解码 helper（`registers_to_float`，高寄存器在前）。framer 选择留可配置/可探测。
  2. `profiles.py`：定义 pydantic `ModbusProfile` / `MetricSpec`（`key`/`address`/`type`/`unit`/`device_class`/`state_class`?/`ha_component`）；`load_profile(name) -> ModbusProfile`（读 `profiles/<name>.yaml`，校验失败抛错）；`decode(profile, registers) -> dict[key -> value]`（按各 metric 的 address+type 从寄存器对解码）；`list_profiles() -> list[(name, description)]`。**不做抽象基类/继承**，全是数据 + 模块函数。
  3. `profiles/sdm120.yaml`：按 §3.1 示例写全核心量（参考文档 §4 地址）。
  4. `scripts/modbus_cli.py`：纯命令行、**不依赖 DB / 不需先配设备**，供**受控手工测试**（设备接市电、非随时在线，不进自动化）。两个**只读**子命令：
     - `read --host H --port P --unit U --profile sdm120`：按 profile 读一次、解码后**把各工程量打印成可读结果**（表格/JSON）——验证整套解码链路。
     - `probe --host H --port P --unit U --fc 4 --address 0x0000 --count 2 [--decode float32]`：**手工指定要发送的请求内容**（功能码 FC03/04 + 起始地址 + 数量），打印**原始寄存器（hex）+ 可选大端 float 解码**——first-contact 验证网关连通、framer 模式与 unit 地址，可单读一个寄存器，不依赖 profile。
     连接失败给清晰报错 + 非零退出。**只读**：CLI 仅暴露读功能码（FC03/04），**不提供任何写寄存器子命令**（数据红线 + 防改坏电表通信参数）。
- **Out of scope / 不要碰**: 不连真实硬件（单测用 mock/fake 返回已知寄存器字节）；不写调度（T04）；不写电表配置寄存器；不在 profile 里放 unit_id/friendly_name。
- **Acceptance criteria**:
  - [ ] 单测：给定 `0x4366,0x3334` 解码为 `230.2`（参考文档实例）；字序/字节序正确。
  - [ ] 单测：`load_profile("sdm120")` 校验通过；`decode(profile, ...)` 把已知寄存器映射到正确 key 与值。
  - [ ] 单测：profile YAML 缺字段/类型错时 `load_profile` 抛可识别校验错。
  - [ ] 连接失败/超时抛可识别异常，不静默返回错值。
  - [ ] `python -m scripts.modbus_cli read ...` 能（对 mock/真实网关）打印解码后的各工程量；连接失败非零退出。
  - [ ] `python -m scripts.modbus_cli probe --fc 4 --address 0x0000 --count 2 ...` 能打印原始寄存器与可选解码值；CLI **无任何写寄存器子命令**。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 解码确为大端、高寄存器在前；地址与参考文档一致；**CLI 与 driver 都无任何写寄存器路径（仅 FC03/04）**；profile 纯协议知识、无部署项；`requirements.txt` 已同步锁定 `pymodbus`、`PyYAML`。

### M5-T04 — 采集 service + APScheduler 轮询
- **Status**: `done` · **Depends**: M5-T03
- **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`
- **Steps**:
  1. `modbus_poll.py`：`poll_device(session, device) -> ModbusReading | None`（`load_profile` → driver 读 → `decode` → 把 dict 存进 `payload` 插一行；更新 `last_poll_at`/`last_poll_ok`）；`poll_all_enabled_devices(session)` 遍历 enabled 设备；service 内吞异常并日志，不让 job 崩。
  2. `main.py`：加同步 wrapper `_run_scheduled_modbus_poll`（自管 session），`add_job(IntervalTrigger(seconds=...), id="modbus-poll", max_instances=1, coalesce=True)`。周期取**最小 per-device interval 或一个基础 tick**（实现可用单一基础 tick + 各设备按自身 interval 取模决定本 tick 是否读，保持 job 简单）；受全局 `MODBUS_POLLING_ENABLED` 控制。
  3. 失败的设备记 `last_poll_ok=false`（供 T11 暴露），不影响其它设备。
- **Out of scope / 不要碰**: 不发 MQTT（T11）；不加 HTTP 路由（T05）；不引入两级周期（后续杠杆）。
- **Acceptance criteria**:
  - [ ] 单测：mock driver 返回已知 dict，`poll_all_enabled_devices` 后 `modbus_reading` 精确 +N 行、`payload` 内容正确。
  - [ ] 单测：某设备读失败时其它设备仍正常落库，job 不抛；失败设备 `last_poll_ok=false`。
  - [ ] `MODBUS_POLLING_ENABLED=false` 时不轮询。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭；job `max_instances=1` 防叠加；无 N+1/每行单独 connect 的明显低效；异常不外泄崩 job；payload 为解码后的 dict（非裸寄存器）。

### M5-T05 — Modbus JSON API（device CRUD + readings + metrics + test）
- **Status**: `done` · **Depends**: M5-T02
- **Context**: 给前端提供设备 CRUD、最新读数、时间范围读数、量目录、即时试读。
- **Files**: `create app/api/routes/api/modbus.py`、`app/schemas/modbus.py`；`modify app/main.py`（注册路由）；`create tests/test_api_modbus.py`
- **Steps**:
  1. devices：`GET`(list)/`POST`/`GET{uuid}`/`PATCH{uuid}`/`DELETE{uuid}`；session+CSRF；`POST` 校验 profile 名存在；`DELETE` 有读数 → 409。
  2. readings：`GET {uuid}/latest`（最新一行 payload）、`GET {uuid}/readings`（`start/end/limit`，limit 有上限防全表导出，按 `recorded_at` 升序，返回 `recorded_at + payload`）。
  3. `GET {uuid}/metrics`：返回该设备 profile 的量目录（key/label/unit/device_class），供前端渲染。
  4. `GET /api/modbus/profiles`：`list_profiles()` 的名+描述。
  5. `POST {uuid}/test`：用 driver 即时读一次返回解码 payload（或错误），**不落库**。
- **Out of scope / 不要碰**: 不在此处发 MQTT；不写采集逻辑（复用 T03/T04 的 driver/service）。
- **Acceptance criteria**:
  - [ ] CRUD 行为正确：创建/改/删行数精确；删有读数的设备返回 409；未登录 401、缺 CSRF 403；建设备引用不存在 profile → 422。
  - [ ] readings 时间范围 + limit 上限生效；latest 返回最新一条 payload；metrics 返回 profile 量目录。
  - [ ] schema 经 OpenAPI 固化入库。
  - [ ] 校验闸门全绿（含 `openapi/` 重导出）。
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径；查询走 `(device_id, recorded_at)` 索引；test 端点确不落库；路径键用 `uuid`。

### M5-T06 — 前端：设备管理 UI（Energy 视图）
- **Status**: `done` · **Depends**: M5-T01, M5-T05
- **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`
- **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API；列表 + 新建/编辑表单（friendly_name/host/port/unit_id/profile 下拉/poll/enabled）+ 删除二次确认（删失败 409 提示改用禁用）；"试读"按钮调 `POST {uuid}/test` 显示结果。
- **Out of scope / 不要碰**: 走势图在 T07；不碰其它页面。
- **Acceptance criteria**:
  - [ ] 能增/改/删设备并即时刷新；删除有二次确认；409 有友好提示；profile 走 `/api/modbus/profiles` 下拉。
  - [ ] 侧栏出现 Energy 入口、`/energy` 可达。
  - [ ] 前端闸门全绿。
- **Reviewer checklist**: 全部走生成的类型化 client；删除走确认；无与契约不符的手写请求。

### M5-T07 — 前端：读数展示 + Recharts 走势图
- **Status**: `done` · **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`，字段标签/单位取自 `/metrics`）；时间范围选择 + 折线图（电压/电流/功率/电能，从 `payload` 按 key 取序列），接 `readings`（窗口 + limit）；图表封在 `EnergyCharts` 内（仿 Leaflet 隔离，便于将来换库）。
- **Out of scope / 不要碰**: 不做服务端降采样（后续）；不改后端。
- **Acceptance criteria**:
  - [ ] 最新读数与走势图渲染正确；时间范围只取窗口数据（不拉全量）；标签/单位来自 metrics。
  - [ ] Recharts 封装自包含、仅此处 import。
  - [ ] 前端闸门全绿（`build` 通过，注意 chunk 体积提示）。
- **Reviewer checklist**: 图表组件隔离；查询有窗口/上限；空数据/加载/错误态有处理；从 payload 取 key 的逻辑容忍缺 key。

### M5-T08 — MQTT / Discovery 配置项（CONFIG_FIELDS）
- **Status**: `done` · **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`)、`modbus_polling_enabled`；CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Modbus」section；`_settings_payload` 补齐对应行。
- **Out of scope / 不要碰**: 不建 MQTT 客户端（T10）；不动 expose 表（T09）。
- **Acceptance criteria**:
  - [ ] 新配置项在 `GET /api/config` 出现且分 section；password 为 secret（回空、留空保留）；port 为 number。
  - [ ] 非法值（端口非数字）422 不写库。
  - [ ] 校验闸门全绿（OpenAPI 若变化则重导出）。
- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例；`_settings_payload` 未漏字段（否则运行期 override 丢失）。

### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]`
- **Status**: `done` · **Depends**: M5-T02
- **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`
- **Steps**:
  1. `ExposableEntity`（`key/component/device/device_class/unit/value_getter`）+ provider 接口 `enumerate(session) -> list[ExposableEntity]`。
  2. modbus provider：每个 enabled 设备 → 一个 device（`identifiers` 用设备 `uuid`），其各量 → sensor entity（**device_class/unit/component 取自 profile 的 `metrics[]`**），加一个 `binary_sensor` online（取 `last_poll_ok`）。
  3. `build_catalog(session)` 合并所有 provider 的实体 + 各自 `enabled`（来自 toggle 表，缺省 false）。
  4. migration 建 toggle 表；更新 baseline 常量。
- **Out of scope / 不要碰**: 不发 MQTT（T11）；不加 HTTP（T12）。
- **Acceptance criteria**:
  - [ ] 单测：建若干设备后 `build_catalog` 产出每设备对应 entity（含 online binary_sensor）+ 正确 device 分组、device_class、unit（与 profile 一致）。
  - [ ] toggle 表 migration 可升/降；缺省 enabled=false。
  - [ ] 至少含一个非 sensor component（online binary_sensor）。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: `key` 稳定（用设备 `uuid` + 量 key，不用自增 id，避免重建漂移）；component 支持多类型；目录由 provider 计算、元数据源自 profile 而非写死。

### M5-T10 — MQTT 客户端（paho，lifespan 连接 + 重连）
- **Status**: `done` · **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：启用则 connect；shutdown disconnect。
  3. 配置保存后若 MQTT 设置变化 → 触发 manager 重连（在 config 保存路径加 hook 或保存后比对）。
  4. `POST /api/config/mqtt/test`：用提交/现存配置试连**并发布一条测试消息**到一个测试 topic（如 `<discovery_prefix>/home-automation/test`），返回三态（success/config-error/failed）。仿 SMTP 测试"发一封测试邮件"的语义——用户随后在 **MQTT Explorer** 里就能看到这条消息，确认 broker 发布链路通（这是 MQTT 端的手工验证手段，**不另做 CLI**）。
- **Out of scope / 不要碰**: 不构建 discovery/state 消息（T11）。
- **Acceptance criteria**:
  - [ ] 单测（fake broker/paho mock）：configured 时 connect 调用正确；未配置 no-op；publish 透传 topic/payload/retain。
  - [ ] `POST /api/config/mqtt/test` 试连**并发布一条测试消息**（可在 MQTT Explorer 看到）；三态有明确返回；session+CSRF 保护。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 断网/连接失败不崩主进程；线程在 shutdown 正确停止；`requirements.txt` 同步锁定 `paho-mqtt`；密码不进日志。

### M5-T11 — Discovery 发布 + state 发布
- **Status**: `done` · **Depends**: M5-T09, M5-T10
- **Context**: 把 enabled 实体发成 HA discovery config（retained）并周期推 state；采集轮询后推最新值。
- **Files**: `create app/services/ha_discovery.py`；`modify app/services/modbus_poll.py`（轮询后推 state）、`app/main.py`（state 周期 job + 连接后/勾选变更后发 discovery）、`app/api/routes/api/...`（`/api/expose/republish` 在 T12 接，本任务提供 service）；`create tests/test_ha_discovery.py`
- **Steps**:
  1. `build_discovery_payload(entity)` → HA 规范 config（`<prefix>/<component>/<node>/<object>/config`，含 `device` 块、`state_topic`、`device_class`、`unit_of_measurement`、`availability`；**`unique_id` 用设备 uuid + 量 key 派生，`name` 用 friendly_name**）。
  2. `publish_discovery(session)`：对 enabled 实体发 retained config；对取消勾选的发空 payload 清除。
  3. `publish_states(session)`：取各实体当前值发 state；采集在 `poll_device` 成功后顺带推该设备实体 state + online。
  4. lifespan：连接成功 / 目录或勾选变更后 `publish_discovery`；周期 job 兜底 `publish_states` + availability。
- **Out of scope / 不要碰**: 不做前端（T12）；不改采集解码逻辑。
- **Acceptance criteria**:
  - [ ] 单测：discovery payload 符合 HA 结构（device 分组正确、topic/device_class/unit 正确、`unique_id` 取自 uuid）；取消勾选发空 payload。
  - [ ] 单测：采集轮询成功后推对应 state topic；失败推 online=false。
  - [ ] discovery 用 retained。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 仅发 enabled 实体；entity `unique_id` 稳定（源自 uuid，不随改名变）；MQTT 未启用时整链 no-op；不阻塞轮询。

### M5-T12 — 前端：Expose 勾选 UI + `/api/expose`
- **Status**: `done` · **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+CSRF；OpenAPI 固化。
  - [ ] 勾选变更后（或点重新发布）触发 discovery 重发。
  - [ ] 前端能勾选并显示状态；前后端闸门全绿。
- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置；republish 真触发 T11；类型化 client。

### M5-T13 — 文档 + OpenAPI + roadmap 收尾
- **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/`
- **Acceptance criteria**:
  - [ ] 文档反映新链路；`git diff --exit-code openapi/` 无未提交差异。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 无残留旧描述（含旧 `energy_meters`/`/api/energy` 字样）；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`、`PyYAML`、`paho-mqtt`）与前端依赖（`recharts`）：必须同步 `requirements.in/.txt` 的重新锁定与 `package-lock.json`，否则镜像构建会缺包。
- 新增源文件都在 `app/`、`scripts/` 与 `frontend/` 既有 COPY 范围内，无需改 `Dockerfile` 的 `COPY`；**但 `app/integrations/modbus/profiles/*.yaml` 是非 .py 资源**——确认 `COPY app ...` 把整个目录（含 YAML）带进镜像，且运行期能按相对路径定位 YAML（建议用 `importlib.resources` 或基于 `__file__` 的路径，别用 CWD 相对路径）。`scripts/modbus_cli.py` 须在镜像里可 `python -m scripts.modbus_cli` 调用；`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
- 发版前置走查（见 CLAUDE.md）：真起 app 跑一次轮询、真连一次 broker、前端 Energy 视图人工瞄一眼渲染，再打 tag。

## 10. 后续杠杆（本里程碑不做，文档留痕）

- **热点量的 generated column + 索引**：JSON `payload` 默认无法对单个量建索引；某个量若需高频聚合，给 `modbus_reading` 加一列 `GENERATED ALWAYS AS (json_extract(payload,'$.<key>'))` 并建索引——非破坏性、要哪个补哪个。
- **两级采样周期**：瞬时量（V/I/P/PF/Hz）快、累计电能慢——9600 总线吃紧或要把功率压到 5s 以下时再开（device 表加 `slow_interval_s`，读数表是通用 payload、无需改结构）。
- **保留 / 降采样**：5s 采样长期行数大（≈630 万行/年/设备），加定期降采样或保留窗口任务（独立于本里程碑），`GROUP BY` + `AVG(json_extract(...))` 在 SQL 端做。
- **更多 device profile**：三相电表（如 SDM630）新增一个 YAML profile（payload 里多几个相位 key），不改表、不改采集主链。
- **更多 expose provider**：public-ip / poo 等挂入 expose 框架。
- **写 Modbus 寄存器**：当前只读；如需经 MQTT/UI 控制设备（switch 类），再单独评估安全边界。

## 11. 人工验收 walkthrough（实现完成后）

> 重点：用命令行**手工**读到设备数据并展示结果。这些是**受控手工测试**——设备接市电、并非随时在线，故**不纳入自动化测试**（自动化只用 mock）；CLI 工具（`modbus_cli read/probe`）就是为这种"我想测的时候手动测一次"而设。可用 `docker compose` 起环境，命令在容器内或容器外跑均可。

**前提**：网关（Waveshare RTU↔TCP）已上电接入网络，电表 Meter ID 已知（默认 1）。

**1) 命令行直接试读（不依赖 DB，最快验证）**
- 容器内：`docker compose exec <app> python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
- 或容器外：`source .venv/bin/activate && python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
- 预期：打印解码后的工程量（电压/电流/有功功率/功率因数/频率/导入导出电能等），数值合理；连不上则清晰报错。
- **first-contact / 原始验证**：`python -m scripts.modbus_cli probe --host <网关IP> --port 502 --unit 1 --fc 4 --address 0x0000 --count 2 --decode float32` —— 手工指定请求内容、看原始回复（电压寄存器应解出约 230V），用于确认网关 framer 模式与 unit 地址；建 profile / 设备前就能跑。

**2)（可选）经 API 试读已配置的设备**
- 先在前端 Energy 页或 `POST /api/modbus/devices` 建一个设备；
- `POST /api/modbus/devices/{uuid}/test` 即时试读，返回解码 payload（不落库）。

**3)（可选）验证后台轮询落库**
- 确认 `MODBUS_POLLING_ENABLED=true` 且设备 `enabled`；等一个采样周期；
- 看前端 Energy 视图的最新读数/走势图，或查 `GET /api/modbus/devices/{uuid}/readings` 有新行。

**4)（可选）手工验证 MQTT / HA Discovery 发布链路**（受控手工步骤，不进自动化）
- **broker 发布链路**：配好 MQTT broker 后，在 Config 页点「发送测试消息」（`POST /api/config/mqtt/test`）——它试连并发一条测试消息；打开 **MQTT Explorer** 确认能收到，即链路通。
- **HA Discovery**：在 Expose 设置勾选若干实体、开 `HA_DISCOVERY_ENABLED`，点「重新发布 discovery」（`POST /api/expose/republish`）；到 **Home Assistant** 确认对应 device/entity 出现、值正确。
- **改名验证**：改某设备 friendly_name 后重发，确认 HA 显示名跟着变、历史不丢（`unique_id` 稳定）。
- 不通则按需调整配置/勾选再重发即可。

## 12. 里程碑完成定义（DoD）

- 后端能按 per-device 周期静默轮询 Modbus-TCP 设备、按 YAML profile 解码落 `modbus_reading.payload`，支持多设备 CRUD。
- MQTT 启用时，勾选的实体以 HA Discovery 注册成 device/entities（含非 sensor），state 周期发布；配置变更可重连重发；改 friendly_name 重发后 HA 显示名跟着变、`unique_id` 不变。
- 前端侧边栏可切换功能；Energy 视图能管理设备、看最新读数与走势图；设置页可勾选 expose。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 已入库。
- README / architecture / roadmap / design 索引反映 M5 现实。
