# M8 — WarmteLink P1、多数据源 Meter 与热力计费 > **状态:Planning 已完成,M8-T01~M8-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 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 约定。 - 新增可选 `docker-compose.warmtelink.yml` overlay,把宿主机稳定 `/dev/serial/by-id/...` 映射为 容器内 `/dev/warmtelink`,通过宿主机 serial GID 授权,容器仍非 root。 - 默认 `docker-compose.yml` 在没有硬件时仍可启动,不强制声明不存在的 device。 - 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**: `todo` - **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**: `todo` - **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 — 部署 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/` 映射为容器 `/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 状态与 T01~T20 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 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,历史数据仍完整。 ## 13. Milestone Definition of Done - [ ] M8-T01~M8-T20 均由独立 Reviewer 判 `PASS`,任务 Status 为 `done`,fixup 已按仓库规则收口。 - [ ] 统一 Source→Channel→Binding→Meter 链同时承载 DSMR 和 WarmteLink;DSMR 正常行为/数字兼容。 - [ ] WarmteLink 双 channel 只读采集、质量接纳、latest/history、重连、隐私与部署全部符合 D1~D8。 - [ ] electricity 成本可审计到 binding;thermal 合同、15 分钟账本、固定费、API/UI/HA 符合 D9~D14。 - [ ] 历史升级对账、空库迁移、后端/前端/OpenAPI/codegen、Docker build 和 §12 walkthrough 全绿。 - [ ] 文档、Roadmap 和 milestone 状态反映真实完成度;没有删除用户数据、没有真实 secret/设备身份, 没有未经授权的 push/tag。