1214 lines
71 KiB
Markdown
1214 lines
71 KiB
Markdown
# M8 — WarmteLink P1、多数据源 Meter 与热力计费
|
||
|
||
> **状态:M8-T01~M8-T20 自动化技术验收已完成;交付后用户人工 walkthrough 待验收。**
|
||
> [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md) 已完成并通过翻牌;本文锁定
|
||
> M8 的目标架构、迁移顺序、外部契约和可由编排器逐张执行的原子任务。
|
||
|
||
## 1. 目标与完成边界
|
||
|
||
M8 完成后,Energy 模块不再把“电表”“采集连接”和“协议设备”混为一谈。系统能够:
|
||
|
||
- 用统一的 `MeterSource → MeterSourceChannel → MeterSourceBinding → Meter` 链路表示
|
||
DSMR MQTT 和 WarmteLink serial 两种采集源。
|
||
- 从一个 WarmteLink P1 串口发现并持续采集两个累计 channel:区域供暖 `GJ` 与生活热水
|
||
`m³`,保留数据质量状态,且不保存原始 telegram 或可识别设备编号。
|
||
- 让 `electricity`、`heating`、`hot_water` 三种 Meter epoch 各自绑定 channel;数据源切换和
|
||
物理换表是两条独立时间线。
|
||
- 在不改变既有正常电费结果的前提下,让电费可审计到 source binding;同时支持一份热力合同、
|
||
两种热力累计量、15 分钟成本、每日固定费和汇总。
|
||
- 在 Energy UI 中分别管理 Sources、Modbus Devices、Meters、合同、价格与成本,并按需把新的
|
||
source/meter/cost 实体暴露给 Home Assistant。
|
||
- 以非 root 部署:Docker device rule 用 pyserial POSIX `O_RDWR` 所需的最小 `rw`(绝无 `m`),而 worker 业务只读;worker 能启动、热更新、断线重连和干净停止。
|
||
|
||
M8 不承诺 WarmteLink 当前 telegram 未提供的瞬时流量、热功率、供/回水温度,也不推断
|
||
“生活热水消耗了多少 GJ”。不把 WarmteLink 塞进 `modbus_device`,不把所有协议读数强行泛化成
|
||
一张万能 telemetry 表,也不删除旧数据库、历史读数或旧 `app_config` 行。
|
||
|
||
## 2. 已验证基线
|
||
|
||
- Pre-M8 以 `115200 7N1` 完成正式只读 CLI 长测;frame 长度可变,不能按固定字节数切包。
|
||
- 每帧稳定包含 channel 1:生活热水累计 `m³`(device type `006`)和 channel 2:区域供暖累计
|
||
`GJ`(device type `012`)。人工开启供暖后,后者由 `0.017` 增至 `0.018 GJ`,物理表同步。
|
||
- 设备 timestamp 每 10 秒递进;所有观测 frame 缺少标准 `/`,因此 CRC 只能标为
|
||
`unverifiable`,不能伪装成 `valid`。
|
||
- 现有 DSMR 把解析后的完整 JSON 降采样写入 `dsmr_reading`;`recorded_at` 当前是全表唯一,
|
||
electricity Meter 与读数之间没有显式 binding。
|
||
- 现有 Meter 已有 epoch/换表语义;现有 Modbus Device 是轮询协议设备,不是通用数据源。
|
||
- 现有合同、动态电价、`energy_cost_period` 与 HA 能源实体以 electricity 为中心;每日固定费在
|
||
Europe/Amsterdam 本地时间 `01:05` 后结算。
|
||
|
||
## 3. 锁定的架构决策
|
||
|
||
### D1 — Source、Device、Meter 是三个概念
|
||
|
||
- `MeterSource`:应用如何连接并采集数据,M8 支持 `dsmr_mqtt`、`warmtelink_serial`。
|
||
- `ModbusDevice`:Modbus 协议设备,继续保留现有模型与轮询配置。
|
||
- `Meter`:一只物理计量表的安装 epoch,继续承担换表/搬家时间线。
|
||
- 前端明确分成 **Sources / Modbus Devices / Meters**,不再把 Modbus 页面称为通用 Devices。
|
||
|
||
### D2 — 多 channel 先发现,再由人确认绑定
|
||
|
||
Source 首次成功解码时 upsert channel。parser 可以根据 device type/unit 给出 commodity 建议,但
|
||
不得自动创建 Meter 或自动绑定。用户在 UI/API 中确认 channel 与 Meter epoch 的关系。
|
||
|
||
### D3 — Binding 有独立时间线
|
||
|
||
`MeterSourceBinding` 表示一个 channel 在 `[started_at, ended_at)` 为某个 Meter 提供读数。
|
||
同一 Meter 和同一 channel 在任一时刻都最多有一个有效 binding。切换 MQTT topic、USB 适配器或
|
||
采集主机时,可以结束旧 binding 并创建新 binding,而无需伪造一次物理换表;真正换表仍创建新的
|
||
Meter epoch。
|
||
|
||
### D4 — 协议读数分表,身份链统一
|
||
|
||
DSMR 继续保存 JSON payload;WarmteLink 使用规范化标量表。两种表都关联 source/channel,避免
|
||
为了表面统一而损失协议字段或制造稀疏万能表。
|
||
|
||
### D5 — WarmteLink 质量状态不漂白
|
||
|
||
- parser 结果质量枚举为 `valid`、`invalid`、`unverifiable`。
|
||
- `valid` 可直接接纳;`invalid` 丢弃且记录诊断;`unverifiable` 必须连续两帧确认后才接纳。
|
||
- 连续确认要求:设备时间严格递增、channel identity/metadata 相同、累计值不下降。
|
||
- 接纳后仍保存 `unverifiable`,不得改写为 `valid`。reset/wrap 没有真机证据,M8 不自动修正。
|
||
|
||
### D6 — 当前状态与历史采样分离
|
||
|
||
每个有效 frame(约 10 秒)更新 source/channel 的 latest/status;持久化历史按设备时间每分钟一个
|
||
样本。首次启动前没有数据就保持空,不回填或猜测历史。只保存 SHA-256 equipment fingerprint,
|
||
绝不保存原始 telegram bytes 或原始 equipment identifier。
|
||
|
||
### D7 — 串口 worker 只读且可协调
|
||
|
||
每个 enabled serial source 一个 worker。manager 在启动和配置变更时 reconcile,在关闭时 join;
|
||
断线按 `1, 2, 4, …, 60s` 退避并可恢复。串口只调用 read,不提供 `.write()` 路径;每次落库使用
|
||
短生命周期 session。测试以 fake serial/clock 驱动,不依赖宿主机真实设备。
|
||
|
||
### D8 — DSMR 也迁入 source/binding 世界
|
||
|
||
升级时创建 DSMR source/channel,并给所有 electricity Meter epoch 建立时间相交的 binding;
|
||
`dsmr_reading.source_id` 改名为 `telegram_id`,新增 `meter_source_id`,唯一键变成
|
||
`(meter_source_id, recorded_at)`。旧 DSMR `app_config` KV 暂时保留作回滚证据,运行时和 UI 在
|
||
新 source 生效后不再读取它们。
|
||
|
||
### D9 — 电费对 source binding 可审计
|
||
|
||
`energy_cost_period` 增加 `source_binding_id`。周期两端必须落在同一 Meter epoch 和同一 binding;
|
||
跨 Meter/source/binding、缺 binding 或读数陈旧都降级,不把两个累计域相减。既有单 source 的
|
||
正常输入、价格与舍入结果必须保持不变。
|
||
|
||
### D10 — 合同按 scope 并存
|
||
|
||
现有 `manual`、`tibber` 属于 `electricity` scope;新 `district_heating` 属于 `thermal` scope。
|
||
互斥规则改为每个 scope 最多一个 active,因此 electricity 与 thermal 合同可以同时激活。一份
|
||
thermal 合同同时给 heating 与 hot_water 计价。
|
||
|
||
### D11 — 热力合同字段固定,但不硬编码真实费率
|
||
|
||
`district_heating` profile 使用以下 Decimal 字段:
|
||
|
||
| 类别 | 字段 | 单位 |
|
||
| --- | --- | --- |
|
||
| variable | `heating` | EUR/GJ |
|
||
| variable | `hot_water_heating` | EUR/m³ |
|
||
| variable | `hot_water` | EUR/m³ |
|
||
| variable | `hot_water_tax` | EUR/m³ |
|
||
| standing | `heating_network` | EUR/year |
|
||
| standing | `metering` | EUR/year |
|
||
| standing | `delivery_set` | EUR/year |
|
||
| standing | `hot_water_network` | EUR/year |
|
||
| standing | `other` | EUR/year |
|
||
|
||
standing 字段默认 `0`;仓库不内置用户的真实合同价格。字段解释以
|
||
[Vattenfall stadsverwarming tarieven](https://www.vattenfall.nl/stadsverwarming/tarieven/) 为人工录入参考,
|
||
具体金额始终由用户提供并通过 version 保存。
|
||
|
||
### D12 — 热力成本按 commodity 落 15 分钟账本
|
||
|
||
新 `meter_cost_period` 以 `(commodity, period_start)` 唯一,保存 Meter/binding(降级时可空)、
|
||
contract version、Decimal quantity/cost、`cost_breakdown`、`pricing_snapshot` 和 `degraded_reason`。
|
||
|
||
- heating:`ΔGJ × heating`。
|
||
- hot_water:`Δm³ × (hot_water_heating + hot_water + hot_water_tax)`,breakdown 保留三部分。
|
||
- 防呆:读数 freshness `120s`;15 分钟 heating delta 上限 `0.1 GJ`、hot_water 上限 `1 m³`;
|
||
负值、越限、跨 epoch/binding 或质量不可用均降级。
|
||
- 固定费是 contract-level summary,只计一次,不分别塞进两个 commodity period。按年费 `/ 365`
|
||
对已结算的本地自然日计提,沿用 `01:05` 结算点。
|
||
|
||
### D13 — HA 身份与开关保持稳定
|
||
|
||
新增 source online、Meter total/today,以及 thermal 的 heating、hot-water-heating、water、water-tax、
|
||
fixed、all-in total/today 实体;所有新 toggle 默认 false。单 Meter 实体以 Meter UUID 锚定;热力组合
|
||
成本实体以当前 heating/hot_water Meter UUID 的有序组合锚定,任意一个换表都会生成新 identity。
|
||
DSMR Reader 现有直接 HA 暴露不受影响。
|
||
|
||
### D14 — UI/API 兼容优先
|
||
|
||
Contracts / Prices / Costs 使用 Electricity / Thermal scope selector。原 DSMR 顶层 tab 折入对应
|
||
source detail;已有 `GET /api/energy/dsmr/latest` 保持兼容。删除有历史、channel 或 binding 的 source
|
||
返回 `409`,不级联删除。
|
||
|
||
## 4. 目标数据流
|
||
|
||
```text
|
||
DSMR MQTT ──┐ ┌─ electricity Meter epoch
|
||
├─ MeterSource ─ channel ─ binding ─┼─ heating Meter epoch
|
||
WarmteLink ─┘ └─ hot_water Meter epoch
|
||
serial │
|
||
├─ protocol reading table ── latest / history API / UI / HA
|
||
└─ binding-aware cost engine ── 15-min ledger / summary / HA
|
||
```
|
||
|
||
Source 负责连接和健康状态,channel 负责稳定的测量身份,binding 负责把测量域接到物理 Meter epoch。
|
||
任何成本计算都先解析 period 两端的 Meter + binding,再决定能否相减。
|
||
|
||
## 5. 目标模型与迁移不变量
|
||
|
||
| 表 / 变更 | 关键字段与不变量 |
|
||
| --- | --- |
|
||
| `meter_source` | UUID、name、kind、enabled、config JSON、status、last_seen_at、last_error、created/updated;config 经 kind profile 验证,API 不回显 secret |
|
||
| `meter_source_channel` | source FK、稳定 `channel_key`、label、suggested commodity、unit、device_type、fingerprint、latest value/time/quality;`(source_id, channel_key)` 唯一 |
|
||
| `meter_source_binding` | Meter FK、channel FK、`[started_at, ended_at)`;同 Meter/同 channel 不允许时间重叠,commodity/unit 必须兼容 |
|
||
| `dsmr_reading` | `telegram_id` 仅供诊断;`meter_source_id` FK;`(meter_source_id, recorded_at)` 唯一;payload 保持原样 |
|
||
| `warmtelink_reading` | channel FK、recorded/received time、Decimal value、unit、quality、fingerprint;`(channel_id, recorded_at)` 唯一 |
|
||
| `energy_contract` | 新增 `scope`;旧行回填 `electricity`,active 互斥按 scope |
|
||
| `energy_cost_period` | 新增 nullable `source_binding_id`;历史可为空,未来正常周期必须可审计到 binding |
|
||
| `meter_cost_period` | `(commodity, period_start)` 唯一;quantity/cost 用定点 Decimal;降级原因与 pricing snapshot 可审计 |
|
||
|
||
所有 migration 必须在空库和带历史数据的升级副本上通过,迁移前后逐表对账,不 drop/truncate 任何
|
||
业务数据。SQLite batch migration 后显式验证 FK、索引和唯一约束。
|
||
|
||
### M8 Alembic revision 计划与仓库级不变量
|
||
|
||
M8 的 schema 变更沿现有单一 `alembic_app` 链按下表串行落地;不得合并成一次不可审计的大迁移,
|
||
也不得创建并行 head:
|
||
|
||
| Task / revision | `down_revision` | Schema 变更 |
|
||
| --- | --- | --- |
|
||
| T01 / `20260822_15_meter_sources` | `20260625_14_meter_uuid` | 新建 `meter_source`、`meter_source_channel`、`meter_source_binding`;给 `energy_cost_period` 增加 nullable `source_binding_id` |
|
||
| T03 / `20260822_16_dsmr_source_adoption` | `20260822_15_meter_sources` | `dsmr_reading.source_id` 政名为 `telegram_id`,新增 non-null `meter_source_id`,唯一键改为 `(meter_source_id, recorded_at)`,并执行 DSMR/source/binding/cost 历史回填 |
|
||
| T08 / `20260822_17_warmtelink_readings` | `20260822_16_dsmr_source_adoption` | 新建 `warmtelink_reading` 标量历史表 |
|
||
| T12 / `20260822_18_contract_scopes` | `20260822_17_warmtelink_readings` | 给 `energy_contract` 增加 non-null `scope`,旧行回填 `electricity` |
|
||
| T14 / `20260822_19_meter_cost_periods` | `20260822_18_contract_scopes` | 新建 `meter_cost_period` 审计账本 |
|
||
|
||
每张新增 revision 的任务还必须同时遵守以下仓库级契约:
|
||
|
||
1. 新模型模块在首次引入时显式 import 到 `alembic_app/env.py`,保证 `Base.metadata` 完整;后续只在
|
||
已注册模块中新增模型时无需重复改 env。
|
||
2. 同一任务把 `scripts/app_db_adopt.py::APP_BASELINE_REVISION` 更新为该任务的新 head。现有
|
||
fail-closed 启动校验和多组部署/模型回归测试都要求该常量与唯一 Alembic head 完全相等;不得把
|
||
常量临时留在旧 revision 等后续任务补救。
|
||
3. `scripts/run_migrations.py` 继续只调用 app DB adoption/upgrade,不新增第二条迁移链;每个任务验证
|
||
旧 head→新 head、空库→head 和重复运行幂等。
|
||
4. 实现、review 和 T20 收尾只使用 pytest `tmp_path`、`/tmp` 下明确命名的临时 SQLite 库以及合成的
|
||
历史 fixture。不得挂载、复制、打开或修改运行中 production 的 DB 路径、容器或 volume;所谓
|
||
“历史升级副本”在本轮指结构和边界场景等价的隔离合成副本,不含真实生产数据。
|
||
5. 每次升级对账至少记录 migration 前后业务表行数、Alembic revision、孤儿 FK、关键唯一键/索引;
|
||
任何不一致必须在同一事务中失败并回滚。downgrade 仅用于临时测试库的 schema 可逆性验证,绝不
|
||
对生产或用户备份执行。
|
||
|
||
### DSMR 回填算法
|
||
|
||
1. 读取旧 runtime config,创建一个 `dsmr_mqtt` source 和稳定 electricity channel;无旧 config 时也
|
||
创建 disabled source,不猜 broker/topic。
|
||
2. 所有既有 `dsmr_reading` 指向该 source;行数必须完全相等。
|
||
3. 对每个 electricity Meter epoch,以 Meter 的 `[started_at, ended_at)` 与 DSMR 数据域相交创建
|
||
binding;没有数据交集的 epoch 也保留 Meter,不伪造读数。
|
||
4. 回填 `energy_cost_period.source_binding_id`:能唯一解析的正常周期必须绑定;跨边界或无法唯一
|
||
解析的行保留账本但标为 degraded,禁止静默绑定到当前表。
|
||
5. migration 完成后验证孤儿 FK 为 0、读数/成本行数不减少、正常可解析周期无空 binding。
|
||
|
||
## 6. HTTP 契约
|
||
|
||
所有路由继续使用 admin session + CSRF 约定。改路由/schema/docstring 的任务必须同时更新
|
||
`openapi/` 和 `frontend/src/api/schema.d.ts`。
|
||
|
||
| Method + path | 语义 |
|
||
| --- | --- |
|
||
| `GET /api/energy/source-profiles` | 返回 source kind、字段 schema、默认值与 capabilities |
|
||
| `GET/POST /api/energy/sources` | 列表 / 创建 source |
|
||
| `GET/PATCH/DELETE /api/energy/sources/{uuid}` | 详情 / 更新 / 无依赖时删除;否则 `409` |
|
||
| `POST /api/energy/sources/{uuid}/discover` | 请求一次连接/发现,返回发现状态,不自动绑定 |
|
||
| `GET /api/energy/sources/{uuid}/channels` | channel、latest、质量、建议 commodity 与 binding 状态 |
|
||
| `GET /api/energy/sources/{uuid}/channels/{channel_uuid}/readings` | 分页/时间窗历史 |
|
||
| `GET /api/energy/commodities` | 返回 electricity/heating/hot_water 与单位、能力 |
|
||
| `GET/POST /api/energy/meters/{meter_id}/bindings` | binding 时间线 / 新建 binding |
|
||
| `PATCH /api/energy/bindings/{binding_id}` | 修正/关闭 binding,不删除历史 |
|
||
| `POST /api/energy/meters` | 可选 `source_channel_uuid`,同事务声明 Meter + binding |
|
||
| `GET /api/energy/dsmr/latest` | 保持既有响应兼容,内部按 DSMR source 查询 |
|
||
| contracts responses | 增加 `scope`;active 互斥按 scope |
|
||
| `GET /api/energy/prices?scope=...` | electricity 保持既有结果;thermal 返回当前 contract version |
|
||
| `GET /api/energy/meter-costs?scope=thermal` | 热力 15 分钟账本 |
|
||
| `GET /api/energy/meter-costs/summary?scope=thermal` | variable + fixed + all-in 汇总 |
|
||
| `POST /api/energy/meter-costs/recompute?scope=thermal` | 显式重算时间窗,幂等覆盖同一唯一键 |
|
||
|
||
## 7. Worker、部署与运行边界
|
||
|
||
- serial 默认 `115200`、`7` data bits、no parity、`1` stop bit;设备路径必须是 `/dev/...`。
|
||
- `warmtelink_serial` config 保存路径和串口参数,不保存 telegram/equipment id;敏感字段采用现有
|
||
secret mask 约定。
|
||
- 现有 base+dev Compose 通过 `WARMTELINK_DEVICE_PATH` 与 `WARMTELINK_SERIAL_GID` 环境变量,把宿主机稳定
|
||
`/dev/serial/by-id/...` 直接映射为容器内 `/dev/warmtelink`,并以宿主机 serial GID 授权。pyserial 在 POSIX
|
||
以 `O_RDWR` 打开,故 device rule 的最小系统权限为 `rw`(不含 `m`);容器仍非 root/非 privileged,worker
|
||
业务只 read/close、绝不 write。
|
||
- 当前 base+dev Compose 要求在 `.env`(或等效部署环境)中同时提供
|
||
`WARMTELINK_DEVICE_PATH` 与 `WARMTELINK_SERIAL_GID`,并固定声明该 device 映射;没有有效硬件路径和
|
||
serial GID 的部署不能把 Compose 当作“无硬件可启动”的配置。
|
||
- source disable/delete/config change 必须停止旧 worker;shutdown 不留下线程或打开的 serial fd。
|
||
|
||
## 8. 前端信息架构
|
||
|
||
Energy 页面最终包含:Sources、Modbus Devices、Meters、Contracts、Prices、Costs;HA Expose 继续位于
|
||
现有 Config 页面,不在 M8 搬家。
|
||
|
||
- Sources:kind profile 驱动表单;状态、last seen/error、discover;source detail 显示 channel latest、
|
||
quality、历史和当前 binding。DSMR 原顶层面板折入 DSMR source detail。
|
||
- Meters:支持三种 commodity;声明 Meter 时可以选已发现且单位兼容的 channel,并原子创建
|
||
binding;历史页能看 binding 时间线,数据源切换不伪装成换表。
|
||
- Contracts / Prices / Costs:统一 scope selector;thermal 合同表单展示 §3 D11 固定字段;成本页
|
||
分开展示 heating/hot_water variable breakdown、fixed 与 all-in,degraded 原因可见。
|
||
- 所有前端任务补齐 RTL 测试,并执行 lint、typecheck、test、build;不能手改生成的
|
||
`schema.d.ts`。
|
||
|
||
## 9. 依赖图与建议编排顺序
|
||
|
||
```text
|
||
T01 → T02
|
||
T02 → T03 → T04 → T05 → T06
|
||
T02 → T07 → T08 → T09
|
||
(T06, T09) → T10 → T11
|
||
T08 → T12 → T13 → T14 → T15 → T16
|
||
(T11, T16) → T17 → T18 → T19 → T20
|
||
```
|
||
|
||
T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 WarmteLink;T12~T16
|
||
建立 thermal contract/billing;T17~T19 完成 HA 与 UI;T20 做部署、文档与整链验收。只有
|
||
`Depends` 全部为 `done` 的卡可以派发。
|
||
|
||
## 10. 原子任务卡
|
||
|
||
### M8-T01 — 建立 Source / Channel / Binding 基础模型 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: none
|
||
- **Context**: 先建立协议无关的身份链和时间约束;本卡只改 schema/model,不接运行时或 HTTP。
|
||
|
||
**Files**
|
||
- `create app/models/meter_source.py`
|
||
- `modify app/models/__init__.py`
|
||
- `modify app/models/energy.py`
|
||
- `modify alembic_app/env.py`
|
||
- `create alembic_app/versions/20260822_15_meter_sources.py`
|
||
- `modify scripts/app_db_adopt.py`
|
||
- `create tests/test_meter_sources.py`
|
||
- `modify tests/test_energy_models.py`
|
||
|
||
**Steps**
|
||
1. 新建 `MeterSource`、`MeterSourceChannel`、`MeterSourceBinding`,字段和唯一键严格按 §5;
|
||
source `config` 为 JSON,UUID 是外部稳定身份。
|
||
2. binding 使用整型 FK 关联 Meter/channel,时间统一存 UTC;用服务前可复用的 model helper 表达
|
||
半开区间相交,数据库 FK 使用 `RESTRICT`,relationship 不配置 delete cascade。
|
||
3. 给 `EnergyCostPeriod` 增加 nullable `source_binding_id` FK 和 relationship;迁移此时只加列,
|
||
不回填 DSMR 历史。
|
||
4. 在 `alembic_app/env.py` 注册新模型模块,并把 `APP_BASELINE_REVISION` 同步到 revision 15;
|
||
不改变 `scripts/run_migrations.py` 的单库行为。
|
||
5. migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何
|
||
外部数据库文件。
|
||
6. 测试 Alembic 空库升级、revision 14→15、重复运行、baseline=head、模型默认值、唯一约束、
|
||
FK RESTRICT 和无 delete cascade;数据库只使用 `tmp_path` 临时文件。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不迁移 `dsmr_reading`,不读取旧 config,不实现 API、worker 或 source profile。
|
||
- 不把 ModbusDevice 改成 MeterSource。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 三张新表、UUID/唯一键/FK/索引与 §5 一致,`energy_cost_period` 新列可空。
|
||
- [ ] 删除被 channel/binding/成本引用的行会被数据库拒绝,不级联丢历史。
|
||
- [ ] 空库与从 revision 14 升级都到达 head,`APP_BASELINE_REVISION` 等于唯一 head,测试覆盖约束。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- migration 是否完全无 drop/truncate/用户文件操作;SQLite 重建后 FK 是否仍启用。
|
||
- `alembic_app/env.py` 是否已注册新模型,baseline 常量是否与 revision 15 完全一致。
|
||
- 时间窗是否统一为 `[started_at, ended_at)`,没有把 source 生命周期混进 Meter epoch。
|
||
|
||
### M8-T02 — Source profile registry 与 binding service
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T01
|
||
- **Context**: 在开放 API 前集中 kind config、commodity/unit 兼容与时间线规则,避免各入口各写一套。
|
||
|
||
**Files**
|
||
- `create app/integrations/meter_sources.py`
|
||
- `create app/services/meter_sources.py`
|
||
- `create tests/test_meter_source_services.py`
|
||
|
||
**Steps**
|
||
1. 注册 `dsmr_mqtt`、`warmtelink_serial` 两个 profile;声明 config 字段、默认值、secret 标记、
|
||
capabilities 和允许 channel 单位。serial 默认 `115200/7/N/1`,path 必须以 `/dev/` 开头。
|
||
2. 实现 config validate/sanitize/merge:未知字段报错,secret 响应遮罩,PATCH 遮罩值表示保持原值。
|
||
3. 实现 source CRUD、channel discovery upsert、binding list/create/update/close 服务;所有写操作由
|
||
调用方持有事务,不在 helper 中悄悄 commit。
|
||
4. binding 校验:Meter 与 channel 存在、commodity/unit 映射为 electricity→kWh、heating→GJ、
|
||
hot_water→m³;同 Meter 或同 channel 的有效区间不得重叠,边界相等允许。
|
||
5. source delete 仅在无 channel、binding、readings 依赖时允许,否则抛出可映射为 `409` 的领域错误。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不连接 MQTT/serial,不自动创建 Meter,不实现 FastAPI route。
|
||
- 不支持 Pre-M8 未确认的 gas、温度、功率或 flow channel。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 两个 profile 的验证/默认值/secret 遮罩有表驱动测试。
|
||
- [ ] overlap、边界相等、unit 不兼容、删除受限、channel upsert 幂等均有测试。
|
||
- [ ] 服务不会自动绑定建议 commodity,也不会自行 commit。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- `warmtelink_serial` 是否没有 write capability;source config 是否可能泄露 secret。
|
||
- 是否同时检查“同 Meter”和“同 channel”的重叠,而非只检查其中一侧。
|
||
|
||
### M8-T03 — 将 DSMR 历史迁入 Source / Binding [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T02
|
||
- **Context**: 先把既有电力链路安全迁到统一模型,之后才能让 runtime 和计费真正按 source 工作。
|
||
|
||
**Files**
|
||
- `modify app/models/energy.py`
|
||
- `create alembic_app/versions/20260822_16_dsmr_source_adoption.py`
|
||
- `modify scripts/app_db_adopt.py`
|
||
- `create tests/test_dsmr_source_migration.py`
|
||
- `modify tests/test_energy_models.py`
|
||
- `modify tests/test_meter_sources.py`
|
||
|
||
**Steps**
|
||
1. 把 ORM `DsmrReading.source_id` 重命名为 nullable `telegram_id`,新增 non-null
|
||
`meter_source_id` FK,唯一约束改为 `(meter_source_id, recorded_at)`;保留 JSON payload。
|
||
2. migration 按 §5 DSMR 回填算法创建迁移 source/channel、回填所有读数和 electricity Meter
|
||
bindings,再回填可唯一解析的 `energy_cost_period.source_binding_id`。
|
||
3. 不能唯一解析的旧成本行不删不猜:保留原金额与 pricing snapshot,设置 `degraded=true` 并让
|
||
binding 保持 nullable;migration 注释和测试必须明确这是历史边界,而非当前 binding。
|
||
4. 每一阶段在 migration 内核对 source/readings/cost 行数与 orphan FK;不一致立即抛错回滚。
|
||
5. 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。
|
||
6. 把 `APP_BASELINE_REVISION` 同步到 revision 16;所有升级 fixture 仅在 `tmp_path` 中构造,不读取
|
||
真实 app DB 或 volume。
|
||
7. 把 T01 自身的 revision 14→15 schema 对账测试固定升级到明确 revision 15,不以可继续前进的
|
||
`head` 作为 T01 终点;T03 的新 fixture 单独负责 revision 14/15→16 历史回填对账。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不删除旧 DSMR `app_config` 行,不改变 MQTT subscription,不改 API 响应。
|
||
- 不将 JSON payload 拆列或回写历史价格。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 迁移前后 DSMR 与成本行数不减少,orphan FK 为 0,正常可解析周期有 binding。
|
||
- [ ] 两个 source 可在同一 timestamp 各存一条 DSMR reading;同 source 重复 timestamp 被拒绝。
|
||
- [ ] `telegram_id` 不参与幂等唯一键,旧 telegram id 完整保留。
|
||
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 16 head,升级重复运行幂等。
|
||
- [ ] T01 schema-only fixture 固定停在 revision 15,T03 fixture 到 revision 16,二者职责不随 head 漂移。
|
||
- [ ] 历史升级 fixture、空库升级、`pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。
|
||
- baseline 常量是否随 revision 16 同步,fixture 是否完全隔离于真实生产路径。
|
||
- 前序 migration 测试是否使用明确 revision 边界,而非把历史阶段误写为永久 `head`。
|
||
- 是否存在“把所有历史强绑当前 Meter/source”的静默错误或任何 destructive cleanup。
|
||
|
||
### M8-T04 — DSMR runtime 改为多 Source 配置 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T03
|
||
- **Context**: schema 回填后,DSMR subscription/ingest 应以数据库 source 为单一运行时配置来源。
|
||
|
||
**Files**
|
||
- `modify app/services/dsmr_ingest.py`
|
||
- `modify app/integrations/mqtt.py`
|
||
- `modify app/services/config_page.py`
|
||
- `modify app/main.py`
|
||
- `modify tests/test_dsmr_ingest.py`
|
||
- `modify tests/test_dsmr_subscription_apply.py`
|
||
- `modify tests/test_api_config.py`
|
||
- `modify tests/test_mqtt_subscribe.py`
|
||
|
||
**Steps**
|
||
1. 用 `meter_source` 中所有 enabled `dsmr_mqtt` sources reconcile subscriptions;handler 捕获稳定
|
||
source id/config snapshot,把 `meter_source_id` 写入 reading。
|
||
2. topic 或 config 变更时只替换受影响 source 的订阅;多个 source 可并存,禁用/删除会 unsubscribe;
|
||
相同 topic 如无法由 MQTT manager 区分 source,应在 profile/service 层拒绝重复 enabled topic。
|
||
3. DSMR tariff 状态按 source 隔离,并提供按当前 electricity binding 解析 tariff 的 helper;不能继续用
|
||
单个模块全局值代表所有 source。
|
||
4. startup/config-save 调用新的 reconcile;现有 flat DSMR config 从 config page/runtime 中移除读取,
|
||
但数据库 KV 不删除。
|
||
5. 更新网络线程异常吞吐、短 session、timestamp downsample 和重复投递测试。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不改 cost 算法、HTTP schema 或 WarmteLink parser。
|
||
- 不改变 DSMR payload 内容和既有 sample interval 语义。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 两个 source 的订阅、读数、tariff 和唯一键相互隔离;单 source 行为保持兼容。
|
||
- [ ] config 修改无需重启即可 reconcile,disable 后不再写入。
|
||
- [ ] `app_config` 旧 DSMR KV 保留但运行时不读,config API 不再展示旧表单。
|
||
- [ ] 网络 handler 始终短 session 且异常不杀死 MQTT loop。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 检查所有遗留模块全局 DSMR topic/tariff 状态,确保不会串 source。
|
||
- 配置切换是否可能造成双订阅、旧 handler 继续写入或 session 泄漏。
|
||
|
||
### M8-T05 — 电费计算绑定 Source Binding [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T04
|
||
- **Context**: DSMR 已多 source 后,电力 period 必须只在同一 Meter/binding 累计域内计算。
|
||
|
||
**Files**
|
||
- `modify app/services/energy_cost.py`
|
||
- `modify app/models/energy.py`
|
||
- `modify app/schemas/energy.py`
|
||
- `modify tests/test_energy_cost.py`
|
||
- `modify tests/test_api_energy.py`
|
||
- `modify tests/test_energy_expose.py`
|
||
- `modify openapi/openapi.json`
|
||
- `modify openapi/openapi.yaml`
|
||
- `modify frontend/src/api/schema.d.ts`
|
||
|
||
**Steps**
|
||
1. 为 period 两个边界按时间解析唯一 electricity Meter、binding 和 DSMR source;查询 reading 时加入
|
||
`meter_source_id`,结果写 `EnergyCostPeriod.source_binding_id`。
|
||
2. 两端 Meter/binding/source 不同、缺失、overlap 歧义或读数 freshness 超限时生成 degraded 行,
|
||
不跨累计域相减。
|
||
3. recompute 与 summary 保持现有 API 形状和 immutable/audit 语义;响应可追加 nullable binding identity,
|
||
不删除旧字段。
|
||
4. 用现有 golden tests 锁住单 source 正常 period 的 quantity、cost、rounding、fixed fee 与 summary;
|
||
增加 source switch、binding boundary、missing/ambiguous binding 测试。
|
||
5. 因响应 schema 增加 nullable binding identity,重导 OpenAPI,并在 `frontend/` 运行
|
||
`npm run codegen`;两个生成物必须随本卡提交,禁止手改 `schema.d.ts`。
|
||
6. 固定现有 expose fixed-fee/credit 回归中的 `local_now()` 到明确已越过 01:05 结算点的时刻;测试
|
||
不得依赖执行当天恰好处于 UTC 00:00~01:05 之外,也不得为消除红灯改变生产结算语义。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不实现 thermal cost,不修改合同 scope,不改变正常电价公式。
|
||
- 不因旧行 binding 为空而删除或重算全部历史。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 正常新周期总能审计到唯一 binding,跨域周期明确 degraded。
|
||
- [ ] 既有单 DSMR source 的所有非降级数字逐项不变。
|
||
- [ ] recompute 幂等,不能把 source A 起点和 source B 终点相减。
|
||
- [ ] fixed-fee/credit golden tests 在 01:05 前后任意实际运行时刻均确定性通过,生产结算点不变。
|
||
- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿且生成物已提交。
|
||
|
||
**Reviewer checklist**
|
||
- 重点构造 Meter 相同但 source 切换、source 相同但 Meter 换表两种边界。
|
||
- 检查 Decimal/rounding 和本地日 fixed-fee 逻辑是否被无意改变。
|
||
- 检查 expose golden tests 是否显式固定业务时钟,而不是等待 wall clock 或放宽 01:05 断言。
|
||
- 独立重导 OpenAPI 与 codegen,确认 schema 生成物同步且不是手改。
|
||
|
||
### M8-T06 — Source / Channel / Binding HTTP 契约 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T05
|
||
- **Context**: 在基础服务与 DSMR 兼容稳定后,提供 §6 的管理接口及原子 Meter+binding 入口。
|
||
|
||
**Files**
|
||
- `create app/schemas/meter_source.py`
|
||
- `create app/api/routes/api/meter_sources.py`
|
||
- `modify app/schemas/meter.py`
|
||
- `modify app/api/routes/api/meters.py`
|
||
- `modify app/main.py`
|
||
- `create tests/test_meter_source_api.py`
|
||
- `modify tests/test_api_meters.py`
|
||
- `modify openapi/openapi.json`
|
||
- `modify openapi/openapi.yaml`
|
||
- `modify frontend/src/api/schema.d.ts`
|
||
|
||
**Steps**
|
||
1. 实现 source profiles、source CRUD、channel list、commodity catalog 和 binding list/create/patch;
|
||
discover 在非 serial 实现上返回 capability-aware 结果,WarmteLink 真连接留给 T11。
|
||
2. 映射领域错误:不存在 `404`,validation/overlap `422`,有依赖删除 `409`;响应使用 source/channel
|
||
UUID,绝不暴露 DB secret 或原始 equipment id。
|
||
3. 扩展 Meter POST 可选 `source_channel_uuid`;Meter 声明和 binding 创建在一个事务,任一步失败
|
||
全部回滚。Meter response/list 暴露 bindings 或稳定的 binding summary。
|
||
4. 写 auth/CSRF、secret mask、原子回滚、时间本地化、兼容 DSMR latest 的 API 测试。
|
||
5. 运行 OpenAPI 导出,再在 `frontend/` 运行 `npm run codegen`,提交两个生成物。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不实现 WarmteLink history/worker,不改前端页面。
|
||
- 不允许 source DELETE cascade,也不为 suggestion 自动建 Meter。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] §6 中截至 bindings 的路由、状态码、auth/CSRF 和响应字段均有 API 测试。
|
||
- [ ] 原子 Meter+binding 在失败场景不留下半条 Meter;secret/identifier 不出现在响应/OpenAPI。
|
||
- [ ] 既有 `GET /api/energy/dsmr/latest` 合同测试继续通过。
|
||
- [ ] `pytest`、`ruff check .`、OpenAPI diff、`npm run codegen` diff 全绿且生成物已提交。
|
||
|
||
**Reviewer checklist**
|
||
- 独立重导 OpenAPI 和 codegen,确认 `schema.d.ts` 不是手改。
|
||
- 检查 DELETE 依赖、PATCH secret mask、时区半开区间与事务边界。
|
||
|
||
### M8-T07 — 提取可复用 P1 parser(零行为变化)
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T02
|
||
- **Context**: Pre-M8 parser 已有真机 fixture 证据;先无损提取,避免 worker 与 probe 维护两份协议逻辑。
|
||
|
||
**Files**
|
||
- `create app/integrations/p1.py`
|
||
- `modify scripts/p1_probe.py`
|
||
- `modify tests/test_p1_probe.py`
|
||
- `create tests/test_p1_parser.py`
|
||
|
||
**Steps**
|
||
1. 把变长 frame framing、文本解码、字段/channel 解析、CRC 状态和脱敏 fingerprint 提取为无 I/O 的
|
||
typed parser API;parser 输入 bytes、输出 frame/channel DTO 或明确错误。
|
||
2. 保持 Pre-M8 对 `/` 缺失、四位 footer、`valid/invalid/unverifiable` 判定逐 fixture 不变;parser
|
||
本身不做双帧 ingestion 接纳决策。
|
||
3. equipment identifier 仅在函数内用于 SHA-256,返回值/日志/异常/repr 不包含原值;原始 bytes 不
|
||
写文件或数据库。
|
||
4. `p1_probe.py` 改为调用共享 parser,CLI 参数、stdout 脱敏格式、退出码和 read-only 约束不变。
|
||
5. 以现有真机 fixture 加截断、粘包、变长、坏字符、invalid CRC、unverifiable 测试。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不打开后台线程、不连接数据库、不改变 probe 的串口参数与用户接口。
|
||
- 不把 `unverifiable` 提升为 `valid`,不推断不存在的 measurements。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] probe 的既有 contract/golden tests 全部不变通过,共享 parser 无串口/DB side effect。
|
||
- [ ] frame 长度不固定,完整/截断/粘包均不会错配 channel。
|
||
- [ ] 原始 equipment id 和 telegram bytes 不出现在 DTO、日志、异常快照或测试 snapshot。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 对比提取前后的 CLI 行为与质量判定,警惕“顺手修正”真机已确认的异常 framing。
|
||
- 搜索日志与 dataclass repr,确认没有隐私泄漏或 serial `.write()`。
|
||
|
||
### M8-T08 — 建立 WarmteLink 标量读数表
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T07
|
||
- **Context**: WarmteLink 与 DSMR payload 结构不同,建立可精确计算的 Decimal 标量历史表。
|
||
|
||
**Files**
|
||
- `modify app/models/meter_source.py`
|
||
- `create alembic_app/versions/20260822_17_warmtelink_readings.py`
|
||
- `modify scripts/app_db_adopt.py`
|
||
- `create tests/test_warmtelink_models.py`
|
||
|
||
**Steps**
|
||
1. 新建 `WarmteLinkReading`:channel FK、`recorded_at`、`received_at`、定点 Decimal `value`、unit、
|
||
quality、equipment fingerprint;`(channel_id, recorded_at)` 唯一并索引时间。
|
||
2. quality 只允许 parser 的三个持久状态;数据库不存 raw telegram、raw identifier 或派生的
|
||
“estimated valid”。
|
||
3. FK 使用 `RESTRICT`,不配置 orphan/delete cascade;选择足以容纳长期累计量与 0.001 精度的
|
||
Numeric precision/scale,并测试 Decimal round-trip。
|
||
4. migration 覆盖空库/升级、唯一键、FK 和索引;downgrade 不触碰任何外部文件。
|
||
5. 把 `APP_BASELINE_REVISION` 同步到 revision 17;所有 migration 测试只使用 `tmp_path` 临时库。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不实现接纳、sampling、worker、API 或成本。
|
||
- 不把 DSMR JSON 搬进该表,也不新增通用 value/unit 万能表。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] schema 与 §5 一致,Decimal 无 float 转换,source/channel 删除受限。
|
||
- [ ] 同 channel/timestamp 幂等隔离,不同 channel 同时刻允许。
|
||
- [ ] 模型和 migration 中不存在 raw telegram/equipment identifier 列。
|
||
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 17 head。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- Numeric precision 是否覆盖合理长期累计值;timezone 与唯一键是否使用设备时间。
|
||
- migration 是否数据安全、约束命名稳定且可在 SQLite 正确执行。
|
||
- baseline 常量是否随 revision 17 同步,测试是否未打开任何真实数据库。
|
||
|
||
### M8-T09 — WarmteLink 质量接纳、发现与分钟采样 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T08
|
||
- **Context**: 把 parser 输出变成可审计的 latest/history;这里锁住最关键的 unverifiable 接纳策略。
|
||
|
||
**Files**
|
||
- `create app/services/warmtelink_ingest.py`
|
||
- `create tests/test_warmtelink_ingest.py`
|
||
|
||
**Steps**
|
||
1. 实现每 source 的接纳状态机:`valid` 单帧接纳、`invalid` 拒绝、`unverifiable` 只有连续两帧满足
|
||
时间递增、channel identity/metadata 完全相同、各累计值不下降才接纳第二帧;确认后保留第二帧
|
||
作为 sliding previous,后续每个连续一致 frame 都可接纳,不要求再成对跳帧。
|
||
2. gap、乱序、metadata 变化、channel 缺失/新增或累计下降都会清空候选并留下诊断;不自动解释为
|
||
reset/wrap,不从另一 source 借候选帧。
|
||
3. 接纳时按 source + `channel_key` 幂等 upsert channel,写 suggestion/unit/device_type/fingerprint,
|
||
更新 source/channel latest、last_seen/status;不自动创建 Meter/binding。
|
||
4. 每个接纳 frame 更新 latest;历史以每个设备 timestamp UTC minute bucket 的**首个接纳 frame**
|
||
为样本,每 channel 每分钟最多一条,重投递/重启先查 bucket 并保持幂等。首次样本前不 backfill。
|
||
5. 所有 entry point 捕获单帧解析/DB 错误并标 source error;事务短、回滚后下一帧仍可恢复。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不打开 serial,不实现 HTTP/HA/cost,不接受 invalid frame。
|
||
- 不保存被拒绝 frame 的 raw bytes 或 identifier,不自动绑定建议 commodity。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] valid、invalid 和所有 unverifiable 连续性分支有确定性 fake-clock 测试。
|
||
- [ ] 10 秒 latest 与每分钟 history 分离,重复投递/进程重启不产生重复历史。
|
||
- [ ] channel 1/2 映射保持真机 metadata,质量仍是 `unverifiable`。
|
||
- [ ] 无 raw data/identifier 泄漏;`pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 双帧确认是否错误接纳第一帧、跨 source 串状态,或把 `unverifiable` 改成 `valid`。
|
||
- 累计下降/metadata drift 后必须重新积累两帧,数据库异常不能留下半更新 latest。
|
||
|
||
### M8-T10 — Serial worker manager 与应用生命周期 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T09, M8-T06
|
||
- **Context**: 以可测试的 manager 为每个 enabled WarmteLink source 持有一个只读 worker。
|
||
|
||
**Files**
|
||
- `create app/services/warmtelink_worker.py`
|
||
- `modify app/main.py`
|
||
- `modify app/api/routes/api/meter_sources.py`
|
||
- `create tests/test_warmtelink_worker.py`
|
||
- `modify tests/test_meter_source_api.py`
|
||
|
||
**Steps**
|
||
1. 定义 serial factory/clock/stop-event 协议,生产 factory 从 source profile 创建 `115200 7N1`
|
||
read-only 连接;业务代码只依赖 `.read()`/context close,不暴露 `.write()`。
|
||
2. manager `reconcile()` 对所有 enabled `warmtelink_serial` source 做 start/update/stop,保证每 source
|
||
至多一个 worker;config PATCH/enable/disable 后提交事务,再触发 reconcile。
|
||
3. worker 用共享 framer/parser/ingest,DB 写入每批新开短 session;断连/打开失败按
|
||
`1,2,4,8,16,32,60,60...s` 可中断退避,成功读到完整 frame 后重置退避。
|
||
4. FastAPI lifespan 在 DB ready 后启动 manager,在 shutdown 先 set stop、关闭 serial、join 有界时间;
|
||
manager/worker 异常只更新 source status,不阻止其他 source。
|
||
5. fake serial 测试启动、重复 reconcile、热更新、disable、断线恢复、退避上限、shutdown 和无 write。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不访问真实 `/dev`、不实现 Docker mapping、API history 或成本。
|
||
- 不在 worker 长持 SQLAlchemy session,不用 daemon thread 掩盖关闭失败。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] N 个 enabled serial sources 恰有 N 个 worker,重复 reconcile 无重复线程。
|
||
- [ ] disable/config change/shutdown 会关闭 fd 并结束旧 worker;退避可由 stop 立即打断。
|
||
- [ ] fake serial 明确断言从未调用 write;一个 source 崩溃不影响其他 source/app。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 检查启动/提交/reconcile 竞态、join timeout、session/fd 泄漏与敏感 path/identifier 日志。
|
||
- 确认失败循环不会 busy-spin,成功后退避确实 reset。
|
||
|
||
### M8-T11 — WarmteLink discover、latest 与 history API
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T10
|
||
- **Context**: worker 链路稳定后,把一次发现、状态和规范化历史接到已建立的 source API。
|
||
|
||
**Files**
|
||
- `modify app/schemas/meter_source.py`
|
||
- `modify app/api/routes/api/meter_sources.py`
|
||
- `modify app/services/warmtelink_worker.py`
|
||
- `modify tests/test_meter_source_api.py`
|
||
- `modify openapi/openapi.json`
|
||
- `modify openapi/openapi.yaml`
|
||
- `modify frontend/src/api/schema.d.ts`
|
||
|
||
**Steps**
|
||
1. `POST .../discover` 对 WarmteLink 发起有界的一次 read/discovery request;已有常驻 worker 时通过
|
||
manager 协调,不并发抢同一 serial fd。响应返回 pending/completed/error 与脱敏 channel metadata。
|
||
2. channel list 加 latest value/time/quality/source status/binding summary;history 支持 `from/to/limit`
|
||
和稳定时间升序,参数范围非法为 `422`。
|
||
3. 保证普通 HTTP 请求不阻塞等待无期限串口;timeout 后 source 可继续后台重连。
|
||
4. API 测试覆盖无数据、unverifiable、分页、source/channel 归属校验、auth、timeout、secret/privacy。
|
||
5. 重导 OpenAPI 并 codegen。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不自动建 Meter/binding,不做前端页面、计费或 HA。
|
||
- 不在响应中返回 raw frame、raw identifier 或未经接纳的候选帧。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] §6 discover/channels/readings 合同完整,有界且不会双开同一 serial device。
|
||
- [ ] history 只返回持久化分钟样本,latest 可反映约 10 秒更新与原始质量。
|
||
- [ ] channel/source UUID 归属错误不泄露别的 source 数据。
|
||
- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 独立验证 timeout/cancel 后 worker 仍健康,API 无同步 I/O 卡死 event loop 风险。
|
||
- 独立重导两个契约生成物并搜索隐私字段。
|
||
|
||
### M8-T12 — 合同 Scope 与按 Scope 激活 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T08
|
||
- **Context**: electricity 与 thermal 必须能各有一份 active 合同,同时保持旧客户端默认看 electricity。
|
||
|
||
**Files**
|
||
- `modify app/models/energy.py`
|
||
- `modify app/services/contracts.py`
|
||
- `modify app/schemas/energy_contract.py`
|
||
- `modify app/api/routes/api/energy_contracts.py`
|
||
- `create alembic_app/versions/20260822_18_contract_scopes.py`
|
||
- `modify scripts/app_db_adopt.py`
|
||
- `modify tests/test_api_energy_contracts.py`
|
||
- `modify tests/test_energy_models.py`
|
||
- `modify tests/test_warmtelink_models.py`
|
||
- `modify openapi/openapi.json`
|
||
- `modify openapi/openapi.yaml`
|
||
- `modify frontend/src/api/schema.d.ts`
|
||
|
||
**Steps**
|
||
1. 给 `EnergyContract` 增加 non-null `scope`,旧行确定性回填 `electricity`;service registry 声明
|
||
manual/tibber→electricity,后续 district_heating→thermal,客户端不能伪造不匹配 scope。
|
||
2. active 互斥从全局改为同 scope;create/update/activate 都在事务内先锁定/停用同 scope 其他合同,
|
||
不影响另一 scope。
|
||
3. list/detail/create/update responses 增加 scope;请求可省略 scope 并由 kind 推导。旧请求与无 query
|
||
list 默认兼容 electricity 行为,新增可选 scope filter。
|
||
4. migration 对账合同/version/cost 行数,不改 versions/pricing values,不删除任何合同。
|
||
5. 覆盖两 scope 同时 active、同 scope 互斥、kind/scope mismatch、旧 payload 与升级 fixture;重导契约。
|
||
6. 把 `APP_BASELINE_REVISION` 同步到 revision 18;升级 fixture 仅使用隔离临时库。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不新增真实 thermal profile/费率,不改 electricity 成本公式。
|
||
- 不把一份合同同时归两个 scope,也不重写历史 version values。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 旧合同全部回填 electricity 且行数/version FK 不变。
|
||
- [ ] service 对预置的 electricity/thermal rows 可同时保持 active;每 scope 最多一份 active。
|
||
- [ ] 旧 manual/tibber API 请求仍成功并得到 electricity scope。
|
||
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 18 head。
|
||
- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 并发/事务失败是否可能留下同 scope 双 active 或误停用另一 scope。
|
||
- baseline 常量是否随 revision 18 同步,历史对账是否在临时副本内执行。
|
||
- migration 是否在 active 旧数据上安全,API 默认值是否真正向后兼容。
|
||
|
||
### M8-T13 — District-heating 定价 Profile
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T12
|
||
- **Context**: 用 profile 固定热力字段、单位和验证,不把用户的实际 Vattenfall 金额写进仓库。
|
||
|
||
**Files**
|
||
- `create app/integrations/pricing/profiles/district_heating.yaml`
|
||
- `modify app/integrations/pricing/profiles.py`
|
||
- `modify app/services/contracts.py`
|
||
- `modify tests/test_pricing_profiles.py`
|
||
- `modify tests/test_api_energy_contracts.py`
|
||
|
||
**Steps**
|
||
1. 新建 `district_heating` kind,scope=`thermal`;按 §3 D11 定义 4 个 variable 与 5 个 standing
|
||
Decimal 字段、EUR 单位、label/help、最小值 `0`,standing defaults 全为 `0`。
|
||
2. profile loader 明确保留 Decimal/字符串精度,拒绝 float、未知字段、负值、缺失必填 variable;
|
||
version snapshot 包含完整规范化 values。
|
||
3. 合同 create/new-version 对该 profile 验证,既有 manual/tibber profile 和 Tibber fetch 选择逻辑不变。
|
||
4. 测试完整/最小 payload、默认 standing、未知/负数/float、版本时间线和 scope;断言 profile 中没有
|
||
任何非零真实 tariff default。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不抓取网站、不自动更新价格、不硬编码用户合同金额。
|
||
- 不实现热力 cost、API prices 或前端表单。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 字段名/单位/默认值与 D11 完全一致,金额全程 Decimal-safe。
|
||
- [ ] 任意非零价格都只能来自用户提交的 contract version。
|
||
- [ ] manual/tibber 行为与 tests 不变,district_heating 只能属于 thermal。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- YAML parser 是否可能把小数悄悄变成 float;help 文案是否把参考费率误写成默认费率。
|
||
- 旧 profile registry/API 枚举是否被破坏。
|
||
|
||
### M8-T14 — 建立通用 Meter Cost Period 账本
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T13
|
||
- **Context**: 热力成本不能硬塞进 electricity 专用账本;先建立按 commodity 审计的独立表。
|
||
|
||
**Files**
|
||
- `modify app/models/energy.py`
|
||
- `create alembic_app/versions/20260822_19_meter_cost_periods.py`
|
||
- `modify scripts/app_db_adopt.py`
|
||
- `create tests/test_meter_cost_models.py`
|
||
- `modify tests/test_energy_models.py`
|
||
|
||
**Steps**
|
||
1. 新建 `MeterCostPeriod`,字段按 §5:commodity、period start/end、nullable Meter/binding、nullable
|
||
contract version(降级时允许缺失)、Decimal quantity/cost、cost/pricing JSON、quality/degraded reason、
|
||
created/updated timestamps。
|
||
2. 唯一键为 `(commodity, period_start)`;Meter/binding/version 均 `RESTRICT`,无 delete cascade;索引
|
||
commodity+time 和 binding。
|
||
3. quantity/cost 使用定点 Numeric,ORM 不经 float;JSON 中的金额/数量统一序列化为十进制字符串。
|
||
4. migration 只创建新表,不迁移/删除 `energy_cost_period`;测试空库/升级、Decimal round-trip、
|
||
唯一键、nullable degraded 和 FK RESTRICT。
|
||
5. 把 `APP_BASELINE_REVISION` 同步到 revision 19;升级/降级测试只使用 `tmp_path` 临时库。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不计算或回填热力成本,不泛化/删除现有 electricity 表。
|
||
- 不把 contract-level fixed fee 复制进每个 commodity row。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 表结构可表达正常和降级 period,`(commodity, period_start)` 幂等。
|
||
- [ ] Decimal 与 JSON snapshot 可精确 round-trip,无 binary float。
|
||
- [ ] 现有 electricity 账本及行数完全不受 migration 影响。
|
||
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 19 head。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 降级 nullable 不能放松正常写入的 service invariant;FK 删除策略必须保护审计。
|
||
- baseline 常量是否随 revision 19 同步,测试路径是否与生产 DB/volume 完全隔离。
|
||
- period timezone/半开区间、唯一键和 Numeric scale 是否足以重算。
|
||
|
||
### M8-T15 — Thermal 15 分钟成本引擎与调度 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T14
|
||
- **Context**: 基于两个独立累计 Meter 生成可审计 variable 账本,并在 summary 层只计一次固定费。
|
||
|
||
**Files**
|
||
- `create app/services/meter_cost.py`
|
||
- `modify app/main.py`
|
||
- `create tests/test_meter_cost.py`
|
||
- `modify tests/test_energy_cost.py`
|
||
|
||
**Steps**
|
||
1. 实现 closed 15-minute period 计算:分别解析 heating/hot_water Meter、binding、channel 与两端
|
||
WarmteLinkReading;要求同 Meter/binding、quality 可接纳、两边界距目标不超过 `120s`。
|
||
2. 用 Decimal 实现 D12 公式和 breakdown;负 delta、heating `>0.1 GJ`、hot_water `>1 m³`、
|
||
跨 epoch/binding、读数/合同缺失或质量 invalid 都写幂等 degraded row,不猜值。
|
||
3. recompute range 显式覆盖同一唯一键,正常 scheduler 不覆写已完成正常行;contract version 与完整
|
||
pricing snapshot 按 period 生效时间保存。
|
||
4. summary 聚合 variable breakdown;standing fees 按 thermal contract version 的本地自然日片段
|
||
`annual / 365` 计提,在 `01:05` 后才包含当日,且 contract-level 只计一次。
|
||
5. 在现有每分钟成本 job 中调用 thermal compute;一个 scope 失败不得阻断另一 scope,随后 HA publish
|
||
仍 best-effort。
|
||
6. 测试 DST、本地日、跨合同 version、换表/source switch、双 commodity 只有一个、limits、freshness、
|
||
idempotency、recompute 与 existing electricity regression。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不把固定费摊进两个 period,不将 hot-water m³ 换算为 GJ。
|
||
- 不改变现有 electricity cost 表、公式、01:05 语义或 rounding。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] heating/hot_water 正常 period 数值与 breakdown 精确符合 D12。
|
||
- [ ] 所有边界/质量/防呆场景明确 degraded,绝不跨累计域相减。
|
||
- [ ] fixed summary 按 contract-level 每日只计一次,跨 version/DST 可复现。
|
||
- [ ] thermal 失败不影响 electricity;既有 electricity golden tests 数字不变。
|
||
- [ ] `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 重点审 Decimal、period 边界最近读数选择、120s 是双边独立判断还是被错误放宽。
|
||
- 用同时具备 heating/hot_water 的一天证明 fixed 没有 double count,并复核 01:05/DST。
|
||
|
||
### M8-T16 — Thermal Prices / Costs / Summary / Recompute API
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T15
|
||
- **Context**: 对外提供 scope-aware 价格和新的热力账本,不破坏既有 electricity 路由响应。
|
||
|
||
**Files**
|
||
- `create app/schemas/meter_cost.py`
|
||
- `create app/api/routes/api/meter_costs.py`
|
||
- `modify app/schemas/energy.py`
|
||
- `modify app/api/routes/api/energy.py`
|
||
- `modify app/services/meter_cost.py`
|
||
- `modify app/main.py`
|
||
- `create tests/test_meter_cost_api.py`
|
||
- `modify tests/test_api_energy.py`
|
||
- `modify openapi/openapi.json`
|
||
- `modify openapi/openapi.yaml`
|
||
- `modify frontend/src/api/schema.d.ts`
|
||
|
||
**Steps**
|
||
1. `GET /api/energy/prices` 增加 scope 参数:省略时保持 electricity 既有响应;thermal 返回当前
|
||
district_heating contract/version 的规范化 values 与有效期,不伪造 15 分钟市场价格。
|
||
2. 实现 §6 三个 meter-cost routes;list 支持时间窗/commodity/pagination,summary 返回 heating、
|
||
hot_water 三分项、variable subtotal、fixed breakdown、all-in 与 degraded counts。
|
||
3. recompute 写入要求 CSRF,校验有界时间窗,scope 目前只接受 thermal;返回 processed/normal/
|
||
degraded counts,重试幂等。
|
||
4. Decimal 在 JSON 中遵循既有金额 schema 约定且无 float 漂移;API 测试 auth/CSRF、空数据、
|
||
degraded、跨 version、默认 electricity compatibility 和非法 scope。
|
||
5. 注册 router,重导 OpenAPI/codegen。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不做前端、HA 或新计算公式;不移除旧 electricity costs endpoints。
|
||
- thermal prices 不调用 Tibber/Vattenfall 网络,也不返回硬编码 tariff。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] §6 thermal prices/costs/summary/recompute 合同、状态码和 Decimal 序列化有测试。
|
||
- [ ] 无 scope 的现有 prices/cost 客户端保持原响应语义。
|
||
- [ ] recompute 受 session+CSRF 保护,非法/过大窗口不执行部分写入。
|
||
- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 独立比较变更前 electricity response fixture,确认兼容不是只靠可选字段猜测。
|
||
- 重算事务/分页边界/金额 JSON 精度,以及 thermal 无外部抓价路径。
|
||
|
||
### M8-T17 — Home Assistant Source / Meter / Thermal 实体 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T11, M8-T16
|
||
- **Context**: 在完整采集和成本链上扩展现有 expose provider,保持默认关闭与稳定 identity。
|
||
|
||
**Files**
|
||
- `modify app/integrations/expose.py`
|
||
- `modify app/services/ha_discovery.py`
|
||
- `modify tests/test_energy_expose.py`
|
||
- `modify tests/test_ha_discovery.py`
|
||
- `modify tests/test_api_expose.py`
|
||
|
||
**Steps**
|
||
1. 注册 source online entities;为当前 Meter 注册 total/today(electricity 延续既有行为,heating GJ、
|
||
hot_water m³ 使用正确 device/state class)。latest 太旧或 source offline 时 availability 不可用。
|
||
2. 注册 thermal cost total/today:heating、hot_water_heating、water、water_tax、fixed、all_in;金额单位
|
||
使用 contract currency,provider 只读 T15 summary,不重算另一套公式。
|
||
3. 单 Meter unique_id/node_id 以 Meter UUID;thermal 组合成本以当前 heating/hot_water Meter UUID
|
||
排序后确定性组合。任一换表生成新 identity,并 best-effort 清理旧 retained discovery。
|
||
4. 所有新 catalog toggle 默认 false;已有 toggle 和 DSMR Reader 直接 HA 暴露不受影响。
|
||
5. 测试 entity metadata、identity 稳定/换表、online/offline、today reset、无合同/单 commodity、默认
|
||
disabled、publish/cleanup。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不自动启用任何 HA entity,不改变 MQTT broker/discovery 基础配置。
|
||
- 不暴露 raw channel identifier、source secret 或 unverifiable 候选帧。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] D13 所列实体均出现在 catalog 且默认 false,HA metadata/unit/state class 正确。
|
||
- [ ] identity 对普通重启稳定,对 heating 或 hot_water 换表确定性变化。
|
||
- [ ] source stale/offline 反映 availability,不发布伪造的零值。
|
||
- [ ] 既有 expose/DSMR/electricity 回归测试不变;`pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 核对 fixed/all-in 没被 provider double count;today 是否沿用 Europe/Amsterdam 日界。
|
||
- 检查 retained cleanup 范围只针对旧 identity,不误清其他 Meter/source。
|
||
|
||
### M8-T18 — Sources UI 与多 Commodity Meters [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T17
|
||
- **Context**: 把新数据源/binding 概念变成可配置体验,并把 Modbus 专用页面准确命名。
|
||
|
||
**Files**
|
||
- `create frontend/src/energy/SourceManager.tsx`
|
||
- `create frontend/src/energy/SourceManager.test.tsx`
|
||
- `create frontend/src/energy/SourceForm.tsx`
|
||
- `create frontend/src/energy/SourceForm.test.tsx`
|
||
- `create frontend/src/energy/SourceReadings.tsx`
|
||
- `create frontend/src/energy/SourceReadings.test.tsx`
|
||
- `modify frontend/src/energy/hooks.ts`
|
||
- `modify frontend/src/energy/hooks.test.tsx`
|
||
- `modify frontend/src/energy/MeterManager.tsx`
|
||
- `modify frontend/src/energy/MeterManager.test.tsx`
|
||
- `modify frontend/src/energy/DsmrPanel.tsx`
|
||
- `modify frontend/src/energy/DsmrPanel.test.tsx`
|
||
- `modify frontend/src/pages/EnergyPage.tsx`
|
||
- `modify frontend/src/pages/EnergyPage.test.tsx`
|
||
|
||
**Steps**
|
||
1. 新增 Sources tab/list/form/detail;表单由 source-profiles 渲染,secret mask 不覆盖旧值,serial 字段
|
||
展示 `/dev` 与 115200 7N1 提示,列表显示 enabled/status/last seen/error。
|
||
2. source detail 支持 discover、channel suggestion/quality/latest、分钟 history 与 binding summary;对
|
||
`unverifiable` 显示解释,不使用绿色 verified 文案。DSMR panel 嵌入 DSMR source detail,移除顶层
|
||
DSMR tab 入口但复用其兼容 API 展示能力。
|
||
3. 将原 Devices tab/标题改为 **Modbus Devices**,现有 Modbus CRUD/force-delete 行为不变。
|
||
4. Meters 支持 electricity/heating/hot_water,声明时可选 unit-compatible unbound channel;POST 单次
|
||
发送 `source_channel_uuid`,成功后展示 Meter epoch 与 binding timeline。另提供 source switch 的
|
||
binding close/create 交互,明确不等于换表。
|
||
5. hooks 使用生成类型并正确 invalidate source/channel/meter/expose queries;补 loading/empty/error/
|
||
409/422、discover timeout、atomic create 和 accessibility 测试。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不在浏览器实现 parser/cost 公式,不自动接受 commodity suggestion。
|
||
- 不把 WarmteLink 放进 Modbus form,不手改 `schema.d.ts`。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] Sources / Modbus Devices / Meters 信息架构和 D1/D2/D3 一致。
|
||
- [ ] 用户能创建 source、发现 channel、确认并原子绑定三种 Meter、查看 quality/history。
|
||
- [ ] DSMR 顶层 tab 消失但功能位于 source detail,现有 Modbus 功能无回归。
|
||
- [ ] `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全绿。
|
||
- [ ] 后端 `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 人工验证窄屏、keyboard/label、secret PATCH、错误态和 `unverifiable` 文案。
|
||
- 搜索所有“Devices”歧义及旧 DSMR tab 引用;确认 UI 不把 suggestion 当事实。
|
||
|
||
### M8-T19 — Scope-aware Contracts / Prices / Costs UI [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T18
|
||
- **Context**: 完成 electricity/thermal 双 scope 的合同录入、价格快照和成本审计体验。
|
||
|
||
**Files**
|
||
- `modify frontend/src/energy/ContractForm.tsx`
|
||
- `modify frontend/src/energy/ContractForm.test.tsx`
|
||
- `modify frontend/src/energy/ContractManager.tsx`
|
||
- `modify frontend/src/energy/ContractManager.test.tsx`
|
||
- `modify frontend/src/energy/TibberPrices.tsx`
|
||
- `modify frontend/src/energy/TibberPrices.test.tsx`
|
||
- `modify frontend/src/energy/CostView.tsx`
|
||
- `modify frontend/src/energy/CostView.test.tsx`
|
||
- `modify frontend/src/energy/energy-hooks.test.tsx`
|
||
- `modify frontend/src/pages/EnergyPage.tsx`
|
||
- `modify frontend/src/pages/EnergyPage.test.tsx`
|
||
|
||
**Steps**
|
||
1. Contracts、Prices、Costs 各增加统一 Electricity/Thermal selector,并把 scope 放进 query key;切换时
|
||
不串缓存,默认 electricity 保持旧体验。
|
||
2. thermal ContractForm 按 profile 渲染 D11 字段与单位,standing 零值可见可改;金额以 string
|
||
输入/提交,禁止 JS float 预计算,不预填真实 tariff。
|
||
3. thermal Prices 显示当前 contract version/effective range/variable+standing snapshot,并说明这不是
|
||
15 分钟市场 spot price;electricity Tibber 页面保持现状。
|
||
4. thermal Costs 显示 heating、hot-water 三分项、variable、fixed、all-in 的 total/today/time range,
|
||
可展开 15 分钟 rows、degraded reason,并提供带确认的显式 recompute。
|
||
5. 测试 scope 切换、双 active contract、Decimal payload、空/降级/跨 version、fixed 不重复、CSRF
|
||
recompute 和 electricity regression。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不在前端重算金额,不抓取外部 tariff,不改变后端公式。
|
||
- 不把 thermal 与 electricity 合成一个 active 状态或一张误导性 price curve。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] 两 scope 可独立查看/编辑 active contract,切换无缓存串线。
|
||
- [ ] thermal 全部字段、单位、breakdown/fixed/all-in/degraded 与 API 一致且 Decimal-safe。
|
||
- [ ] electricity 合同/Tibber/成本 UX 和测试保持兼容。
|
||
- [ ] `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全绿。
|
||
- [ ] 后端 `pytest`、`ruff check .` 全绿。
|
||
|
||
**Reviewer checklist**
|
||
- 人工用小数费率核对请求 body 与展示值未被 float 改写。
|
||
- 同时启用两 scope、换 Meter、无一侧 thermal Meter 时,页面文案与 summary 是否诚实。
|
||
|
||
### M8-T20 — 部署 Compose 配置、运行文档与端到端收尾 [structural]
|
||
|
||
- **Status**: `done`
|
||
- **Depends**: M8-T19
|
||
- **Context**: 最后一张卡把串口权限、操作 runbook 与隔离的自动化技术验收变成可重复验收结果;真实硬件/HA 观察作为交付后由用户执行的人工 walkthrough,不是 agent 或 Reviewer 的技术 PASS 前置条件。
|
||
|
||
**Files**
|
||
- `modify docker-compose.yml`
|
||
- `modify docker-compose.dev.yml`
|
||
- `modify tests/test_deployment.py`
|
||
- `create docs/warmtelink-energy.md`
|
||
- `modify docs/architecture-overview.md`
|
||
- `modify docs/homeassistant-outbound.md`
|
||
- `modify docs/meter-epochs.md`
|
||
- `modify README.md`
|
||
- `modify docs/roadmap.md`
|
||
- `modify docs/design/README.md`
|
||
- `modify docs/design/m8-warmtelink-energy.md`
|
||
|
||
**Steps**
|
||
1. 初始交付曾使用独立 Compose 配置文件;该形态已由 M8-R07 后续修订收敛为现有 base+dev Compose。示例通过
|
||
`WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-id>` 和 `WARMTELINK_SERIAL_GID=<serial-gid>` 直接映射容器
|
||
`/dev/warmtelink`,以显式 serial GID/additional group 授权;服务保持非 root。当前 base+dev Compose
|
||
明确要求这两个环境变量并固定声明 device 映射,不能作为无硬件启动形态使用。不要写入用户真实设备 id。
|
||
2. 检查 Dockerfile/compose/build context 与新 Python/前端文件;deployment tests 覆盖默认及 base+dev compose
|
||
合并、device path、pyserial `O_RDWR` 所需的 `rw`/无 `m`、非 root/非 privileged、migration 无 device、COPY
|
||
source 存在。
|
||
3. 写运行文档:识别稳定 by-id、查 GID、在 `.env`(或等效部署环境)配置 stable by-id path 与 serial GID,
|
||
由 Compose 固定映射串口;创建 source、discover/bind、质量含义、reconnect/权限排障、backup/migration、
|
||
合同录入、HA toggle 与安全回滚。明确这两个变量为必填项,不删除旧 config/data。
|
||
4. 在 `/tmp` 下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。
|
||
不挂载、复制或打开运行中 production 的数据库、容器或 volume。
|
||
5. 只用 mock/fake serial、合成 source/channel/binding/Meter/contract/cost/HA payload 和 `/tmp`
|
||
合成数据库,跑全量后端/前端/OpenAPI/codegen 闸门;演练 rev14→head 与空库→head。构建镜像后,
|
||
仅启动一个无 production volume/bind/device 的临时容器,使用容器内临时数据库迁移并确认关键路由不
|
||
500;结束时删除该容器、临时 image tag、`/tmp` 数据库和测试凭据。
|
||
6. 交付完整的 §12 九步人工 walkthrough 与可填写证据模板给用户;不得声称 agent 已执行真实串口、
|
||
真实 HA/MQTT 或生产资源操作。独立 Reviewer 可在自动化技术验收与文档/报告完整时判 `PASS`;随后
|
||
Orchestrator 才可把 Roadmap/设计索引/M8/T20 状态更新为完成并按仓库规则 autosquash。打 tag/push
|
||
仍需用户另行明确授权。
|
||
|
||
**Out of scope / 不要碰**
|
||
- 不把宿主真实 serial id、GID、equipment id、合同金额或数据库写进仓库。
|
||
- 不删除旧 app_config/数据库/volume,不以 root 或 privileged 容器绕过权限。
|
||
- 不自动 push、force-push 或打 release tag。
|
||
|
||
**Acceptance criteria**
|
||
- [ ] **自动化技术验收**:默认 compose 与 synthetic base+dev 配置的结构检查通过;app 仍为非 root/非 privileged,device rule 为无 `m` 的 `rw`(仅满足 pyserial `O_RDWR` 打开),worker 业务只 read/close,migration 无 device。
|
||
- [ ] **自动化技术验收**:mock/fake serial 覆盖采集、断线与只读边界;合成 source/channel/binding/Meter、thermal/electricity contract/cost 与 HA payload 覆盖完整身份链和成本/HA 行为,绝不连接真实 serial、HA 或 MQTT。
|
||
- [ ] **自动化技术验收**:仅在 `/tmp` 合成历史副本演练 rev14→head 并对账 DSMR 正常数字、source/binding、Meter、contract 和 cost;空库→head 及重复运行通过,绝不打开、复制、挂载或修改 production DB/config/container/volume。
|
||
- [ ] **自动化技术验收**:`pytest`、`ruff check .`、OpenAPI/codegen、全部前端闸门和 Docker build 全绿;临时 Docker 运行只使用无 volume/bind/device、无 production 挂载的容器及容器内临时数据库,关键路由不 500,结束后清理容器、image tag、`/tmp` 文件与测试凭据。
|
||
- [ ] **交付后用户人工 walkthrough**:文档提供完整 §12 九步操作和脱敏证据模板,明确由用户在真实 serial/HA 的备份、可回滚部署上验收;agent/Reviewer 不伪称已执行,且该人工结果不是 Reviewer 技术 PASS 的前置条件。
|
||
|
||
**Reviewer checklist**
|
||
- 必须亲自检查 Dockerfile `COPY`、compose merge 后的 user/group/device,不接受只看 unit tests。
|
||
- 独立运行 fake serial、合成 source/channel/binding/Meter/contract/cost/HA payload、`/tmp` rev14→head 和空库→head 的技术验收;抽查迁移对账和 rollback 文档,不允许任何自动化删除历史或接触真实 serial/HA/MQTT/production 资源。
|
||
- 独立运行完整闸门及无 volume/bind/device 的临时 Docker 容器,核对清理;文档与最终报告必须完整交付 §12 九步给用户且不伪称已执行。上述自动化和交付完整时,Reviewer 可判 `PASS`;真实人工结果由用户交付后验收。
|
||
|
||
## 11. 每张任务卡的校验矩阵
|
||
|
||
除任务卡另有更严格要求外,每张卡都必须在根目录运行:
|
||
|
||
```bash
|
||
pytest
|
||
ruff check .
|
||
```
|
||
|
||
只要改动 HTTP route、Pydantic schema 或 route docstring,还必须:
|
||
|
||
```bash
|
||
python scripts/export_openapi.py
|
||
git diff --exit-code openapi/
|
||
cd frontend
|
||
npm run codegen
|
||
git diff --exit-code src/api/schema.d.ts
|
||
```
|
||
|
||
前端任务还必须在 `frontend/` 运行:
|
||
|
||
```bash
|
||
npm run lint
|
||
npm run typecheck
|
||
npm run test
|
||
npm run build
|
||
```
|
||
|
||
删除/移动/重命名文件的任务必须搜索 Dockerfile、compose、CI 和 import 引用,并确保
|
||
`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍绿。Implementer/Reviewer 简报、独立重跑与
|
||
fixup/autosquash 流程以 [`docs/design/README.md`](./README.md) 和仓库 `AGENTS.md` 为准。
|
||
|
||
M8-T20 的自动化技术验收进一步受以下隔离边界约束:只可使用 mock/fake serial、合成
|
||
source/channel/binding/Meter/contract/cost/HA payload、pytest `tmp_path` 或 `/tmp` 明确命名的合成
|
||
SQLite;Docker 只可运行无 volume/bind/device、无生产挂载的临时容器。不得打开真实 serial、连接真实
|
||
HA/MQTT,或访问 production DB/config/container/volume。真实环境观察仅属于 §12 的交付后用户人工
|
||
walkthrough。
|
||
|
||
## 12. 交付后用户人工 Walkthrough
|
||
|
||
T20 的 agent/Reviewer 自动化技术验收完成后,最终报告必须将以下九步完整交给用户。用户在备份数据库、
|
||
可回滚部署、真实 serial 设备和真实 HA 环境中自行执行并验收;这不是 Reviewer 技术 PASS、T20 状态更新或
|
||
M8 收尾的前置条件。agent 不得执行、记录为已执行,或以 mock/临时容器结果替代这些人工观察。
|
||
|
||
1. 在 `.env`(或等效部署环境)设置 `WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-id>` 与
|
||
`WARMTELINK_SERIAL_GID=<serial-gid>`,启动当前 base+dev Compose stack,确认 app、现有 DSMR、Modbus、
|
||
电价/电费和前端均无回归;不得把缺少这两个值的配置当作无硬件启动模式。
|
||
2. 确认当前 base+dev Compose 的固定映射:容器保持非 root/非 privileged,`/dev/warmtelink` 的 device rule
|
||
为 pyserial POSIX `O_RDWR` 所需的 `rw`(没有 `m`),并记录可成功打开。该系统权限不等于业务写入:代码只
|
||
read/close,绝不调用 write。若要运行 Pre-M8 probe,必须先停止 app,保证串口只有一个 owner,完成后再恢复
|
||
当前 `.env` 直配部署。
|
||
3. UI 创建 WarmteLink source,discover 后看到两个 channel、正确 unit/device type、质量
|
||
`unverifiable`;原始 equipment id 在日志、DB、API、UI 均不可见。
|
||
4. 分别创建/选择 heating 与 hot_water Meter 并确认 binding;观察 latest 约 10 秒更新、history
|
||
每分钟一条。拔线后 source offline/reconnect,插回后自动恢复且不重复 history。
|
||
5. 人工制造 source switch,确认没有创建假换表;再声明一次 Meter swap,确认两条时间线和 HA
|
||
identity 分别按设计变化,边界 period degraded。
|
||
6. 创建 thermal contract version,录入一组专用测试费率;等待/重算 15 分钟 period,手算核对
|
||
heating、hot-water 三分项、variable、fixed、all-in 与 01:05 日结,确认 fixed 只计一次。
|
||
7. 同时保留 active electricity contract,确认 electricity 与 thermal UI/成本/HA 不串 scope,已有
|
||
electricity 正常数字与 M8 前一致。
|
||
8. 开启少量新 expose toggles,核对 HA unit、state class、availability、today reset 和换表后的新
|
||
identity;关闭 toggle 后 retained discovery 被正确清理。
|
||
9. 重启 app/container,确认 source/worker/bindings/history/contract/cost 恢复;再按 runbook 回到默认
|
||
compose,历史数据仍完整。
|
||
|
||
### 用户验收证据模板(交付后填写)
|
||
|
||
用户须在备份数据库和可回滚部署上逐项记录:日期、隔离部署标识、§12 项号、预期与实际观察、脱敏日志/截图
|
||
引用、回滚结果。记录不得包含 stable device id、GID、secret、数据库路径或合同金额。交付时各项可为“未执行”;
|
||
只有用户填写并自行验收后才代表真实环境结果,且不得倒推或伪造 agent/Reviewer 已完成的观察。
|
||
|
||
## 13. Milestone Definition of Done
|
||
|
||
- [x] M8-T01~M8-T20 均由独立 Reviewer 依据各自的自动化技术验收判 `PASS`,任务 Status 为 `done`,fixup 已按仓库规则收口。
|
||
- [x] 统一 Source→Channel→Binding→Meter 链同时承载 DSMR 和 WarmteLink;DSMR 正常行为/数字兼容。
|
||
- [x] WarmteLink 双 channel 只读采集、质量接纳、latest/history、重连、隐私与部署全部符合 D1~D8。
|
||
- [x] electricity 成本可审计到 binding;thermal 合同、15 分钟账本、固定费、API/UI/HA 符合 D9~D14。
|
||
- [x] 历史升级对账、空库迁移、后端/前端/OpenAPI/codegen、Docker build 与无生产挂载的临时 Docker 技术验收全绿;其输入只可为 mock、模拟数据和 `/tmp` 合成数据库。
|
||
- [x] 最终报告完整交付 §12 九步及用户证据模板;真实 serial/HA walkthrough 由用户在交付后自行验收,不能在报告中伪称 agent 已执行。
|
||
- [x] 文档、Roadmap 和 milestone 状态反映真实完成度;没有删除用户数据、没有真实 secret/设备身份,
|
||
没有未经授权的 push/tag。
|