Files
home-automation/docs/design/m5-iot-energy.md
T

53 KiB
Raw Blame History

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

阅读前提:先读 README.md(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 M1 单库 + M2 React SPA 之上。 配套参考:电表协议见 ../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 前端)→ CMQTT/Discovery,消费 B 的数据)。

2. 现状(实现者可据此工作,不必通读全仓库)

单库数据层M1 完成态)

  • app/db.pyclass Base(DeclarativeBase),绑 settings.app_database_url 的 cached engineWAL 已开),get_engine / get_session_local / reset_db_caches / get_db_session
  • 模型都继承同一 Baseapp/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>.pyrevision / down_revision 串链。
  • scripts/app_db_adopt.py 常量 APP_BASELINE_REVISION 指向当前 headscripts/run_migrations.py 负责把 app 库升到 head。

配置系统(扁平 KV,自动渲染)

  • app/config.pyclass Settings(BaseSettings),每个配置项一个带类型的字段 + 默认值。
  • app/services/config_page.pyCONFIG_FIELDS: tuple[ConfigField, ...]注册表section / env_name / setting_attr / label / secret / input_type);build_config_sections(读,secret 回空串)、save_config_updates(写,空 secret 保留旧值)、build_runtime_settingsDB override 合并进 Settings)、_settings_payload(把 Settings 摊平成 dict,新字段要在此补一行)。
  • app/api/routes/api/config.pyGET/PUT /api/configsession + 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 lifespanBackgroundScheduler(timezone="UTC")scheduler.add_job(_run_scheduled_public_ip_check, IntervalTrigger(hours=4), id=..., max_instances=1, coalesce=True)scheduler.start()yieldscheduler.shutdown(wait=False)
  • 周期任务惯例:一个同步 wrapper 自己开/关 sessionsession = get_session_local()(); try: service(session, ...) finally: session.close()service 内部不抛崩溃。

Home Assistant 现状(REST,不碰 MQTT

  • app/integrations/homeassistant.pyHomeAssistantClient.publish_sensor()POST /api/states/{entity})、trigger_webhook()
  • 入站 webhook app/api/routes/homeassistant.pyPOST /homeassistant/publishenvelope target/action/content)。
  • 新 MQTT Discovery 与此并行、不冲突,是第二条独立通道。

前端(M2

  • React + react-router v6 + Mantine + TanStack Query + openapi-fetch 生成的类型化 clientfrontend/src/api/client.ts + schema.d.ts)。
  • frontend/src/App.tsxAppLayout(当前是顶栏),包住所有受保护页;路由 /(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 以太网;服务器无串口)。用 pymodbusModbusTcpClient,由它处理封帧 / 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.pyYAML 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.payloadJSON);profile 没列出的量根本不读。
  • 只读:不写电表配置寄存器(改 Meter ID/波特率有通信中断风险)。
  • 轮询:每个设备一个 poll_interval_s(默认 5s),APScheduler 一个 job 扫所有 enabled 设备,逐设备块读→解码→落库。9600 总线上多设备串行,5s 余量充足。
    • 全局开关 MODBUS_POLLING_ENABLEDCONFIG_FIELDS)可一键停采。
    • 采集成功/失败写入设备的 last_poll_at / last_poll_ok(供 §3.3 的 online binary_sensor)。
    • 两级周期 / 降采样 / 保留是后续杠杆(见 §10),本里程碑用单周期读全。

sdm120.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.yamlmetrics[].key 决定。
  • 接入新设备型号无需改表:换 profilepayload 里的 key 集合随之变;表结构不动。
  • 三相电表以后用三相 profilepayload 里多几个相位 key(如 voltage_l1/l2/l3),仍是同一张表。

3.3 MQTT + HA Discovery(通用 expose 框架,元数据由 profile 派生)

HA MQTT Discovery 模型 = device → entities:往 <prefix>/<component>/<node>/<object>/config 发 retained 消息定义一个 entityconfig 内 device.identifiers 相同的 entity 归到同一个 HA device 卡片下;之后往 state_topic 推值。

  • 可暴露实体目录由 provider 动态产出
    • app/integrations/expose.py:定义 ExposableEntitykey(稳定)、componentsensor/binary_sensor/switch…)、device(归属,决定 HA device 分组)、device_classunit、取值来源)+ 一个 provider 注册表。
    • Modbus/energy provider:每个 enabled 设备 = 一个 HA deviceidentifiers 用设备 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.pypaho-mqttloop_start() 后台线程;在 lifespan 起/停;支持配置变更后重连 + 重发 discovery
  • 发布时机
    • discovery configretained):连接成功时、目录/勾选变更时全量发;取消勾选时发空 payload 清除该 entity。
    • state:采集在每次轮询后推最新值;另有一个周期 job 兜底重发所有 enabled 实体的 state + availability(在线)topic。
  • 配置(走现有扁平 CONFIG_FIELDS):MQTT_ENABLEDMQTT_BROKER_HOST/PORT/USERNAME/PASSWORD(secret)MQTT_TLS_ENABLEDHA_DISCOVERY_ENABLEDHA_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/limitlimit 有上限),返回 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_extractDB 端做 AVG/MAX/GROUP BY),热点量后补 generated column + 表达式索引。两级周期/降采样为后续杠杆。
  4. 协议知识 = 仓库内只读 YAML profile(声明式"数据 + 函数",无 OOP 继承,pydantic 启动期校验);YAML 携带寄存器 + 每个量的 key/unit/device_class/ha_component,是唯一真相源,同时驱动"读 / 存 / HA 注册"。多设备可共享一个 profile。首个 profile sdm120
  5. Modbus 仅 TCPpymodbusframer 配网关模式,只读采集。设备是网关后面的 slaveunit_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 提供 readprofile 解码)与 probe(手工指定请求内容、看原始回复)两个子命令,一律只读(仅 FC03/04),不暴露写寄存器。
  15. MQTT/HA 发布链路的手工验证走 UI + 外部工具,不另做 CLIConfig 页「发送测试消息」(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 BModbus 采集 + 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 APIdevice CRUD + readings + metrics + test
            └─► M5-T06 前端:设备管理 UI(依赖 T01 侧栏 + T05 API
            └─► M5-T07 前端:读数展示 + Recharts 走势图(依赖 T05;引入 recharts

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

收尾
  M5-T13 文档 + OpenAPI + roadmap 收尾(依赖全部)

T01T01BT02T08 无前置可先开。


7. 原子任务(任务卡)

后端任务沿用校验闸门(pytest / ruff / 改路由或 schema 则 export_openapi 重导出入库)。前端任务闸门见 §8。新增依赖(pymodbusPyYAMLpaho-mqttrecharts)须在对应任务里同步 requirements.in/.txtfrontend/package.json 并重新锁定。

M5-T01 — 侧边栏布局重构 [structural]

  • Status: done · Depends: none
  • Context: 把 AppLayout 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
  • Files: modify frontend/src/App.tsxcreate frontend/src/components/AppSidebar.tsxfrontend/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.tsxmodify 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 级 sidebaraccordion 是内容、非 chrome)。

M5-T02 — modbus_device + modbus_reading 表与模型 [schema]

  • Status: done · Depends: none
  • Context: 单库 app 链新增两张通用表,建出 §3.2 结构(设备 = 部署层,读数 = JSON payload 通用遥测层)。本任务只建 schema + 模型,不写采集/接口。
  • Files: create app/models/modbus.pyModbusDeviceModbusReading,继承 app.db.Base);create alembic_app/versions/<date>_07_modbus_tables.pymodify alembic_app/env.pyimport 新模型);modify scripts/app_db_adopt.pyAPP_BASELINE_REVISION → 新 head);create tests/test_modbus_models.py
  • Steps:
    1. 模型按 §3.2 列定义(Mapped[...] 2.0 风格):ModbusDeviceuuid(unique)、friendly_nametransporthostportunit_idprofilepoll_interval_senabledlast_poll_at/last_poll_ok(nullable)、时间戳;uuiddefault 生成 uuid4 字符串。ModbusReadingdevice_id FK→modbus_device.idondelete="RESTRICT")、recorded_atpayloadJSON 列,非 null)。
    2. 新 revisiondown_revision = 当前 headupgrade()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_devicemodbus_reading 及索引;downgrade -1 干净回滚。
    • Base.metadata.tables 含两新表;FK 为 RESTRICTuuid 唯一且自动生成。
    • APP_BASELINE_REVISION == 新 head。
    • 校验闸门全绿。
  • Reviewer checklist: 列/约束与 §3.2 一致;payload 为 JSON 列;recorded_at 为真实索引列(非塞进 payload);链上单 headenv.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__.pyapp/integrations/modbus/driver.pyapp/integrations/modbus/profiles.pyapp/integrations/modbus/profiles/sdm120.yamlscripts/modbus_cli.pymodify requirements.in/requirements.txt(加 pymodbusPyYAML,重新锁定);create tests/test_modbus_driver.pytests/test_modbus_profiles.pytests/test_modbus_cli.py
  • Steps:
    1. driver.pyread_blocks(host, port, unit_id, blocks) -> dict[int,int](按 profile 的 blocks 块读 input registersFC04),用 ModbusTcpClient;超时/连接失败抛明确异常;大端 float32 解码 helperregisters_to_float,高寄存器在前)。framer 选择留可配置/可探测。
    2. profiles.py:定义 pydantic ModbusProfile / MetricSpeckey/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 已同步锁定 pymodbusPyYAML

M5-T04 — 采集 service + APScheduler 轮询

  • Status: done · Depends: M5-T03
  • Context: 周期扫所有 enabled 设备,调用 driver 读+解码,落 modbus_reading.payload。仿 public-ip 的同步 job 模式。
  • Files: create app/services/modbus_poll.pymodify app/main.pylifespan 注册 job);create tests/test_modbus_poll.py
  • Steps:
    1. modbus_poll.pypoll_device(session, device) -> ModbusReading | Noneload_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 / 不要碰: 不发 MQTTT11);不加 HTTP 路由(T05);不引入两级周期(后续杠杆)。
  • Acceptance criteria:
    • 单测:mock driver 返回已知 dictpoll_all_enabled_devicesmodbus_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 APIdevice CRUD + readings + metrics + test

  • Status: done · Depends: M5-T02
  • Context: 给前端提供设备 CRUD、最新读数、时间范围读数、量目录、即时试读。
  • Files: create app/api/routes/api/modbus.pyapp/schemas/modbus.pymodify app/main.py(注册路由);create tests/test_api_modbus.py
  • Steps:
    1. devicesGET(list)/POST/GET{uuid}/PATCH{uuid}/DELETE{uuid}session+CSRFPOST 校验 profile 名存在;DELETE 有读数 → 409。
    2. readingsGET {uuid}/latest(最新一行 payload)、GET {uuid}/readingsstart/end/limitlimit 有上限防全表导出,按 recorded_at 升序,返回 recorded_at + payload)。
    3. GET {uuid}/metrics:返回该设备 profile 的量目录(key/label/unit/device_class),供前端渲染。
    4. GET /api/modbus/profileslist_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 返回最新一条 payloadmetrics 返回 profile 量目录。
    • schema 经 OpenAPI 固化入库。
    • 校验闸门全绿(含 openapi/ 重导出)。
  • Reviewer checklist: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 (device_id, recorded_at) 索引;test 端点确不落库;路径键用 uuid

M5-T06 — 前端:设备管理 UIEnergy 视图)

  • Status: todo · Depends: M5-T01, M5-T05
  • Context: 在侧栏加 Energy 入口与 /energy 路由;设备增删改 + 试读。
  • Files: create frontend/src/pages/EnergyPage.tsxfrontend/src/energy/DeviceForm.tsxfrontend/src/energy/hooks.tsmodify frontend/src/App.tsx(路由)、frontend/src/components/AppSidebar.tsxEnergy 项);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: todo · Depends: M5-T05(数据), M5-T06(页面壳)
  • Context: 在 Energy 页展示每设备最新读数 + 时间序列走势。
  • Files: modify frontend/src/pages/EnergyPage.tsxcreate frontend/src/energy/EnergyCharts.tsx(封装 Recharts);modify frontend/package.json(加 rechartspackage-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: todo · Depends: none
  • Context: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。
  • Files: modify app/config.py(新增 Settings 字段)、app/services/config_page.py(追加 CONFIG_FIELDS + _settings_payload);modify .env.examplemodify tests/test_api_config.py
  • Steps: 加字段 mqtt_enabledmqtt_broker_host/port/username/password(secret)、mqtt_tls_enabledha_discovery_enabledha_discovery_prefix(默认 homeassistant)、modbus_polling_enabledCONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Modbus」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: 建"可暴露实体目录"的抽象与开关存储;modbus provider 把设备映射成 device/entities实体元数据从 profile 派生
  • Files: create app/integrations/expose.pyExposableEntity、provider 协议、注册表、modbus provider);create app/models/expose.pyExposedEntityTogglekey unique + enabled + updated_at);create alembic_app/versions/<date>_08_exposed_entities.pymodify alembic_app/env.pyscripts/app_db_adopt.pycreate tests/test_expose_catalog.py
  • Steps:
    1. ExposableEntitykey/component/device/device_class/unit/value_getter+ provider 接口 enumerate(session) -> list[ExposableEntity]
    2. modbus provider:每个 enabled 设备 → 一个 deviceidentifiers 用设备 uuid),其各量 → sensor entitydevice_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 / 不要碰: 不发 MQTTT11);不加 HTTPT12)。
  • Acceptance criteria:
    • 单测:建若干设备后 build_catalog 产出每设备对应 entity(含 online binary_sensor+ 正确 device 分组、device_class、unit(与 profile 一致)。
    • toggle 表 migration 可升/降;缺省 enabled=false。
    • 至少含一个非 sensor componentonline binary_sensor)。
    • 校验闸门全绿。
  • Reviewer checklist: key 稳定(用设备 uuid + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。

M5-T10 — MQTT 客户端(paholifespan 连接 + 重连)

  • Status: todo · Depends: M5-T08
  • Context: 长连接 MQTT 客户端,配置变更可重连;含连接测试端点。
  • Files: create app/integrations/mqtt.pymodify app/main.pylifespan 起/停)、app/api/routes/api/config.pyPOST /api/config/mqtt/test);modify requirements.in/requirements.txt(加 paho-mqtt 重新锁定);create tests/test_mqtt_client.py
  • Steps:
    1. MqttManageris_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:用提交/现存配置试连并发布一条测试消息到一个测试 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-oppublish 透传 topic/payload/retain。
    • 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 configretained)并周期推 state;采集轮询后推最新值。
  • Files: create app/services/ha_discovery.pymodify app/services/modbus_poll.py(轮询后推 state)、app/main.pystate 周期 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_topicdevice_classunit_of_measurementavailabilityunique_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: todo · Depends: M5-T09, M5-T11
  • Context: 后端 expose 读写端点 + 设置页勾选界面。
  • Files: create app/api/routes/api/expose.pyapp/schemas/expose.pymodify app/main.pycreate tests/test_api_expose.py;前端 create frontend/src/pages/.../ExposeSettings.tsx(或并入 ConfigPage)、modify 路由/设置入口;create 前端测试
  • Steps: GET /api/expose(目录 + 勾选 + MQTT/Discovery 状态)、PUT /api/exposekey→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.mdModbus/Energy/MQTT 段、新依赖)、docs/roadmap.mdM5 行 + 把"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/ 下:

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 依赖(pymodbusPyYAMLpaho-mqtt)与前端依赖(recharts):必须同步 requirements.in/.txt 的重新锁定与 package-lock.json,否则镜像构建会缺包。
  • 新增源文件都在 app/scripts/frontend/ 既有 COPY 范围内,无需改 DockerfileCOPYapp/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 profilepayload 里多几个相位 key),不改表、不改采集主链。
  • 更多 expose providerpublic-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 现实。