diff --git a/docs/design/README.md b/docs/design/README.md index c4a9add..d55d50a 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -5,6 +5,8 @@ - [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基 - [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2 - [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期) +- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做** +- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏) 本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。 diff --git a/docs/design/m4-login-hardening.md b/docs/design/m4-login-hardening.md new file mode 100644 index 0000000..78c74a3 --- /dev/null +++ b/docs/design/m4-login-hardening.md @@ -0,0 +1,309 @@ +# M4 — 登录加固(Login Hardening) + +> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。 +> **排期:本里程碑排在 [M5](./m5-iot-energy.md) 之前。** 系统现为公网 + 仅密码 + 无限重试,是现成风险;M4 与 M5 零文件重叠,先堵这个洞再做 IoT。 + +## 1. 目标 + +给暴露在公网的单 admin 登录做纵深防御: + +1. **防爆破 / 指数退避**:失败登录按指数增长延迟,拖垮暴力枚举;成功即清零。 +2. **CLI 逃生通道**:纯命令行重置密码 / 解锁 / 关停 TOTP,**不依赖任何已存恢复凭据**——拿到服务器 CLI 权限的人本来就无所不能,所以这是可接受、且必须存在的最终逃生口(消除"锁死就彻底进不去"的死角)。 +3. **TOTP 二次验证(可选配)**:admin 可**自选启用** RFC 6238 TOTP;启用后登录需密码 + 6 位动态码;提供一次性恢复码;CLI 能在恢复码全丢时关掉 TOTP。**非强制**,不启用则维持纯密码登录。 + +> 三段顺序:A(防爆破,紧急、可独立先发版)→ B(TOTP,可选配)→ 收尾。Phase A 自成可用增量,Phase B 失败不影响 A。 + +## 2. 现状(实现者可据此工作,不必通读全仓库) + +**单 admin + Argon2** +- `app/models/auth.py`:`AuthUser`(`id / username unique / password_hash / is_active / force_password_change / created_at`)、`AuthSession`(`token_hash / csrf_token / expires_at / revoked_at`)。单库 `Base`。 +- `app/services/auth.py`: + - `authenticate_user(session, *, username, password) -> AuthUser | None`:查用户→`verify_password`(Argon2);失败只 `logger.info`,**无任何节流**。 + - `create_session(session, *, user, settings) -> (AuthSession, raw_token)`:`secrets.token_urlsafe`,SHA256 存 `token_hash`,TTL `auth_session_ttl_hours`(默认 12h)。 + - `change_password(...)`、`get_authenticated_session(...)`、`initialize_auth_schema(session, settings)`(**仅当无任何用户时**用 `AUTH_BOOTSTRAP_USERNAME/PASSWORD` 建初始 admin)。 +- `app/api/routes/api/session.py`: + - `POST /api/auth/login`(body `{username, password}`):`authenticate_user` → None 则 401;否则 `create_session` + `set_cookie`(HttpOnly/SameSite=Lax/`auth_cookie_secure`) → 返回 `{user, csrf_token}`。 + - `GET /api/session`(401 或 user+csrf)、`POST /api/auth/logout`(CSRF)、`POST /api/auth/password`(CSRF + 校验当前密码)。 + +**配置** +- `app/config.py`:`auth_bootstrap_username/password`、`auth_session_cookie_name`、`auth_session_ttl_hours`、`auth_cookie_secure_override`(computed `auth_cookie_secure`)。 +- 配置系统:扁平 KV(`CONFIG_FIELDS` 注册表 + `Settings` + `_settings_payload`),新增标量配置项前端自动渲染(见 M5 文档 §2)。 + +**无现成 CLI** +- `scripts/`:`run_migrations.py` / `app_db_adopt.py` / `migrate_legacy_data.py`(argparse) / `export_openapi.py`。**没有**任何 admin/密码/auth 的 CLI。 +- `pyproject.toml`:**无 `console_scripts`**。CLI 入口沿用 `python -m scripts.` 风格(与 `python -m scripts.run_migrations` 一致)。 + +**前端登录(单步)** +- `frontend/src/pages/LoginPage.tsx`:`POST /api/auth/login {username,password}` → 401 显示通用错误;成功存 csrf、跳转。 +- `frontend/src/auth/SessionProvider.tsx`:`GET /api/session` 引导。 +- 无"锁定/稍后再试"提示、无二步流程。 + +**测试** +- `tests/test_api_session.py`(凭据/cookie/登出/改密/强制改密);`tests/conftest.py` 用 env 设 bootstrap 凭据。 + +## 3. 目标架构 + +### 3.1 防爆破 / 指数退避 + +- **状态表 `auth_login_throttle`**:按 key 记失败窗口(`key / scope / failures / first_failed_at / last_failed_at / next_allowed_at`),成功即删该 key 的行。 +- **双键**:同时按 **client IP** 与 **username** 计;本次请求需等待 = 两者退避的**较大值**。 + - 按 IP:堵单点暴力(attacker IP 越敲越慢,合法用户换 IP 不受影响)。 + - 按 username:单 admin 的全局兜底(即便分布式换 IP 也有一层)。 + - **DoS 权衡**:单 admin 下纯按 username 硬锁会被攻击者故意失败把你锁死,所以退避是**延迟(429 + Retry-After)而非永久锁定**,且 CLI 是最终逃生口。 +- **client IP 来源**:默认用 socket IP;`AUTH_TRUST_FORWARDED_FOR=true` 时取 `X-Forwarded-For` 最左(部署在反代后才开)。**这点必须显式配置**,否则反代后所有请求同一 IP,按 IP 退避失效。 +- **指数公式(默认,常量可后调)**:前 `N_free=3` 次不延迟;之后 `wait = min(cap, base * 2^(failures - N_free))`,`base=1s`、`cap=900s`。 +- **接入点**:`POST /api/auth/login` 开头先查退避 → 在窗口内直接 429(带 `Retry-After`),**不验密码**(省 Argon2、防枚举);验密码失败 → 记一次失败;成功 → 清该 IP + username 的退避行。 +- **全局开关** `AUTH_LOGIN_THROTTLE_ENABLED`(CONFIG_FIELDS,默认 true)。 + +### 3.2 CLI 逃生通道 + +`scripts/admin_cli.py`(`python -m scripts.admin_cli `,argparse 子命令;直接连本地 DB,无网络,复用 `get_session_local()` + 模型 + Argon2 hasher): + +| 命令 | 作用 | +| --- | --- | +| `reset-password [--password ...]` | 重置密码(不给则交互式 prompt,不回显);逃生:忘密码 | +| `unlock [--all | --ip | --username ]` | 清 `auth_login_throttle` 行;逃生:被退避锁住 | +| `disable-totp ` | 关 TOTP(清 secret + 恢复码);逃生:**恢复码全丢也能进**(Phase B 才有意义)| +| `reissue-totp ` | 重新发放 TOTP secret(可选)| +| `list-admin` | 列用户/状态(排障)| + +- **数据安全**:CLI 只动 auth 状态行(人工执行的 admin 动作),**绝不**触碰用户数据表(location/poo/energy 等),不 drop 有数据的表。 + +### 3.3 TOTP 二次验证(可选配) + +- **库**:`pyotp`(Python 侧);二维码在**前端**用 JS 库(`qrcode.react`)从 `otpauth://` URI 渲染——后端不引图像依赖。 +- **存储**: + - `AuthUser` 增列 `totp_secret`(nullable)、`totp_enabled`(bool, default false)。secret 落库明文(与现有 secret 处理一致,靠文件权限保护;文档注明)。 + - `auth_recovery_code` 表(`user_id / code_hash / used_at`):恢复码**哈希**存(复用 Argon2 hasher),一次性。 +- **启用流程(opt-in)**: + 1. `POST /api/auth/totp/setup`(已登录)→ 生成 pending secret + `otpauth://` URI + 一组恢复码(**仅此一次明文返回**),尚未启用。 + 2. `POST /api/auth/totp/enable`(带一个当前 6 位码确认)→ `totp_enabled=true`,持久化恢复码哈希。 + 3. `POST /api/auth/totp/disable`(带密码或当前码)→ 关闭、清 secret/恢复码。 +- **登录二因子(单端点、无独立 challenge token,保持无状态)**: + - `POST /api/auth/login` body 增可选 `totp_code`。 + - 密码通过且 `totp_enabled`:无 `totp_code` → 返回 401 + `{totp_required: true}`(**不发 session**);有 `totp_code` → 校验 TOTP **或**恢复码(命中恢复码则消费它)→ 通过才发 session。 + - 未启用 TOTP:维持现状单步。 + - 退避(§3.1)对两步都生效。 +- **issuer 标签**:`AUTH_TOTP_ISSUER`(默认 `app_name`),显示在 Authenticator app 里。 + +### 3.4 前端 + +- **登录页两步**:`LoginPage` 在收到 `401 + totp_required` 时切到第二屏(6 位码输入,也接受恢复码),再次 `POST /api/auth/login` 带 `totp_code`。被退避(429)显示"稍后再试"(用 `Retry-After`)。 +- **设置页 TOTP 区**:未启用→「启用」走 setup(展示二维码 + 恢复码一次性、要求输入码确认);已启用→「停用」。恢复码只在生成时展示一次,提示妥善保存。 + +## 4. API 契约(M4 要落地的端点) + +> 全部 `/api`、写端点 session + CSRF 保护、JSON 进出;schema 经 `export_openapi.py` 固化。 + +| 分组 | 端点 | 用途 | +| --- | --- | --- | +| 登录 | `POST /api/auth/login` | 加退避(429+Retry-After);body 增可选 `totp_code`;启用 TOTP 且缺码→401 `{totp_required:true}` | +| TOTP | `POST /api/auth/totp/setup` | 生成 pending secret + otpauth URI + 恢复码(一次性)| +| TOTP | `POST /api/auth/totp/enable` | 带当前码确认启用 | +| TOTP | `POST /api/auth/totp/disable` | 关闭 TOTP | +| TOTP | `GET /api/auth/totp` | 返回当前 TOTP 状态(enabled 与否)| + +> CLI 不是 HTTP 端点。throttle 开关与 issuer 走现有 `GET/PUT /api/config`(仅加 CONFIG_FIELDS)。 + +## 5. 已锁定决策(讨论后拍板) + +1. **M4 独立里程碑、排在 M5 之前**。 +2. **范围**:防爆破 + 指数退避 + CLI 重置(密码/解锁/关 TOTP)+ TOTP(**可选配,非强制**)。 +3. **退避双键(IP + username)、延迟非永久锁定、成功清零、CLI 兜底**;反代后需开 `AUTH_TRUST_FORWARDED_FOR`。 +4. **CLI 逃生通道不依赖任何已存恢复凭据**;只动 auth 行,不碰用户数据。 +5. **TOTP 单端点二因子**(login 加可选 `totp_code`,无独立 challenge token);恢复码哈希存、一次性;secret 明文存(与现有一致)。 +6. **二维码前端渲染**(`qrcode.react`),后端只给 `otpauth://` URI + secret;Python 仅加 `pyotp`。 +7. **CLI 入口 = `python -m scripts.admin_cli`**(无 console_scripts)。 + +> 项目定位:个人自用、单 admin——可按单用户简化,不为多租户/找回邮件等过度设计。 + +## 6. 任务依赖图 + +``` +Phase A(防爆破,紧急,自成可发版增量) + M4-T01 [schema] auth_login_throttle 表 + 模型 + └─► M4-T02 退避 service + 接入 login(429/Retry-After/成功清零) + └─► M4-T03 CLI 骨架 + reset-password + unlock + +Phase B(TOTP,可选配) + M4-T04 [schema] AuthUser TOTP 字段 + auth_recovery_code 表 + ├─► M4-T05 TOTP service + setup/enable/disable/status API + ├─► M4-T06 登录二因子(login 加 totp_code;依赖 T02 已改过 login) + └─► M4-T07 CLI disable-totp / reissue-totp + M4-T08 前端:两步登录 + 设置页 TOTP(依赖 T05+T06;引入 qrcode.react) + +收尾 + M4-T09 文档 + OpenAPI + roadmap(依赖全部) +``` + +`T01` 可先开。Phase A(T01–T03)跑完即可发一版只含防爆破 + CLI 的安全增量。 + +--- + +## 7. 原子任务(任务卡) + +> 后端沿用校验闸门(`pytest`/`ruff`/改路由或 schema 则 `export_openapi` 重导出入库)。前端闸门见 §8。新增依赖(`pyotp`、`qrcode.react`)须同步 `requirements.in/.txt` 与 `frontend/package.json` 重新锁定。 + +### M4-T01 — `auth_login_throttle` 表 + 模型 `[schema]` +- **Status**: `todo` · **Depends**: none +- **Context**: 存按 IP / username 的失败退避状态。仅建 schema。 +- **Files**: `create app/models/auth_throttle.py`(或并入 `app/models/auth.py`);`create alembic_app/versions/_NN_auth_login_throttle.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`(baseline 常量);`create tests/test_auth_throttle_model.py` +- **Steps**: 模型 `LoginThrottle`(`id / key str / scope str('ip'|'user') / failures int / first_failed_at / last_failed_at / next_allowed_at`,`(scope,key)` unique 索引);新 revision 建表 + 索引;更新 baseline 常量。 +- **Out of scope / 不要碰**: 不写退避逻辑(T02);不动登录端点。 +- **Acceptance criteria**: + - [ ] 临时库 upgrade 到 head 含该表与唯一索引;`downgrade -1` 干净。 + - [ ] `APP_BASELINE_REVISION` 更新;`env.py` 已 import。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: `(scope,key)` 唯一;链上单 head;env.py 注册模型。 + +### M4-T02 — 退避 service + 接入登录 +- **Status**: `todo` · **Depends**: M4-T01 +- **Context**: 指数退避核心 + 接 `POST /api/auth/login`。 +- **Files**: `create app/services/login_throttle.py`;`modify app/api/routes/api/session.py`、`app/services/config_page.py`(+`CONFIG_FIELDS` `AUTH_LOGIN_THROTTLE_ENABLED`)、`app/config.py`(+`auth_login_throttle_enabled`、`auth_trust_forwarded_for`);`modify tests/test_api_session.py`、`create tests/test_login_throttle.py` +- **Steps**: + 1. `check_and_get_wait(session, *, ip, username) -> int`(秒;>0 表示仍在窗口);`register_failure(...)`(更新双键 failures/next_allowed_at,按 §3.1 公式);`clear(session, *, ip, username)`(成功清零)。 + 2. 登录端点:取 client IP(按 `auth_trust_forwarded_for` 决定是否信 XFF 最左);**先**查退避,>0 → 429 + `Retry-After`,不验密码;验密码失败 → `register_failure` 后 401;成功 → `clear` 再发 session。 + 3. 开关关闭时整段 no-op。 +- **Out of scope / 不要碰**: 不做 TOTP(Phase B);不改 logout/password 端点。 +- **Acceptance criteria**: + - [ ] 单测:连续失败后 `wait` 按指数增长且封顶;窗口内请求 429 带 `Retry-After` 且不验密码。 + - [ ] 单测:成功登录清零;下次无延迟。 + - [ ] 401 仍为通用文案(不泄露用户是否存在);现有登录测试仍绿。 + - [ ] 开关 false 时无节流。 + - [ ] 校验闸门全绿(OpenAPI 若变重导出)。 +- **Reviewer checklist**: 退避在验密码**之前**生效;双键较大值;XFF 仅在配置开启时信任;无把密码写进日志。 + +### M4-T03 — CLI 骨架 + reset-password + unlock +- **Status**: `todo` · **Depends**: M4-T01 +- **Context**: 逃生通道第一批命令。 +- **Files**: `create scripts/admin_cli.py`;`create tests/test_admin_cli.py` +- **Steps**: argparse 子命令;`reset-password [--password|prompt(getpass)]` 用 Argon2 hasher 重置 `password_hash`;`unlock [--all|--ip|--username]` 删 `auth_login_throttle` 行;`list-admin`;复用 `get_session_local()` + 模型;无网络。 +- **Out of scope / 不要碰**: 不动 HTTP 端点;不碰用户数据表;TOTP 命令在 T07。 +- **Acceptance criteria**: + - [ ] 单测:`reset-password` 后新密码可 `verify_password` 通过、旧密码失败。 + - [ ] 单测:`unlock` 清掉指定/全部退避行。 + - [ ] 不存在的 user 友好报错、非零退出。 + - [ ] `grep` 确认 CLI 不含对用户数据表的删除/drop。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 密码可交互输入且不回显;只动 auth 行;幂等可重跑。 + +### M4-T04 — AuthUser TOTP 字段 + `auth_recovery_code` 表 `[schema]` +- **Status**: `todo` · **Depends**: none(与 Phase A 并行的 schema,但 T06 依赖 T02) +- **Files**: `modify app/models/auth.py`(`AuthUser.totp_secret`/`totp_enabled`);`create` 模型 `RecoveryCode`;`create alembic_app/versions/_NN_totp.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`;`create tests/test_totp_models.py` +- **Steps**: `totp_secret` nullable、`totp_enabled` bool default false;`auth_recovery_code(user_id FK, code_hash, used_at nullable)`;revision 建列 + 表;更新 baseline。 +- **Out of scope / 不要碰**: 不写 TOTP 逻辑(T05)。 +- **Acceptance criteria**: + - [ ] upgrade/downgrade 干净;默认 `totp_enabled=false`、`totp_secret=null`。 + - [ ] baseline 常量更新;env.py import。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 现有用户迁移后默认未启用 TOTP(不破坏现有登录)。 + +### M4-T05 — TOTP service + setup/enable/disable/status API +- **Status**: `todo` · **Depends**: M4-T04 +- **Files**: `create app/services/totp.py`、`app/schemas/totp.py`;`modify app/api/routes/api/session.py`(或新 `auth_totp.py` 路由)、`app/config.py`(+`auth_totp_issuer`);`modify requirements.in/.txt`(+`pyotp`);`create tests/test_api_totp.py` +- **Steps**: pyotp 生成 secret + `otpauth://` URI(issuer=`auth_totp_issuer`);恢复码生成(N=10、`xxxx-xxxx`,Argon2 哈希存、明文仅 setup 返回一次);`setup`(pending,不启用)/`enable`(带当前码确认)/`disable`(带密码或当前码)/`GET status`;全部 session+CSRF。 +- **Out of scope / 不要碰**: 不改 login 校验(T06)。 +- **Acceptance criteria**: + - [ ] `setup` 返回 secret+URI+恢复码(一次性);未确认前 `totp_enabled` 仍 false。 + - [ ] `enable` 用错码失败、对码成功;恢复码以哈希持久化。 + - [ ] `disable` 关闭并清 secret/恢复码。 + - [ ] schema 经 OpenAPI 固化;secret/恢复码不回显于 `GET status`。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 恢复码只存哈希、明文仅一次;secret 不进 OpenAPI 示例/日志;`requirements.txt` 锁定 `pyotp`。 + +### M4-T06 — 登录二因子(login 加 `totp_code`) +- **Status**: `todo` · **Depends**: M4-T05, M4-T02 +- **Files**: `modify app/api/routes/api/session.py`、`app/schemas/session.py`(`LoginRequest.totp_code` 可选)、`app/services/totp.py`(校验 + 消费恢复码);`modify tests/test_api_session.py`、`tests/test_api_totp.py` +- **Steps**: 密码通过后:若 `totp_enabled` 且无 `totp_code` → 401 `{totp_required:true}`(不发 session、退避照算);有 `totp_code` → 校验 TOTP 或恢复码(命中恢复码置 `used_at`)→ 通过发 session;未启用 → 现状。 +- **Out of scope / 不要碰**: 不改 setup/enable(T05);不动退避公式(T02)。 +- **Acceptance criteria**: + - [ ] 未启用 TOTP 的用户登录与现状完全一致。 + - [ ] 启用后:缺码→401 totp_required;错码→401;对码→发 session;恢复码可用且一次性。 + - [ ] 退避对二步同样生效。 + - [ ] OpenAPI 重导出入库;校验闸门全绿。 +- **Reviewer checklist**: `totp_required` 时确未发 cookie;恢复码消费后不可复用;通用错误不泄露细节。 + +### M4-T07 — CLI disable-totp / reissue-totp +- **Status**: `todo` · **Depends**: M4-T04(逻辑上配合 T03 的 CLI 骨架) +- **Files**: `modify scripts/admin_cli.py`;`modify tests/test_admin_cli.py` +- **Steps**: `disable-totp `:`totp_enabled=false` + 清 secret + 删恢复码(**不需任何恢复码即可执行**);`reissue-totp `:生成新 secret(可选打印新 URI)。 +- **Out of scope / 不要碰**: 不碰用户数据表。 +- **Acceptance criteria**: + - [ ] 单测:启用 TOTP 的用户经 `disable-totp` 后可纯密码登录(结合 T06 行为)。 + - [ ] 不依赖任何恢复码即可关停。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 逃生不依赖已存凭据;只动 auth 行。 + +### M4-T08 — 前端:两步登录 + 设置页 TOTP +- **Status**: `todo` · **Depends**: M4-T05, M4-T06 +- **Files**: `modify frontend/src/pages/LoginPage.tsx`;`create frontend/src/pages/.../TotpSettings.tsx`(或并入 ConfigPage)、`frontend/src/auth/totp.ts`;`modify frontend/package.json`(+`qrcode.react`,lock 同步);`create` 对应测试 +- **Steps**: 登录页:401+`totp_required`→第二屏 6 位码(也接受恢复码);429→"稍后再试"(读 `Retry-After`)。设置页:未启用→启用流程(二维码由 `otpauth` URI 渲染 + 恢复码一次性展示 + 输码确认);已启用→停用。 +- **Out of scope / 不要碰**: 不改后端;不碰防爆破逻辑。 +- **Acceptance criteria**: + - [ ] 未启用 TOTP 的登录体验不变;启用后两步可走通;恢复码可登录。 + - [ ] 二维码可被 Authenticator 扫描;恢复码仅展示一次。 + - [ ] 429 有"稍后再试"提示。 + - [ ] 前端闸门全绿。 +- **Reviewer checklist**: 全走类型化 client;secret/恢复码不落 localStorage/日志;空/错/加载态有处理。 + +### M4-T09 — 文档 + OpenAPI + roadmap 收尾 +- **Status**: `todo` · **Depends**: 全部 +- **Files**: `modify README.md`、`docs/auth.md`(防爆破 + CLI + TOTP 说明、CLI 用法)、`docs/architecture-overview.md`;`modify docs/roadmap.md`(把"TOTP 二次验证"与新"登录防爆破"从「下一阶段」毕业、加 M4 行、注明 M4 先于 M5);`modify docs/design/README.md`(已含 m4);`run python scripts/export_openapi.py` 提交 `openapi/` +- **Acceptance criteria**: + - [ ] 文档含 CLI 逃生用法与 TOTP 启停说明;`git diff --exit-code openapi/` 无差异。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: roadmap 反映 M4 已落地且先于 M5;无残留旧描述。 + +--- + +## 8. 前端校验闸门(前端任务每次结束都要全绿) + +在 `frontend/` 下:`npm ci && npm run lint && npm run typecheck && npm run test && npm run build`。新增依赖 `qrcode.react` 须提交 `package.json` + `package-lock.json`。后端同任务改路由/schema 仍需根目录 `export_openapi.py` 并提交 `openapi/`。 + +## 9. 构建上下文 / 依赖完整性 + +- 新增 Python 依赖 `pyotp`、前端 `qrcode.react`:同步重新锁定 `requirements.txt` / `package-lock.json`,否则镜像缺包。 +- 新文件均在既有 `app/`、`scripts/`、`frontend/` 的 COPY 范围内;`scripts/admin_cli.py` 须在镜像里可 `python -m scripts.admin_cli` 调用(确认 `scripts/` 已进镜像)。 +- `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。 + +## 10. 人工验收 walkthrough(实现完成后) + +> 重点:连续输错 → 系统自动退避锁住 → CLI 解锁后恢复登录。可用 `docker compose` 起服务,CLI 在容器内或容器外跑均可。 + +**准备**:起服务(任选其一) +- Docker:`docker compose up -d`(应用容器名见 `docker-compose.yml`,下文记为 ``)。 +- 或本地:`source .venv/bin/activate && uvicorn app.main:app --port 8000`。 + +**1) 触发退避锁定** —— 用**错误密码**连续登录同一账号: +```bash +for i in $(seq 1 8); do \ + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' -d '{"username":"admin","password":"wrong"}'); \ + echo "尝试 $i -> HTTP $code"; done +``` +- 预期:前几次 `401`,超过免费次数后变 `429`(被锁/退避)。单独跑一次带 `-i` 可看到 `Retry-After` 头逐次增大(指数退避)。 +- 在退避窗口内,**即使输正确密码**也应被 `429` 挡住(验证锁定确实生效)。 + +**2) CLI 解锁** +- 容器内:`docker compose exec python -m scripts.admin_cli unlock --all` +- 或容器外:`python -m scripts.admin_cli unlock --all` +- 预期:打印清除的退避条目数。 + +**3) 验证恢复** —— 立即用**正确密码**登录: +```bash +curl -i -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' -d '{"username":"admin","password":"<正确密码>"}' +``` +- 预期:`200` 且 `Set-Cookie` 下发 session → 解锁成功。 + +**4)(可选)其它逃生命令** +- 重置密码:`python -m scripts.admin_cli reset-password admin`(交互输入新密码、不回显)。 +- 关停 TOTP(若已启用):`python -m scripts.admin_cli disable-totp admin`,随后纯密码即可登录。 + +## 11. 里程碑完成定义(DoD) + +- 登录失败按指数退避(429 + Retry-After),成功清零;可经 `AUTH_LOGIN_THROTTLE_ENABLED` 开关。 +- `python -m scripts.admin_cli` 能在**无任何 Web 访问、无恢复码**的情况下重置密码 / 解锁 / 关停 TOTP。 +- TOTP 可由 admin 自选启用:启用后两步登录、恢复码可用且一次性;不启用维持纯密码。 +- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 入库。 +- README/auth/architecture/roadmap 反映 M4 现实与"先于 M5"的排期。 diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md new file mode 100644 index 0000000..9efd09b --- /dev/null +++ b/docs/design/m5-iot-energy.md @@ -0,0 +1,441 @@ +# M5 — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏) + +> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 M1 单库 + M2 React SPA 之上。 +> 配套参考:电表协议见 [`../references/SDM120-Modbus-Protocol.md`](../references/SDM120-Modbus-Protocol.md)。 + +## 1. 目标 + +给后端接入家庭 IoT 生态,并新增一条能耗采集链路: + +1. **Modbus/Energy 采集**:通过 Modbus-TCP 网关周期读取电表(首个 profile 为 SDM120 单相),解码为工程量,存入单库的能耗表。后台静默轮询,支持多电表。 +2. **MQTT + Home Assistant Discovery**:后端作为 MQTT 发布方,按"**可勾选暴露**"的方式把数据以 HA Discovery 自动注册成 device/entity(不止 sensor)。 +3. **前端侧边栏**:把现有顶栏改成侧边导航,承载新的 Energy 视图(电表管理 + 最新读数 + 走势图)。 + +> 三段有依赖关系,按 §6 的 `Depends` 顺序推进:A(侧边栏,独立)→ B(Energy 后端 + 前端)→ 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_.py`,`revision` / `down_revision` 串链。 +- `scripts/app_db_adopt.py` 常量 `APP_BASELINE_REVISION` 指向当前 head;`scripts/run_migrations.py` 负责把 app 库升到 head。 + +**配置系统(扁平 KV,自动渲染)** +- `app/config.py`:`class Settings(BaseSettings)`,每个配置项一个带类型的字段 + 默认值。 +- `app/services/config_page.py`:`CONFIG_FIELDS: tuple[ConfigField, ...]` 是**注册表**(`section / env_name / setting_attr / label / secret / input_type`);`build_config_sections`(读,secret 回空串)、`save_config_updates`(写,空 secret 保留旧值)、`build_runtime_settings`(DB override 合并进 Settings)、`_settings_payload`(把 Settings 摊平成 dict,新字段要在此补一行)。 +- `app/api/routes/api/config.py`:`GET/PUT /api/config`,session + CSRF 保护;非法值 422 且不写库。 +- 前端 `frontend/src/pages/ConfigPage.tsx`:**通用渲染**——按 section 分组、按 `input_type`/`secret` 渲染输入框。**新增标量配置项零前端改动**(追加 `CONFIG_FIELDS` + `Settings` 字段 + `_settings_payload` 一行即可)。 +- ⚠️ 扁平 KV **装不下"电表列表"和"逐实体勾选"**——这两者走专用表 + 专用 API + 自定义 UI(见 §3.2 / §3.4)。 + +**后台调度(APScheduler)** +- `app/main.py` lifespan:`BackgroundScheduler(timezone="UTC")`,`scheduler.add_job(_run_scheduled_public_ip_check, IntervalTrigger(hours=4), id=..., max_instances=1, coalesce=True)`,`scheduler.start()`;`yield` 后 `scheduler.shutdown(wait=False)`。 +- 周期任务惯例:一个**同步** wrapper 自己开/关 session:`session = get_session_local()(); try: service(session, ...) finally: session.close()`,service 内部不抛崩溃。 + +**Home Assistant 现状(REST,不碰 MQTT)** +- `app/integrations/homeassistant.py`:`HomeAssistantClient.publish_sensor()`(`POST /api/states/{entity}`)、`trigger_webhook()`。 +- 入站 webhook `app/api/routes/homeassistant.py`:`POST /homeassistant/publish`(envelope `target/action/content`)。 +- 新 MQTT Discovery 与此**并行、不冲突**,是第二条独立通道。 + +**前端(M2)** +- React + react-router v6 + Mantine + TanStack Query + `openapi-fetch` 生成的类型化 client(`frontend/src/api/client.ts` + `schema.d.ts`)。 +- `frontend/src/App.tsx`:`AppLayout`(当前是**顶栏**),包住所有受保护页;路由 `/`(HomePage 地图)、`/config`、`/records`;`/login`、`/change-password` 不带 layout。 +- 数据请求惯例:`useQuery`/`useMutation` + `apiClient.GET/POST/...`(见 `frontend/src/records/hooks.ts`)。 +- **无图表库**(只有 Leaflet 地图);走势图需新引入 **Recharts**。 + +## 3. 目标架构 + +### 3.1 Modbus / Energy 采集 + +- **传输:仅 Modbus-TCP**(用户的 Waveshare RTU↔TCP 网关,RJ45 以太网;服务器无串口)。用 **pymodbus** 的 `ModbusTcpClient`,由它处理封帧 / CRC / 超时重试 / float 解码。 + - 网关若开"Modbus TCP"协议转换 → pymodbus 默认 framer 直连。 + - 网关若是透传(RTU-over-TCP,裸 RTU 帧含 CRC)→ pymodbus 用 RTU framer over TCP。 + - **二者实现时对一次即可确定**(连上读 Voltage 寄存器验证),不影响表结构与上层。 +- **协议知识在代码(profile/driver),部署信息在 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。 +- **只读**:不写电表配置寄存器(改 Meter ID/波特率有通信中断风险)。 +- **轮询**:每个电表一个 `poll_interval_s`(默认 5s),APScheduler 一个 job 扫所有 `enabled` 电表,逐表块读→解码→落库。9600 总线上多表串行,5s/几个表余量充足。 + - 全局开关 `ENERGY_POLLING_ENABLED`(CONFIG_FIELDS)可一键停采。 + - **两级周期 / 降采样 / 保留是后续杠杆**(见 §10),本里程碑用单周期读全。 + +### 3.2 数据模型(新增两张表,单库 app 链) + +**`energy_meters`**(电表定义,CRUD 管理) + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| id | int PK | | +| slug | str unique | 稳定标识(用于 MQTT entity key / HA device identifier)| +| name | str | 显示名 | +| transport | str | 现仅 `'tcp'` | +| host | str | 网关 IP | +| port | int | 网关端口(默认 502)| +| unit_id | int | Modbus 从机地址 = 电表 Meter ID(默认 1)| +| profile | str | 设备 profile(首个 `'sdm120'`)| +| poll_interval_s | int | 采样周期(默认 5)| +| enabled | bool | 是否轮询 | +| created_at / updated_at | datetime | | + +**`energy_readings`**(一张相位感知宽表,一行 = 一个电表的一次采样) + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| id | int PK | | +| meter_id | int FK→energy_meters.id | **ON DELETE RESTRICT**(见 §4 删除语义)| +| recorded_at | datetime (UTC) | 采样时刻,索引 | +| voltage_l1 / l2 / l3 | float null | 单相电表只填 l1 | +| current_l1 / l2 / l3 | float null | 单相电表只填 l1 | +| active_power_l1 / l2 / l3 | float null | 三相按相填;单相留空(用 total)| +| total_active_power | float null | W(单相=该电表有功功率)| +| apparent_power | float null | VA | +| reactive_power | float null | var | +| power_factor | float null | | +| frequency | float null | Hz | +| import_active_energy | float null | kWh(累计)| +| export_active_energy | float null | kWh(累计)| +| total_active_energy | float null | kWh(累计)| +| extra | JSON null | 冷门/型号特有量(demands、maxima、无功电能、line-to-line 等)| + +- 索引:`(meter_id, recorded_at)`。 +- **SDM120 单相映射**:`voltage_l1`←电压、`current_l1`←电流、`total_active_power`←有功功率、`apparent_power`/`reactive_power`/`power_factor`/`frequency`、`import/export/total_active_energy`←对应电能寄存器;l2/l3 与 `active_power_l1..l3` 留空。 +- 三相 profile 以后填 l1/l2/l3 + total,**无需改 schema**。 + +### 3.3 MQTT + HA Discovery(通用 expose 框架) + +HA MQTT Discovery 模型 = **device → entities**:往 `////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 + 一个示例。 +- **`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。 +- **配置**(走现有扁平 CONFIG_FIELDS):`MQTT_ENABLED`、`MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD(secret)`、`MQTT_TLS_ENABLED`、`HA_DISCOVERY_ENABLED`、`HA_DISCOVERY_PREFIX`(默认 `homeassistant`)。 + +### 3.4 前端 + +- **侧边栏**:把 `AppLayout` 从顶栏重构为侧边导航(Mantine `AppShell` 或 flex sidebar),导航项:Home / Records / Energy / Config + 主题切换 + 注销;当前路由高亮;移动端可折叠。仅改 `App.tsx`(+ 可抽 `AppSidebar`/`NavItem` 组件),各页面主体不动。 +- **Energy 视图**(新页 `/energy`): + - 电表管理:列表 + 新建/编辑/删除(删除有二次确认;后端对有读数的电表拒删,引导改用"禁用")。 + - 最新读数卡片(每电表当前各工程量)。 + - 走势图:用 **Recharts** 画时间序列(电压/电流/功率/电能),时间范围选择,取数走 readings API(窗口 + 上限)。 +- **Expose 设置**:设置页内一块「Home Assistant Expose」——列出可暴露实体目录、逐项勾选、显示 MQTT/Discovery 连接状态、一个"重新发布 discovery"按钮。 + +## 4. API 契约(M5 要落地的端点) + +> 全部 `/api` 前缀、session + CSRF(写)保护、JSON 进出。schema 经 `export_openapi.py` 固化入库。 + +| 分组 | 端点 | 用途 | +| --- | --- | --- | +| Energy | `GET /api/energy/meters` | 列出电表 | +| Energy | `POST /api/energy/meters` | 新建电表 | +| Energy | `GET /api/energy/meters/{id}` | 单个电表 | +| Energy | `PATCH /api/energy/meters/{id}` | 修改电表(含 enable/disable)| +| Energy | `DELETE /api/energy/meters/{id}` | 删除电表;**有读数时 409**,引导改 disable | +| Energy | `GET /api/energy/meters/{id}/latest` | 该电表最新一条读数 | +| Energy | `GET /api/energy/meters/{id}/readings` | 时间范围读数(`start/end/limit`,limit 有上限),供走势图 | +| Energy | `POST /api/energy/meters/{id}/test` | 即时试读一次(验证网关连通/地址),不落库 | +| Expose | `GET /api/expose` | 返回可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 | +| Expose | `PUT /api/expose` | 设置逐 key 勾选(map key→bool)| +| Expose | `POST /api/expose/republish` | 手动重发 discovery | +| 配置 | `POST /api/config/mqtt/test` | 测试 broker 连接(仿 SMTP 测试三态)| + +> MQTT broker / discovery 的**标量配置**复用现有 `GET/PUT /api/config`(只新增 CONFIG_FIELDS,不新增端点)。 + +## 5. 已锁定决策(讨论后拍板) + +1. **里程碑编排**:一个 M5 文档分三段,`Depends` 串顺序(A 侧边栏 → B Energy → C MQTT)。 +2. **电表定义 = 专用 `energy_meters` 表 + CRUD API/UI**(扁平 KV 装不下多电表)。 +3. **读数 = 一张相位感知宽表 `energy_readings`,单 per-meter 周期(默认 5s)每 tick 读全部**,`extra` JSON 兜底;为三相预留 l1/l2/l3 列。两级周期/降采样为后续杠杆。 +4. **Modbus 仅 TCP**,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`。 + +> 项目定位:个人自用、家庭特化、不开源——可按单用户场景简化,不过度抽象。 + +## 6. 任务依赖图 + +``` +Phase A(独立,可最先做) + M5-T01 [structural] 侧边栏布局重构 + +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) + └─► M5-T07 前端:读数展示 + Recharts 走势图(依赖 T05;引入 recharts) + +Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口) + M5-T08 MQTT/Discovery 配置项(CONFIG_FIELDS) + M5-T09 [schema] exposed_entities 表 + ExposableEntity/provider 框架(energy provider) + ├─► 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`、`T02`、`T08` 无前置可先开。 + +--- + +## 7. 原子任务(任务卡) + +> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。 + +### M5-T01 — 侧边栏布局重构 `[structural]` +- **Status**: `todo` · **Depends**: none +- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。 +- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言) +- **Steps**: + 1. 用 Mantine `AppShell`(或 flex sidebar)重构 `AppLayout`:左侧竖直导航(Home/Records/Config + 主题切换 + 注销),`` 在右。 + 2. 当前路由高亮(`useLocation` 比对 `pathname`);移动端可折叠(burger)。 + 3. 导航项图标沿用 `react-feather`;样式走 Mantine(暗色模式自动适配)。 + 4. 不在本任务加 Energy 项(页面还不存在,T06 加),保持导航无死链。 +- **Out of scope / 不要碰**: 不改各页面主体;不动鉴权(SessionProvider/ProtectedRoute);不引入图表库。 +- **Acceptance criteria**: + - [ ] 受保护页都在侧边栏布局内;`/login`、`/change-password` 不带布局(与现状一致)。 + - [ ] 当前路由在侧栏高亮;移动端宽度下可折叠/展开。 + - [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。 +- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。 + +### M5-T02 — `energy_meters` + `energy_readings` 表与模型 `[schema]` +- **Status**: `todo` · **Depends**: none +- **Context**: 单库 app 链新增两表,建出 §3.2 结构。本任务只建 schema + 模型,不写采集/接口。 +- **Files**: `create app/models/energy.py`(`EnergyMeter`、`EnergyReading`,继承 `app.db.Base`);`create alembic_app/versions/_07_energy_tables.py`;`modify alembic_app/env.py`(import 新模型);`modify scripts/app_db_adopt.py`(`APP_BASELINE_REVISION` → 新 head);`create tests/test_energy_models.py` +- **Steps**: + 1. 模型按 §3.2 列定义(`Mapped[...]` 2.0 风格,`extra` 用 JSON 列且 nullable);`EnergyReading.meter_id` FK→`energy_meters.id`,**`ondelete="RESTRICT"`**;`slug` unique。 + 2. 新 revision:`down_revision` = 当前 head;`upgrade()` 用 `op.create_table` 建两表 + 索引 `(meter_id, recorded_at)`;`downgrade()` 反向 drop。 + 3. 更新 `APP_BASELINE_REVISION`。 +- **Out of scope / 不要碰**: 不写 pymodbus/采集(T03/T04);不加路由(T05);不动其它模型。 +- **Acceptance criteria**: + - [ ] 全新临时 app 库 upgrade 到 head 后含 `energy_meters`、`energy_readings` 及索引;`downgrade -1` 干净回滚。 + - [ ] `Base.metadata.tables` 含两新表;FK 为 RESTRICT。 + - [ ] `APP_BASELINE_REVISION` == 新 head。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 列/约束与 §3.2 一致;`extra` 为 JSON nullable;链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。 + +### M5-T03 — Modbus 驱动 + `sdm120` profile +- **Status**: `todo` · **Depends**: M5-T02 +- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 SDM120 寄存器 profile。纯模块,mock client 单测。 +- **Files**: `create app/integrations/modbus.py`、`app/integrations/energy_profiles.py`、`scripts/energy_cli.py`;`modify requirements.in`/`requirements.txt`(加 `pymodbus`,重新锁定);`create tests/test_modbus_driver.py`、`tests/test_energy_profiles.py`、`tests/test_energy_cli.py` +- **Steps**: + 1. `modbus.py`:`read_meter(host, port, unit_id, blocks) -> dict[int,int]`(块读 input 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);不写电表配置寄存器。 +- **Acceptance criteria**: + - [ ] 单测:给定 `0x4366,0x3334` 解码为 `230.2`(参考文档实例);字序/字节序正确。 + - [ ] 单测:`SDM120_PROFILE.decode(...)` 把已知寄存器映射到正确列名与值;冷门量进 `extra`。 + - [ ] 连接失败/超时抛可识别异常,不静默返回错值。 + - [ ] `python -m scripts.energy_cli read ...` 能(对 mock/真实网关)打印解码后的各工程量;连接失败非零退出。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;无任何写寄存器路径;`requirements.txt` 已同步锁定 `pymodbus`。 + +### M5-T04 — Energy service + APScheduler 轮询 +- **Status**: `todo` · **Depends**: M5-T03 +- **Context**: 周期扫所有 enabled 电表,调用 driver 读+解码,落 `energy_readings`。仿 public-ip 的同步 job 模式。 +- **Files**: `create app/services/energy.py`;`modify app/main.py`(lifespan 注册 job);`create tests/test_energy_poll.py` +- **Steps**: + 1. `energy.py`:`poll_meter(session, meter) -> EnergyReading | None`(按 profile 读+解码+插入一行,记录成功/失败用于 online 状态);`poll_all_enabled_meters(session)` 遍历 enabled 电表;service 内吞异常并日志,不让 job 崩。 + 2. `main.py`:加同步 wrapper `_run_scheduled_energy_poll`(自管 session),`add_job(IntervalTrigger(seconds=...), id="energy-poll", max_instances=1, coalesce=True)`。周期取**最小 per-meter interval 或一个基础 tick**(实现可用单一基础 tick + 各表按自身 interval 取模决定本 tick 是否读,保持 job 简单);受全局 `ENERGY_POLLING_ENABLED` 控制。 + 3. 失败的电表记录 online=false(供 T11 暴露),不影响其它电表。 +- **Out of scope / 不要碰**: 不发 MQTT(T11);不加 HTTP 路由(T05);不引入两级周期(后续杠杆)。 +- **Acceptance criteria**: + - [ ] 单测:mock driver 返回已知值,`poll_all_enabled_meters` 后 `energy_readings` 精确 +N 行、列值正确。 + - [ ] 单测:某电表读失败时其它电表仍正常落库,job 不抛。 + - [ ] `ENERGY_POLLING_ENABLED=false` 时不轮询。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job。 + +### M5-T05 — Energy JSON API(meters CRUD + readings + test) +- **Status**: `todo` · **Depends**: M5-T02 +- **Context**: 给前端提供电表 CRUD、最新读数、时间范围读数、即时试读。 +- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy.py` +- **Steps**: + 1. meters:`GET`(list)/`POST`/`GET{id}`/`PATCH{id}`/`DELETE{id}`;session+CSRF;`slug` 唯一校验;`DELETE` 有读数 → 409。 + 2. readings:`GET {id}/latest`、`GET {id}/readings`(`start/end/limit`,limit 有上限防全表导出,按 `recorded_at` 升序)。 + 3. `POST {id}/test`:用 driver 即时读一次返回解码值(或错误),**不落库**。 +- **Out of scope / 不要碰**: 不在此处发 MQTT;不写采集逻辑(复用 T03/T04 的 driver/service)。 +- **Acceptance criteria**: + - [ ] CRUD 行为正确:创建/改/删行数精确;删有读数的电表返回 409;未登录 401、缺 CSRF 403。 + - [ ] readings 时间范围 + limit 上限生效;latest 返回最新一条。 + - [ ] schema 经 OpenAPI 固化入库。 + - [ ] 校验闸门全绿(含 `openapi/` 重导出)。 +- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(meter_id, recorded_at)` 索引;test 端点确不落库。 + +### M5-T06 — 前端:电表管理 UI +- **Status**: `todo` · **Depends**: M5-T01, M5-T05 +- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;电表增删改 + 试读。 +- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/MeterForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx` +- **Steps**: `useQuery`/`useMutation` 接 meters API;列表 + 新建/编辑表单 + 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {id}/test` 显示结果。 +- **Out of scope / 不要碰**: 走势图在 T07;不碰其它页面。 +- **Acceptance criteria**: + - [ ] 能增/改/删电表并即时刷新;删除有二次确认;409 有友好提示。 + - [ ] 侧栏出现 Energy 入口、`/energy` 可达。 + - [ ] 前端闸门全绿。 +- **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。 + +### M5-T07 — 前端:读数展示 + Recharts 走势图 +- **Status**: `todo` · **Depends**: M5-T05(数据), M5-T06(页面壳) +- **Context**: 在 Energy 页展示每电表最新读数 + 时间序列走势。 +- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/EnergyCharts.tsx`(封装 Recharts);`modify frontend/package.json`(加 `recharts`,`package-lock.json` 同步);`create` 对应测试 +- **Steps**: 最新读数卡片(接 `latest`);时间范围选择 + 折线图(电压/电流/功率/电能),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。 +- **Out of scope / 不要碰**: 不做服务端降采样(后续);不改后端。 +- **Acceptance criteria**: + - [ ] 最新读数与走势图渲染正确;时间范围只取窗口数据(不拉全量)。 + - [ ] Recharts 封装自包含、仅此处 import。 + - [ ] 前端闸门全绿(`build` 通过,注意 chunk 体积提示)。 +- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理。 + +### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS) +- **Status**: `todo` · **Depends**: none +- **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。 +- **Files**: `modify app/config.py`(新增 Settings 字段)、`app/services/config_page.py`(追加 CONFIG_FIELDS + `_settings_payload`);`modify .env.example`;`modify tests/test_api_config.py` +- **Steps**: 加字段 `mqtt_enabled`、`mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled`、`ha_discovery_enabled`、`ha_discovery_prefix`(默认 `homeassistant`)、`energy_polling_enabled`;CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Energy」section;`_settings_payload` 补齐对应行。 +- **Out of scope / 不要碰**: 不建 MQTT 客户端(T10);不动 expose 表(T09)。 +- **Acceptance criteria**: + - [ ] 新配置项在 `GET /api/config` 出现且分 section;password 为 secret(回空、留空保留);port 为 number。 + - [ ] 非法值(端口非数字)422 不写库。 + - [ ] 校验闸门全绿(OpenAPI 若变化则重导出)。 +- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。 + +### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]` +- **Status**: `todo` · **Depends**: M5-T02 +- **Context**: 建"可暴露实体目录"的抽象与开关存储;energy provider 把电表映射成 device/entities。 +- **Files**: `create app/integrations/expose.py`(`ExposableEntity`、provider 协议、注册表、energy provider);`create app/models/expose.py`(`ExposedEntityToggle`:`key` unique + `enabled` + `updated_at`);`create alembic_app/versions/_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。 + 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。 + - [ ] toggle 表 migration 可升/降;缺省 enabled=false。 + - [ ] 至少含一个非 sensor component(online binary_sensor)。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: `key` 稳定(电表用 slug,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算而非写死。 + +### M5-T10 — MQTT 客户端(paho,lifespan 连接 + 重连) +- **Status**: `todo` · **Depends**: M5-T08 +- **Context**: 长连接 MQTT 客户端,配置变更可重连;含连接测试端点。 +- **Files**: `create app/integrations/mqtt.py`;`modify app/main.py`(lifespan 起/停)、`app/api/routes/api/config.py`(`POST /api/config/mqtt/test`);`modify requirements.in`/`requirements.txt`(加 `paho-mqtt` 重新锁定);`create tests/test_mqtt_client.py` +- **Steps**: + 1. `MqttManager`:`is_configured()`、`connect()/disconnect()/reconnect(settings)`、`publish(topic, payload, retain)`;paho `loop_start()` 后台线程;未配置/未启用则 no-op。 + 2. lifespan:启用则 connect;shutdown disconnect。 + 3. 配置保存后若 MQTT 设置变化 → 触发 manager 重连(在 config 保存路径加 hook 或保存后比对)。 + 4. `POST /api/config/mqtt/test`:用提交/现存配置试连,返回三态(success/config-error/failed),仿 SMTP 测试。 +- **Out of scope / 不要碰**: 不构建 discovery/state 消息(T11)。 +- **Acceptance criteria**: + - [ ] 单测(fake broker/paho mock):configured 时 connect 调用正确;未配置 no-op;publish 透传 topic/payload/retain。 + - [ ] `POST /api/config/mqtt/test` 三态有明确返回;session+CSRF 保护。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。 + +### M5-T11 — Discovery 发布 + state 发布 +- **Status**: `todo` · **Depends**: M5-T09, M5-T10 +- **Context**: 把 enabled 实体发成 HA discovery 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` +- **Steps**: + 1. `build_discovery_payload(entity)` → HA 规范 config(`////config`,含 `device` 块、`state_topic`、`device_class`、`unit_of_measurement`、`availability`)。 + 2. `publish_discovery(session)`:对 enabled 实体发 retained config;对取消勾选的发空 payload 清除。 + 3. `publish_states(session)`:取各实体当前值发 state;energy 在 `poll_meter` 成功后顺带推该表实体 state + online。 + 4. lifespan:连接成功 / 目录或勾选变更后 `publish_discovery`;周期 job 兜底 `publish_states` + availability。 +- **Out of scope / 不要碰**: 不做前端(T12);不改采集解码逻辑。 +- **Acceptance criteria**: + - [ ] 单测:discovery payload 符合 HA 结构(device 分组正确、topic/ device_class/unit 正确);取消勾选发空 payload。 + - [ ] 单测:energy 轮询成功后推对应 state topic;失败推 online=false。 + - [ ] discovery 用 retained。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 仅发 enabled 实体;entity 唯一标识稳定;MQTT 未启用时整链 no-op;不阻塞轮询。 + +### M5-T12 — 前端:Expose 勾选 UI + `/api/expose` +- **Status**: `todo` · **Depends**: M5-T09, M5-T11 +- **Context**: 后端 expose 读写端点 + 设置页勾选界面。 +- **Files**: `create app/api/routes/api/expose.py`、`app/schemas/expose.py`;`modify app/main.py`;`create tests/test_api_expose.py`;前端 `create frontend/src/pages/.../ExposeSettings.tsx`(或并入 ConfigPage)、`modify` 路由/设置入口;`create` 前端测试 +- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`(key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。 +- **Out of scope / 不要碰**: 不改采集/发布逻辑(T11)。 +- **Acceptance criteria**: + - [ ] `GET/PUT /api/expose` 正确读写勾选;session+CSRF;OpenAPI 固化。 + - [ ] 勾选变更后(或点重新发布)触发 discovery 重发。 + - [ ] 前端能勾选并显示状态;前后端闸门全绿。 +- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置;republish 真触发 T11;类型化 client。 + +### M5-T13 — 文档 + OpenAPI + roadmap 收尾 +- **Status**: `todo` · **Depends**: 全部 +- **Files**: `modify README.md`(Energy/MQTT 段、新依赖)、`docs/roadmap.md`(M5 行 + 把"MQTT/IoT"从"下一阶段"毕业、新增 Modbus/Energy 方向)、`docs/architecture-overview.md`(新增 MQTT 通道与能耗采集);`modify docs/design/README.md`(列入 m5);`run python scripts/export_openapi.py` 并提交 `openapi/` +- **Acceptance criteria**: + - [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异。 + - [ ] 校验闸门全绿。 +- **Reviewer checklist**: 无残留旧描述;OpenAPI 已入库。 + +--- + +## 8. 前端校验闸门(前端任务每次结束都要全绿) + +在 `frontend/` 下: +```bash +npm ci +npm run lint +npm run typecheck +npm run test +npm run build # 必须产出 dist;留意 chunk 体积告警 +``` +- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`。 +- 新增前端依赖(recharts)须提交 `package.json` + `package-lock.json`。 + +## 9. 构建上下文完整性(M1 教训) + +- 本里程碑**不删/移文件**,但新增了 Python 依赖(`pymodbus`、`paho-mqtt`)与前端依赖(`recharts`):必须同步 `requirements.in/.txt` 的重新锁定与 `package-lock.json`,否则镜像构建会缺包。 +- 新增源文件都在 `app/`、`scripts/` 与 `frontend/` 既有 COPY 范围内,无需改 `Dockerfile` 的 `COPY`;`scripts/energy_cli.py` 须在镜像里可 `python -m scripts.energy_cli` 调用(确认 `scripts/` 已进镜像);`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。 +- 发版前置走查(见 CLAUDE.md):真起 app 跑一次轮询、真连一次 broker、前端 Energy 视图人工瞄一眼渲染,再打 tag。 + +## 10. 后续杠杆(本里程碑不做,文档留痕) + +- **两级采样周期**:瞬时量(V/I/P/PF/Hz)快、累计电能慢——9600 总线吃紧或要把功率压到 5s 以下时再开(meters 表加 `energy_interval_s`,读数表已含全部列,非破坏性变更)。 +- **保留 / 降采样**:5s 采样长期行数大(≈630 万行/年/表),加定期降采样或保留窗口任务(独立于本里程碑)。 +- **更多 device profile**:三相电表(如 SDM630)按 §3.2 填 l1/l2/l3 + total,新增 profile 即可。 +- **更多 expose provider**:public-ip / poo 等挂入 expose 框架。 +- **写电表配置**:当前只读;如需经 MQTT/UI 控制设备(switch 类),再单独评估安全边界。 + +## 11. 人工验收 walkthrough(实现完成后) + +> 重点:用一条命令行命令读到电表数据并展示结果。可用 `docker compose` 起环境,命令在容器内或容器外跑均可。 + +**前提**:网关(Waveshare RTU↔TCP)已上电接入网络,电表 Meter ID 已知(默认 1)。 + +**1) 命令行直接试读(不依赖 DB,最快验证)** +- 容器内:`docker compose exec python -m scripts.energy_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120` +- 或容器外:`source .venv/bin/activate && python -m scripts.energy_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120` +- 预期:打印解码后的工程量(电压/电流/有功功率/功率因数/频率/导入导出电能等),数值合理;连不上则清晰报错。 + +**2)(可选)经 API 试读已配置的电表** +- 先在前端 Energy 页或 `POST /api/energy/meters` 建一个电表; +- `POST /api/energy/meters/{id}/test` 即时试读,返回解码值(不落库)。 + +**3)(可选)验证后台轮询落库** +- 确认 `ENERGY_POLLING_ENABLED=true` 且电表 `enabled`;等一个采样周期; +- 看前端 Energy 视图的最新读数/走势图,或查 `GET /api/energy/meters/{id}/readings` 有新行。 + +## 12. 里程碑完成定义(DoD) + +- 后端能按 per-meter 周期静默轮询 Modbus-TCP 电表、解码落 `energy_readings`,支持多电表 CRUD。 +- MQTT 启用时,勾选的实体以 HA Discovery 注册成 device/entities(含非 sensor),state 周期发布;配置变更可重连重发。 +- 前端侧边栏可切换功能;Energy 视图能管理电表、看最新读数与走势图;设置页可勾选 expose。 +- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 已入库。 +- README / architecture / roadmap / design 索引反映 M5 现实。 diff --git a/docs/references/SDM120-MODBUS_Protocol.pdf b/docs/references/SDM120-MODBUS_Protocol.pdf new file mode 100644 index 0000000..8252414 Binary files /dev/null and b/docs/references/SDM120-MODBUS_Protocol.pdf differ diff --git a/docs/references/SDM120-Modbus-Protocol.md b/docs/references/SDM120-Modbus-Protocol.md new file mode 100644 index 0000000..54e5198 --- /dev/null +++ b/docs/references/SDM120-Modbus-Protocol.md @@ -0,0 +1,134 @@ +# SDM120 Modbus 协议(从官方 PDF 提取) + +> 来源:`docs/references/SDM120-MODBUS_Protocol.pdf` +> (Eastron SDM120 Modbus Smart Meter Modbus Protocol Implementation **V2.4**) +> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。 + +## 0. 本项目的接入方式(重要) + +SDM120 物理层是 **Modbus RTU(RS-485 串口)**。本项目通过一个 **Modbus-TCP 网关**接入: + +- 后端用 **Modbus TCP**(`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。 +- TCP 帧用 MBAP header、**无 CRC**(CRC/Error Check 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `Error Check (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。 +- **Slave Address / Unit ID = 电表的 Meter ID**(默认 `1`,范围 1–247),在 TCP 请求里作为 unit id 传入。 +- 若以后直连串口(RTU),才需要管波特率 / 校验位 / CRC(见 §5 holding 寄存器)。 + +## 1. 协议帧格式 + +MODBUS 定义 master 查询 / slave 响应的格式。Eastron 电表用 16-bit 寄存器在主从间传值,**但实际数据是 32-bit IEEE-754 浮点**,因此每个测量参数占**两个相邻的 16-bit 寄存器**。 + +### Query(master → slave,RTU 帧) + +| 字段 | 说明 | +| --- | --- | +| Slave Address | 8-bit,目标从机地址 1–247(0=广播,Eastron 不支持广播) | +| Function Code | 8-bit,功能码(Eastron 支持 **03 / 04 / 08 / 16(=0x10)**) | +| Start Address (Hi/Lo) | 16-bit 起始寄存器地址;**寄存器成对使用、从 0 开始,所以起始地址必须是偶数** | +| Number of Points (Hi/Lo) | 16-bit 请求的寄存器数量;**也必须是偶数**(成对读浮点) | +| Error Check (Lo/Hi) | 16-bit CRC(RTU 模式;TCP 网关模式无此字段) | + +### Response(slave → master) + +| 字段 | 说明 | +| --- | --- | +| Slave Address | 响应从机地址 | +| Function Code | 与查询相同的功能码(表示识别并已响应) | +| Byte Count | 8-bit,本次返回的数据字节数 | +| Data (寄存器对) | 每个寄存器 Hi byte / Lo byte,按"高寄存器在前"排列 | +| Error Check (Lo/Hi) | 16-bit CRC(RTU 模式) | + +### Exception Response(异常响应) + +- 异常响应的 Function Code = 查询功能码 **OR 0x80**(最高位置 1)。 +- 数据是单字节 Error Code(异常码)。 +- PDF 正文写了"见后文 Table Of Exception Codes",但提取的 8 页里**没有附上该表**。标准 Modbus 异常码(供参考,非本 PDF 内容):`01` 非法功能、`02` 非法数据地址、`03` 非法数据值、`04` 从机设备故障。 + +## 2. 功能码 + +| 功能码 | 作用 | 寄存器区 | +| --- | --- | --- | +| **04** | Read Input Registers(读输入寄存器,3X)—— **所有测量值都在这里** | 30001+ | +| **03** | Read Holding Registers(读保持寄存器,4X)—— 配置项 | 40001+ | +| **16 / 0x10** | Write Holding Registers(写保持寄存器,4X)—— 改配置 | 40001+ | +| 08 | Diagnostics(诊断) | — | + +> ⚠️ **测量值用 FC 04(输入寄存器),不是 FC 03。** 这是最常见的踩坑点。 + +## 3. 浮点数据编码 + +- 每个参数 = **32-bit IEEE-754 float**,占两个相邻 16-bit 寄存器。 +- **字序(word order)= 大端:高寄存器在前。** +- **字节序(byte order)= 大端:寄存器内高字节在前。** +- 即整体就是标准大端 float(`>f`),4 字节顺序 = `[Reg1 Hi][Reg1 Lo][Reg2 Hi][Reg2 Lo]`。 + +**实例(来自 PDF)** + +| 含义 | 原始 4 字节 (hex) | 解码值 | +| --- | --- | --- | +| Volts 1 | `43 66 33 34` | `230.2` V | +| Demand Time | `3F 80 00 00` | `1.0` | +| Network Node | `42 70 00 00` | `60.0` | + +> Python 解码:`struct.unpack('>f', bytes([0x43,0x66,0x33,0x34]))[0]` → `230.2`。 +> pymodbus 用 `BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。 + +## 4. 输入寄存器表(FC 04 读测量值) + +全部为 `Float`,长度 4 字节,每项占 2 个寄存器。"Hex 起始"是 Modbus 协议起始地址(即 Start Address Hi/Lo)。 + +| 寄存器 | 参数 | 单位 | Hex 起始 | +| --- | --- | --- | --- | +| 30001 | Voltage(电压) | Volts | `0000` | +| 30007 | Current(电流) | Amps | `0006` | +| 30013 | Active power(有功功率) | Watts | `000C` | +| 30019 | Apparent power(视在功率) | VA | `0012` | +| 30025 | Reactive power(无功功率) | VAr | `0018` | +| 30031 | Power factor(功率因数) | — | `001E` | +| 30071 | Frequency(频率) | Hz | `0046` | +| 30073 | Import active energy(导入有功电能) | kWh | `0048` | +| 30075 | Export active energy(导出有功电能) | kWh | `004A` | +| 30077 | Import reactive energy(导入无功电能) | kvarh | `004C` | +| 30079 | Export reactive energy(导出无功电能) | kvarh | `004E` | +| 30085 | Total system power demand | W | `0054` | +| 30087 | Maximum total system power demand | W | `0056` | +| 30089 | Import system power demand | W | `0058` | +| 30091 | Maximum import system power demand | W | `005A` | +| 30093 | Export system power demand | W | `005C` | +| 30095 | Maximum export system power demand | W | `005E` | +| 30259 | Current demand | Amps | `0102` | +| 30265 | Maximum current demand | Amps | `0108` | +| 30343 | Total active energy(总有功电能) | kWh | `0156` | +| 30345 | Total reactive energy(总无功电能) | kvarh | `0158` | + +**读取分块建议**:`0x0000–0x005E`(30001–30095)地址连续,可一次块读;`0x0102/0x0108`、`0x0156/0x0158` 各为独立小块。整表用 2–3 次块读即可覆盖,减少 Modbus 事务数。 + +### 常用核心子集(日常监控够用) + +电压 `0000`、电流 `0006`、有功功率 `000C`、功率因数 `001E`、频率 `0046`、导入有功电能 `0048`、导出有功电能 `004A`、总有功电能 `0156`。 + +## 5. 保持寄存器表(FC 03 读 / FC 16 写配置) + +| 寄存器 | 参数 | Hex 起始 | 格式 | 说明 | +| --- | --- | --- | --- | --- | +| 40013 | Relay Pulse Width | `000C` | Float | 继电器脉宽 60/100/200 ms,默认 100ms | +| 40019 | Network Parity Stop | `0012` | Float | 0=1停止位无校验(默认),1=1停止位偶校验,2=1停止位奇校验,3=2停止位无校验;**改后需重启生效** | +| 40021 | Meter ID | `0014` | Float | 从机地址 1–247,默认 1 | +| 40029 | Baud rate | `001C` | Float | 0=2400(默认),1=4800,2=9600,5=1200 | +| 40087 | Pulse 1 output mode | `0056` | Float | 0001 导入有功,0002 导入+导出有功,0004 导出有功(默认),0005 导入无功,0006 导入+导出无功,0008 导出无功 | +| 463745 | Time of scroll display | `F900` | HEX 2字节 | 滚动显示时间 0–30s,默认 0(不滚动) | +| 463761 | Pulse 1 output | `F910` | HEX 2字节 | 0000:0.001kWh/imp(默认),0001:0.01,0002:0.1,0003:1 kWh/imp | +| 463777 | Measurement mode | `F920` | HEX 2字节 | 1:total=import,2:total=import+export(默认),3:total=import-export | +| 464513 | Serial number | `FC00` | uint32 4字节 | 序列号,**只读** | +| 464515 | Meter code | `FC02` | Hex 2字节 | 设备码=`0020`,**只读** | +| 464516 | Software version | `FC03` | Hex 2字节 | 软件版本,**只读** | + +> ⚠️ 写保持寄存器(改 Meter ID / 波特率 / 校验位等)会改变电表通信参数,配错可能导致**通信中断**。本项目默认**只读采集**,不建议在自动化链路里写电表配置。 + +## 6. 给本项目采集驱动的要点小结 + +1. 走 **Modbus TCP 网关**:`ModbusTcpClient(host, port)`,`slave=`。 +2. 测量值用 **FC 04 / 输入寄存器**,按 §4 地址读。 +3. 解码 **大端 float32**(word & byte 都大端,高寄存器在前)。 +4. 起始地址与数量**都用偶数**(成对读)。 +5. 默认**只读**,不写电表配置寄存器。 +6. 不同型号电表 → 不同"寄存器 profile"。本表是 `sdm120` 这一个 profile 的定义。