diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index 9efd09b..85467a6 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -1,17 +1,19 @@ -# M5 — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏) +# 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 生态,并新增一条能耗采集链路: +给后端接入家庭 IoT 生态,并新增一条通用的 Modbus 设备采集链路(首个落地的领域是能耗): -1. **Modbus/Energy 采集**:通过 Modbus-TCP 网关周期读取电表(首个 profile 为 SDM120 单相),解码为工程量,存入单库的能耗表。后台静默轮询,支持多电表。 +1. **Modbus 设备采集(通用管道)**:通过 Modbus-TCP 网关周期读取挂在网关后面的 Modbus slave 设备(首个 profile 为 SDM120 单相电表),按设备的 **YAML profile** 解码为工程量,存入单库的**通用读数表**。后台静默轮询,支持多设备、多 profile。 2. **MQTT + Home Assistant Discovery**:后端作为 MQTT 发布方,按"**可勾选暴露**"的方式把数据以 HA Discovery 自动注册成 device/entity(不止 sensor)。 -3. **前端侧边栏**:把现有顶栏改成侧边导航,承载新的 Energy 视图(电表管理 + 最新读数 + 走势图)。 +3. **前端侧边栏 + Energy 视图**:把现有顶栏改成侧边导航,承载首个领域视图 **Energy**(设备管理 + 最新读数 + 走势图)。 -> 三段有依赖关系,按 §6 的 `Depends` 顺序推进:A(侧边栏,独立)→ B(Energy 后端 + 前端)→ C(MQTT/Discovery,消费 B 的数据)。 +> **命名分层(本里程碑的核心决策)**:**存储 / 采集 / API 是通用的 `modbus_*`**(不锁死在"电表"上,以后接别的 Modbus 设备无需改表);**面向用户的呈现是领域特定的**——本里程碑只落第一个领域视图 **Energy**,消费通用 device 数据、用电表视角展示。 + +> 三段有依赖关系,按 §6 的 `Depends` 顺序推进:A(侧边栏,独立)→ B(Modbus 采集后端 + Energy 前端)→ C(MQTT/Discovery,消费 B 的数据)。 ## 2. 现状(实现者可据此工作,不必通读全仓库) @@ -27,7 +29,7 @@ - `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)。 +- ⚠️ 扁平 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)`。 @@ -46,140 +48,199 @@ ## 3. 目标架构 -### 3.1 Modbus / Energy 采集 +### 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 寄存器验证),不影响表结构与上层。 -- **协议知识在代码(profile/driver),部署信息在 DB(meters 表)**: - - `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。 +- **协议知识在 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/几个表余量充足。 - - 全局开关 `ENERGY_POLLING_ENABLED`(CONFIG_FIELDS)可一键停采。 +- **轮询**:每个设备一个 `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),本里程碑用单周期读全。 -### 3.2 数据模型(新增两张表,单库 app 链) +**`sdm120.yaml` 示例(只含固定协议知识)** -**`energy_meters`**(电表定义,CRUD 管理) +```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 | | -| slug | str unique | 稳定标识(用于 MQTT entity key / HA device identifier)| -| name | str | 显示名 | +| 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 | 设备 profile(首个 `'sdm120'`)| +| 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 | | -**`energy_readings`**(一张相位感知宽表,一行 = 一个电表的一次采样) +**`modbus_reading`**(通用遥测表,一行 = 一个设备的一次采样) | 列 | 类型 | 说明 | | --- | --- | --- | | 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 等)| +| device_id | int FK→modbus_device.id | **ON DELETE RESTRICT**(见 §5 删除语义)| +| recorded_at | datetime (UTC) | 采样时刻,**真实列、带索引**(所有查询按它走时间窗)| +| payload | JSON | profile 解码出的全部工程量 `{key: value}`;无固定列 | -- 索引:`(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**。 +- 索引:`(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 框架) +### 3.3 MQTT + HA Discovery(通用 expose 框架,元数据由 profile 派生) HA MQTT Discovery 模型 = **device → entities**:往 `////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 注册表。 - - **Energy provider**:每个 `enabled` 电表 = 一个 HA **device**,其各工程量 = 一组 sensor entity(带 `device_class=voltage/current/power/energy/frequency` 与单位),外加一个 `binary_sensor`「meter online」(轮询成功/失败)——这就是"**不止 sensor**"的体现。 - - 其它 provider(如 public-ip、poo)可后续挂入,本里程碑至少接 energy + 一个示例。 + - **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:energy 在每次轮询后推最新值;另有一个周期 job 兜底重发所有 enabled 实体的 state + availability(在线)topic。 + - 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`): - - 电表管理:列表 + 新建/编辑/删除(删除有二次确认;后端对有读数的电表拒删,引导改用"禁用")。 - - 最新读数卡片(每电表当前各工程量)。 - - 走势图:用 **Recharts** 画时间序列(电压/电流/功率/电能),时间范围选择,取数走 readings API(窗口 + 上限)。 +- **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` 固化入库。 +> 全部 `/api` 前缀、session + CSRF(写)保护、JSON 进出。schema 经 `export_openapi.py` 固化入库。路径键用设备 `uuid`(稳定、非自增)。 | 分组 | 端点 | 用途 | | --- | --- | --- | -| 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` | 即时试读一次(验证网关连通/地址),不落库 | +| 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 连接(仿 SMTP 测试三态)| +| 配置 | `POST /api/config/mqtt/test` | 试连 broker **并发布一条测试消息**(MQTT Explorer 可见),仿 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**,pymodbus,framer 配网关模式,**只读**采集。 -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`。 +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] 侧边栏布局重构 +Phase A(独立,可最先做,纯前端) + M5-T01 [structural] 侧边栏布局重构 + M5-T01B Config 页分区折叠(Accordion) ← 与 T01 互不依赖,可并行/先后任意 -Phase B(Energy) - M5-T02 [schema] energy_meters + energy_readings 表 + 模型 - ├─► M5-T03 Modbus 驱动 + sdm120 profile(pymodbus,纯模块) - │ └─► M5-T04 Energy service + APScheduler 轮询(接 lifespan) - └─► M5-T05 Energy JSON API(meters CRUD + readings 查询 + test) - └─► M5-T06 前端:电表管理 UI(依赖 T01 侧栏 + T05 API) +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 的 energy 数据与 provider 接口) +Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) M5-T08 MQTT/Discovery 配置项(CONFIG_FIELDS) - M5-T09 [schema] exposed_entities 表 + ExposableEntity/provider 框架(energy provider) + 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 @@ -188,13 +249,13 @@ Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口) M5-T13 文档 + OpenAPI + roadmap 收尾(依赖全部) ``` -`T01`、`T02`、`T08` 无前置可先开。 +`T01`、`T01B`、`T02`、`T08` 无前置可先开。 --- ## 7. 原子任务(任务卡) -> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`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]` - **Status**: `todo` · **Depends**: none @@ -212,101 +273,124 @@ Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口) - [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。 - **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。 -### M5-T02 — `energy_meters` + `energy_readings` 表与模型 `[schema]` +### M5-T01B — Config 页分区折叠(Accordion) - **Status**: `todo` · **Depends**: none -- **Context**: 单库 app 链新增两表,建出 §3.2 结构。本任务只建 schema + 模型,不写采集/接口。 -- **Files**: `create app/models/energy.py`(`EnergyMeter`、`EnergyReading`,继承 `app.db.Base`);`create alembic_app/versions/_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` +- **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. 模型按 §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。 + 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**: `todo` · **Depends**: none +- **Context**: 单库 app 链新增两张**通用**表,建出 §3.2 结构(设备 = 部署层,读数 = JSON payload 通用遥测层)。本任务只建 schema + 模型,不写采集/接口。 +- **Files**: `create app/models/modbus.py`(`ModbusDevice`、`ModbusReading`,继承 `app.db.Base`);`create alembic_app/versions/_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 后含 `energy_meters`、`energy_readings` 及索引;`downgrade -1` 干净回滚。 - - [ ] `Base.metadata.tables` 含两新表;FK 为 RESTRICT。 + - [ ] 全新临时 app 库 upgrade 到 head 后含 `modbus_device`、`modbus_reading` 及索引;`downgrade -1` 干净回滚。 + - [ ] `Base.metadata.tables` 含两新表;FK 为 RESTRICT;`uuid` 唯一且自动生成。 - [ ] `APP_BASELINE_REVISION` == 新 head。 - [ ] 校验闸门全绿。 -- **Reviewer checklist**: 列/约束与 §3.2 一致;`extra` 为 JSON nullable;链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。 +- **Reviewer checklist**: 列/约束与 §3.2 一致;`payload` 为 JSON 列;`recorded_at` 为真实索引列(非塞进 payload);链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。 -### M5-T03 — Modbus 驱动 + `sdm120` profile +### M5-T03 — Modbus 驱动 + YAML profile 框架(`sdm120`) - **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` +- **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. `modbus.py`:`read_meter(host, port, unit_id, blocks) -> dict[int,int]`(块读 input registers,FC04),用 `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);不写电表配置寄存器。 + 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/.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`(参考文档实例);字序/字节序正确。 - - [ ] 单测:`SDM120_PROFILE.decode(...)` 把已知寄存器映射到正确列名与值;冷门量进 `extra`。 + - [ ] 单测:`load_profile("sdm120")` 校验通过;`decode(profile, ...)` 把已知寄存器映射到正确 key 与值。 + - [ ] 单测:profile YAML 缺字段/类型错时 `load_profile` 抛可识别校验错。 - [ ] 连接失败/超时抛可识别异常,不静默返回错值。 - - [ ] `python -m scripts.energy_cli read ...` 能(对 mock/真实网关)打印解码后的各工程量;连接失败非零退出。 + - [ ] `python -m scripts.modbus_cli read ...` 能(对 mock/真实网关)打印解码后的各工程量;连接失败非零退出。 + - [ ] `python -m scripts.modbus_cli probe --fc 4 --address 0x0000 --count 2 ...` 能打印原始寄存器与可选解码值;CLI **无任何写寄存器子命令**。 - [ ] 校验闸门全绿。 -- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;无任何写寄存器路径;`requirements.txt` 已同步锁定 `pymodbus`。 +- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;**CLI 与 driver 都无任何写寄存器路径(仅 FC03/04)**;profile 纯协议知识、无部署项;`requirements.txt` 已同步锁定 `pymodbus`、`PyYAML`。 -### M5-T04 — Energy service + APScheduler 轮询 +### M5-T04 — 采集 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` +- **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. `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 暴露),不影响其它电表。 + 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 返回已知值,`poll_all_enabled_meters` 后 `energy_readings` 精确 +N 行、列值正确。 - - [ ] 单测:某电表读失败时其它电表仍正常落库,job 不抛。 - - [ ] `ENERGY_POLLING_ENABLED=false` 时不轮询。 + - [ ] 单测: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。 +- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job;payload 为解码后的 dict(非裸寄存器)。 -### M5-T05 — Energy JSON API(meters CRUD + readings + test) +### M5-T05 — Modbus JSON API(device CRUD + readings + metrics + 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` +- **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. 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 即时读一次返回解码值(或错误),**不落库**。 + 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。 - - [ ] readings 时间范围 + limit 上限生效;latest 返回最新一条。 + - [ ] CRUD 行为正确:创建/改/删行数精确;删有读数的设备返回 409;未登录 401、缺 CSRF 403;建设备引用不存在 profile → 422。 + - [ ] readings 时间范围 + limit 上限生效;latest 返回最新一条 payload;metrics 返回 profile 量目录。 - [ ] schema 经 OpenAPI 固化入库。 - [ ] 校验闸门全绿(含 `openapi/` 重导出)。 -- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(meter_id, recorded_at)` 索引;test 端点确不落库。 +- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。 -### M5-T06 — 前端:电表管理 UI +### M5-T06 — 前端:设备管理 UI(Energy 视图) - **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` 显示结果。 +- **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 有友好提示。 + - [ ] 能增/改/删设备并即时刷新;删除有二次确认;409 有友好提示;profile 走 `/api/modbus/profiles` 下拉。 - [ ] 侧栏出现 Energy 入口、`/energy` 可达。 - [ ] 前端闸门全绿。 - **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。 ### M5-T07 — 前端:读数展示 + Recharts 走势图 - **Status**: `todo` · **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` 对应测试 -- **Steps**: 最新读数卡片(接 `latest`);时间范围选择 + 折线图(电压/电流/功率/电能),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。 +- **Steps**: 最新读数卡片(接 `latest`,字段标签/单位取自 `/metrics`);时间范围选择 + 折线图(电压/电流/功率/电能,从 `payload` 按 key 取序列),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。 - **Out of scope / 不要碰**: 不做服务端降采样(后续);不改后端。 - **Acceptance criteria**: - - [ ] 最新读数与走势图渲染正确;时间范围只取窗口数据(不拉全量)。 + - [ ] 最新读数与走势图渲染正确;时间范围只取窗口数据(不拉全量);标签/单位来自 metrics。 - [ ] Recharts 封装自包含、仅此处 import。 - [ ] 前端闸门全绿(`build` 通过,注意 chunk 体积提示)。 -- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理。 +- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。 ### 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` 补齐对应行。 +- **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。 @@ -316,20 +400,20 @@ Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口) ### 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/_08_exposed_entities.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`;`create tests/test_expose_catalog.py` +- **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/_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:每个电表 → 一个 device(identifier=slug),各工程量 → sensor entity(带 device_class/unit),加一个 `binary_sensor` online。 + 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。 + - [ ] 单测:建若干设备后 `build_catalog` 产出每设备对应 entity(含 online binary_sensor)+ 正确 device 分组、device_class、unit(与 profile 一致)。 - [ ] toggle 表 migration 可升/降;缺省 enabled=false。 - [ ] 至少含一个非 sensor component(online binary_sensor)。 - [ ] 校验闸门全绿。 -- **Reviewer checklist**: `key` 稳定(电表用 slug,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算而非写死。 +- **Reviewer checklist**: `key` 稳定(用设备 `uuid` + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。 ### M5-T10 — MQTT 客户端(paho,lifespan 连接 + 重连) - **Status**: `todo` · **Depends**: M5-T08 @@ -339,30 +423,30 @@ Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口) 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`:用提交/现存配置试连,返回三态(success/config-error/failed),仿 SMTP 测试。 + 4. `POST /api/config/mqtt/test`:用提交/现存配置试连**并发布一条测试消息**到一个测试 topic(如 `/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` 三态有明确返回;session+CSRF 保护。 + - [ ] `POST /api/config/mqtt/test` 试连**并发布一条测试消息**(可在 MQTT Explorer 看到);三态有明确返回;session+CSRF 保护。 - [ ] 校验闸门全绿。 - **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。 ### M5-T11 — Discovery 发布 + state 发布 - **Status**: `todo` · **Depends**: M5-T09, M5-T10 -- **Context**: 把 enabled 实体发成 HA discovery config(retained)并周期推 state;energy 轮询后推最新值。 -- **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` +- **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(`////config`,含 `device` 块、`state_topic`、`device_class`、`unit_of_measurement`、`availability`)。 + 1. `build_discovery_payload(entity)` → HA 规范 config(`////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;energy 在 `poll_meter` 成功后顺带推该表实体 state + online。 + 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 正确);取消勾选发空 payload。 - - [ ] 单测:energy 轮询成功后推对应 state topic;失败推 online=false。 + - [ ] 单测:discovery payload 符合 HA 结构(device 分组正确、topic/device_class/unit 正确、`unique_id` 取自 uuid);取消勾选发空 payload。 + - [ ] 单测:采集轮询成功后推对应 state topic;失败推 online=false。 - [ ] discovery 用 retained。 - [ ] 校验闸门全绿。 -- **Reviewer checklist**: 仅发 enabled 实体;entity 唯一标识稳定;MQTT 未启用时整链 no-op;不阻塞轮询。 +- **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。 ### M5-T12 — 前端:Expose 勾选 UI + `/api/expose` - **Status**: `todo` · **Depends**: M5-T09, M5-T11 @@ -378,11 +462,11 @@ Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口) ### 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/` +- **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**: 无残留旧描述;OpenAPI 已入库。 +- **Reviewer checklist**: 无残留旧描述(含旧 `energy_meters`/`/api/energy` 字样);OpenAPI 已入库。 --- @@ -401,41 +485,49 @@ npm run build # 必须产出 dist;留意 chunk 体积告警 ## 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` 仍应通过。 +- 本里程碑**不删/移文件**,但新增了 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. 后续杠杆(本里程碑不做,文档留痕) -- **两级采样周期**:瞬时量(V/I/P/PF/Hz)快、累计电能慢——9600 总线吃紧或要把功率压到 5s 以下时再开(meters 表加 `energy_interval_s`,读数表已含全部列,非破坏性变更)。 -- **保留 / 降采样**:5s 采样长期行数大(≈630 万行/年/表),加定期降采样或保留窗口任务(独立于本里程碑)。 -- **更多 device profile**:三相电表(如 SDM630)按 §3.2 填 l1/l2/l3 + total,新增 profile 即可。 +- **热点量的 generated column + 索引**:JSON `payload` 默认无法对单个量建索引;某个量若需高频聚合,给 `modbus_reading` 加一列 `GENERATED ALWAYS AS (json_extract(payload,'$.'))` 并建索引——非破坏性、要哪个补哪个。 +- **两级采样周期**:瞬时量(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 框架。 -- **写电表配置**:当前只读;如需经 MQTT/UI 控制设备(switch 类),再单独评估安全边界。 +- **写 Modbus 寄存器**:当前只读;如需经 MQTT/UI 控制设备(switch 类),再单独评估安全边界。 ## 11. 人工验收 walkthrough(实现完成后) -> 重点:用一条命令行命令读到电表数据并展示结果。可用 `docker compose` 起环境,命令在容器内或容器外跑均可。 +> 重点:用命令行**手工**读到设备数据并展示结果。这些是**受控手工测试**——设备接市电、并非随时在线,故**不纳入自动化测试**(自动化只用 mock);CLI 工具(`modbus_cli read/probe`)就是为这种"我想测的时候手动测一次"而设。可用 `docker compose` 起环境,命令在容器内或容器外跑均可。 **前提**:网关(Waveshare RTU↔TCP)已上电接入网络,电表 Meter ID 已知(默认 1)。 **1) 命令行直接试读(不依赖 DB,最快验证)** -- 容器内:`docker compose exec 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` +- 容器内:`docker compose exec 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/energy/meters` 建一个电表; -- `POST /api/energy/meters/{id}/test` 即时试读,返回解码值(不落库)。 +**2)(可选)经 API 试读已配置的设备** +- 先在前端 Energy 页或 `POST /api/modbus/devices` 建一个设备; +- `POST /api/modbus/devices/{uuid}/test` 即时试读,返回解码 payload(不落库)。 **3)(可选)验证后台轮询落库** -- 确认 `ENERGY_POLLING_ENABLED=true` 且电表 `enabled`;等一个采样周期; -- 看前端 Energy 视图的最新读数/走势图,或查 `GET /api/energy/meters/{id}/readings` 有新行。 +- 确认 `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-meter 周期静默轮询 Modbus-TCP 电表、解码落 `energy_readings`,支持多电表 CRUD。 -- MQTT 启用时,勾选的实体以 HA Discovery 注册成 device/entities(含非 sensor),state 周期发布;配置变更可重连重发。 -- 前端侧边栏可切换功能;Energy 视图能管理电表、看最新读数与走势图;设置页可勾选 expose。 +- 后端能按 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 现实。