Files
home-automation/docs/design/m8-warmtelink-energy.md
T

1182 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M8 — WarmteLink P1、多数据源 Meter 与热力计费
> **状态:Planning 已完成,M8-T01M8-T20 待实现。**
> [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、只读串口方式部署;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 payloadWarmteLink 使用规范化标量表。两种表都关联 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/updatedconfig 经 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 约定。
- 新增可选 `docker-compose.warmtelink.yml` overlay,把宿主机稳定 `/dev/serial/by-id/...` 映射为
容器内 `/dev/warmtelink`,通过宿主机 serial GID 授权,容器仍非 root。
- 默认 `docker-compose.yml` 在没有硬件时仍可启动,不强制声明不存在的 device。
- source disable/delete/config change 必须停止旧 workershutdown 不留下线程或打开的 serial fd。
## 8. 前端信息架构
Energy 页面最终包含:Sources、Modbus Devices、Meters、Contracts、Prices、CostsHA Expose 继续位于
现有 Config 页面,不在 M8 搬家。
- Sourceskind profile 驱动表单;状态、last seen/error、discoversource detail 显示 channel latest、
quality、历史和当前 binding。DSMR 原顶层面板折入 DSMR source detail。
- Meters:支持三种 commodity;声明 Meter 时可以选已发现且单位兼容的 channel,并原子创建
binding;历史页能看 binding 时间线,数据源切换不伪装成换表。
- Contracts / Prices / Costs:统一 scope selectorthermal 合同表单展示 §3 D11 固定字段;成本页
分开展示 heating/hot_water variable breakdown、fixed 与 all-indegraded 原因可见。
- 所有前端任务补齐 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/bindingT07T11 再接 WarmteLinkT12T16
建立 thermal contract/billingT17T19 完成 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 capabilitysource 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 保持 nullablemigration 注释和测试必须明确这是历史边界,而非当前 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 15T03 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 subscriptionshandler 捕获稳定
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 修改无需重启即可 reconciledisable 后不再写入。
- [ ] `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 在失败场景不留下半条 Metersecret/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 APIparser 输入 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
至多一个 workerconfig PATCH/enable/disable 后提交事务,再触发 reconcile。
3. worker 用共享 framer/parser/ingestDB 写入每批新开短 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 summaryhistory 支持 `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 互斥从全局改为同 scopecreate/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` kindscope=`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`,字段按 §5commodity、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 使用定点 NumericORM 不经 floatJSON 中的金额/数量统一序列化为十进制字符串。
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 invariantFK 删除策略必须保护审计。
- 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 breakdownstanding 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 routeslist 支持时间窗/commodity/paginationsummary 返回 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/todayelectricity 延续既有行为,heating GJ、
hot_water m³ 使用正确 device/state class)。latest 太旧或 source offline 时 availability 不可用。
2. 注册 thermal cost total/todayheating、hot_water_heating、water、water_tax、fixed、all_in;金额单位
使用 contract currencyprovider 只读 T15 summary,不重算另一套公式。
3. 单 Meter unique_id/node_id 以 Meter UUIDthermal 组合成本以当前 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 且默认 falseHA 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 counttoday 是否沿用 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 channelPOST 单次
发送 `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 priceelectricity 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 — 部署 Overlay、运行文档与端到端收尾 [structural]
- **Status**: `todo`
- **Depends**: M8-T19
- **Context**: 最后一张卡把串口权限、操作 runbook、真实构建与完整链路变成可重复验收结果。
**Files**
- `create docker-compose.warmtelink.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 overlay:示例把宿主 `/dev/serial/by-id/<stable-id>` 映射为容器
`/dev/warmtelink`,以显式 serial GID/additional group 授权;服务保持非 root,默认 compose 无硬件
也能启动。不要写入用户真实设备 id。
2. 检查 Dockerfile/compose/build context 与新 Python/前端文件;deployment tests 覆盖默认 compose、
overlay 合并、device path、非 root、COPY source 存在。
3. 写运行文档:识别稳定 by-id、查 GID、启停 overlay、创建 source、discover/bind、质量含义、
reconnect/权限排障、backup/migration、合同录入、HA toggle 与安全回滚。明确不删除旧 config/data。
4.`/tmp` 下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。
不挂载、复制或打开运行中 production 的数据库、容器或 volume。
5. 跑全量后端/前端/OpenAPI/codegen 闸门和真实 `docker build`;启动迁移后的 app,确认关键路由
不 500。
6. 按 §12 人工 walkthrough 走完整链;通过后把 Roadmap/设计索引/M8 状态与 T01T20 Status
更新为完成。打 tag/push 仍需用户另行明确授权。
**Out of scope / 不要碰**
- 不把宿主真实 serial id、GID、equipment id、合同金额或数据库写进仓库。
- 不删除旧 app_config/数据库/volume,不以 root 或 privileged 容器绕过权限。
- 不自动 push、force-push 或打 release tag。
**Acceptance criteria**
- [ ] 默认 compose 无设备可启动;overlay 以非 root 只读访问稳定映射路径,部署测试覆盖。
- [ ] 空库/历史副本迁移对账通过,DSMR 正常数字不变,WarmteLink/thermal 全链可运行。
- [ ] 文档足够让另一位 operator 从备份、部署、配置走到 HA 与回滚,不含真实 secret/identifier。
- [ ] `pytest``ruff check .`、OpenAPI/codegen、全部前端闸门和真实 `docker build` 全绿。
- [ ] 人工 walkthrough 全部通过;Roadmap/M8 任务状态与现实一致。
**Reviewer checklist**
- 必须亲自检查 Dockerfile `COPY`、compose merge 后的 user/group/device,不接受只看 unit tests。
- 抽查迁移对账和 rollback 文档,不允许任何自动化删除历史;独立跑完整闸门与 walkthrough。
## 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` 为准。
## 12. M8 最终人工 Walkthrough
T20 收尾时必须在备份数据库/可回滚部署上完成以下人工验证:
1. 不启用 overlay 启动默认 stack,确认 app、现有 DSMR、Modbus、电价/电费和前端均无回归。
2. 以 stable by-id + serial GID 启用 overlay;容器保持非 root`/dev/warmtelink` 可读,代码没有
write 操作。
3. UI 创建 WarmteLink sourcediscover 后看到两个 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,历史数据仍完整。
## 13. Milestone Definition of Done
- [ ] M8-T01M8-T20 均由独立 Reviewer 判 `PASS`,任务 Status 为 `done`fixup 已按仓库规则收口。
- [ ] 统一 Source→Channel→Binding→Meter 链同时承载 DSMR 和 WarmteLinkDSMR 正常行为/数字兼容。
- [ ] WarmteLink 双 channel 只读采集、质量接纳、latest/history、重连、隐私与部署全部符合 D1~D8。
- [ ] electricity 成本可审计到 bindingthermal 合同、15 分钟账本、固定费、API/UI/HA 符合 D9D14。
- [ ] 历史升级对账、空库迁移、后端/前端/OpenAPI/codegen、Docker build 和 §12 walkthrough 全绿。
- [ ] 文档、Roadmap 和 milestone 状态反映真实完成度;没有删除用户数据、没有真实 secret/设备身份,
没有未经授权的 push/tag。