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

64 KiB
Raw Blame History

M8 — WarmteLink P1、多数据源 Meter 与热力计费

状态:Planning 已完成,M8-T01M8-T20 待实现。 Pre-M8 真机概念验证 已完成并通过翻牌;本文锁定 M8 的目标架构、迁移顺序、外部契约和可由编排器逐张执行的原子任务。

1. 目标与完成边界

M8 完成后,Energy 模块不再把“电表”“采集连接”和“协议设备”混为一谈。系统能够:

  • 用统一的 MeterSource → MeterSourceChannel → MeterSourceBinding → Meter 链路表示 DSMR MQTT 和 WarmteLink serial 两种采集源。
  • 从一个 WarmteLink P1 串口发现并持续采集两个累计 channel:区域供暖 GJ 与生活热水 ,保留数据质量状态,且不保存原始 telegram 或可识别设备编号。
  • electricityheatinghot_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:生活热水累计 device type 006)和 channel 2:区域供暖累计 GJdevice type 012)。人工开启供暖后,后者由 0.017 增至 0.018 GJ,物理表同步。
  • 设备 timestamp 每 10 秒递进;所有观测 frame 缺少标准 /,因此 CRC 只能标为 unverifiable,不能伪装成 valid
  • 现有 DSMR 把解析后的完整 JSON 降采样写入 dsmr_readingrecorded_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_mqttwarmtelink_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,避免 为了表面统一而损失协议字段或制造稀疏万能表。

  • parser 结果质量枚举为 validinvalidunverifiable
  • 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 并存

现有 manualtibber 属于 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 为人工录入参考, 具体金额始终由用户提供并通过 version 保存。

D12 — 热力成本按 commodity 落 15 分钟账本

meter_cost_period(commodity, period_start) 唯一,保存 Meter/binding(降级时可空)、 contract version、Decimal quantity/cost、cost_breakdownpricing_snapshotdegraded_reason

  • heatingΔGJ × heating
  • hot_waterΔm³ × (hot_water_heating + hot_water + hot_water_tax)breakdown 保留三部分。
  • 防呆:读数 freshness 120s15 分钟 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. 目标数据流

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;旧行回填 electricityactive 互斥按 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_sourcemeter_source_channelmeter_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 增加 scopeactive 互斥按 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 默认 1152007 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. 依赖图与建议编排顺序

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: todo
  • 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. 新建 MeterSourceMeterSourceChannelMeterSourceBinding,字段和唯一键严格按 §5; source config 为 JSON,UUID 是外部稳定身份。
  2. binding 使用整型 FK 关联 Meter/channel,时间统一存 UTC;用服务前可复用的 model helper 表达 半开区间相交,数据库 FK 使用 RESTRICTrelationship 不配置 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 升级都到达 headAPP_BASELINE_REVISION 等于唯一 head,测试覆盖约束。
  • pytestruff 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: todo
  • 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_mqttwarmtelink_serial 两个 profile;声明 config 字段、默认值、secret 标记、 capabilities 和允许 channel 单位。serial 默认 115200/7/N/1path 必须以 /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。
  • pytestruff check . 全绿。

Reviewer checklist

  • warmtelink_serial 是否没有 write capabilitysource config 是否可能泄露 secret。
  • 是否同时检查“同 Meter”和“同 channel”的重叠,而非只检查其中一侧。

M8-T03 — 将 DSMR 历史迁入 Source / Binding [structural]

  • Status: todo
  • 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

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。

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,升级重复运行幂等。
  • 历史升级 fixture、空库升级、pytestruff check . 全绿。

Reviewer checklist

  • 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。
  • baseline 常量是否随 revision 16 同步,fixture 是否完全隔离于真实生产路径。
  • 是否存在“把所有历史强绑当前 Meter/source”的静默错误或任何 destructive cleanup。

M8-T04 — DSMR runtime 改为多 Source 配置 [structural]

  • Status: todo
  • 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。
  • pytestruff check . 全绿。

Reviewer checklist

  • 检查所有遗留模块全局 DSMR topic/tariff 状态,确保不会串 source。
  • 配置切换是否可能造成双订阅、旧 handler 继续写入或 session 泄漏。

M8-T05 — 电费计算绑定 Source Binding [structural]

  • Status: todo
  • 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

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 测试。

Out of scope / 不要碰

  • 不实现 thermal cost,不修改合同 scope,不改变正常电价公式。
  • 不因旧行 binding 为空而删除或重算全部历史。

Acceptance criteria

  • 正常新周期总能审计到唯一 binding,跨域周期明确 degraded。
  • 既有单 DSMR source 的所有非降级数字逐项不变。
  • recompute 幂等,不能把 source A 起点和 source B 终点相减。
  • pytestruff check . 全绿。

Reviewer checklist

  • 重点构造 Meter 相同但 source 切换、source 相同但 Meter 换表两种边界。
  • 检查 Decimal/rounding 和本地日 fixed-fee 逻辑是否被无意改变。

M8-T06 — Source / Channel / Binding HTTP 契约 [structural]

  • Status: todo
  • 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. 映射领域错误:不存在 404validation/overlap 422,有依赖删除 409;响应使用 source/channel UUID,绝不暴露 DB secret 或原始 equipment id。
  3. 扩展 Meter POST 可选 source_channel_uuidMeter 声明和 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 合同测试继续通过。
  • pytestruff check .、OpenAPI diff、npm run codegen diff 全绿且生成物已提交。

Reviewer checklist

  • 独立重导 OpenAPI 和 codegen,确认 schema.d.ts 不是手改。
  • 检查 DELETE 依赖、PATCH secret mask、时区半开区间与事务边界。

M8-T07 — 提取可复用 P1 parser(零行为变化)

  • Status: todo
  • 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。
  • pytestruff check . 全绿。

Reviewer checklist

  • 对比提取前后的 CLI 行为与质量判定,警惕“顺手修正”真机已确认的异常 framing。
  • 搜索日志与 dataclass repr,确认没有隐私泄漏或 serial .write()
  • Status: todo
  • 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. 新建 WarmteLinkReadingchannel FK、recorded_atreceived_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。
  • pytestruff check . 全绿。

Reviewer checklist

  • Numeric precision 是否覆盖合理长期累计值;timezone 与唯一键是否使用设备时间。
  • migration 是否数据安全、约束命名稳定且可在 SQLite 正确执行。
  • baseline 常量是否随 revision 17 同步,测试是否未打开任何真实数据库。
  • Status: todo
  • 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 泄漏;pytestruff check . 全绿。

Reviewer checklist

  • 双帧确认是否错误接纳第一帧、跨 source 串状态,或把 unverifiable 改成 valid
  • 累计下降/metadata drift 后必须重新积累两帧,数据库异常不能留下半更新 latest。

M8-T10 — Serial worker manager 与应用生命周期 [structural]

  • Status: todo
  • 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。
  • pytestruff check . 全绿。

Reviewer checklist

  • 检查启动/提交/reconcile 竞态、join timeout、session/fd 泄漏与敏感 path/identifier 日志。
  • 确认失败循环不会 busy-spin,成功后退避确实 reset。
  • Status: todo
  • 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 数据。
  • pytestruff check .、OpenAPI/codegen 同步闸门全绿。

Reviewer checklist

  • 独立验证 timeout/cancel 后 worker 仍健康,API 无同步 I/O 卡死 event loop 风险。
  • 独立重导两个契约生成物并搜索隐私字段。

M8-T12 — 合同 Scope 与按 Scope 激活 [structural]

  • Status: todo
  • 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 openapi/openapi.json
  • modify openapi/openapi.yaml
  • modify frontend/src/api/schema.d.ts

Steps

  1. EnergyContract 增加 non-null scope,旧行确定性回填 electricityservice 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。
  • pytestruff check .、OpenAPI/codegen 同步闸门全绿。

Reviewer checklist

  • 并发/事务失败是否可能留下同 scope 双 active 或误停用另一 scope。
  • baseline 常量是否随 revision 18 同步,历史对账是否在临时副本内执行。
  • migration 是否在 active 旧数据上安全,API 默认值是否真正向后兼容。

M8-T13 — District-heating 定价 Profile

  • Status: todo
  • 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、最小值 0standing 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。
  • pytestruff check . 全绿。

Reviewer checklist

  • YAML parser 是否可能把小数悄悄变成 float;help 文案是否把参考费率误写成默认费率。
  • 旧 profile registry/API 枚举是否被破坏。

M8-T14 — 建立通用 Meter Cost Period 账本

  • Status: todo
  • 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

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。
  • pytestruff check . 全绿。

Reviewer checklist

  • 降级 nullable 不能放松正常写入的 service invariantFK 删除策略必须保护审计。
  • baseline 常量是否随 revision 19 同步,测试路径是否与生产 DB/volume 完全隔离。
  • period timezone/半开区间、唯一键和 Numeric scale 是否足以重算。

M8-T15 — Thermal 15 分钟成本引擎与调度 [structural]

  • Status: todo
  • 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 数字不变。
  • pytestruff check . 全绿。

Reviewer checklist

  • 重点审 Decimal、period 边界最近读数选择、120s 是双边独立判断还是被错误放宽。
  • 用同时具备 heating/hot_water 的一天证明 fixed 没有 double count,并复核 01:05/DST。

M8-T16 — Thermal Prices / Costs / Summary / Recompute API

  • Status: todo
  • 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/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 保护,非法/过大窗口不执行部分写入。
  • pytestruff check .、OpenAPI/codegen 同步闸门全绿。

Reviewer checklist

  • 独立比较变更前 electricity response fixture,确认兼容不是只靠可选字段猜测。
  • 重算事务/分页边界/金额 JSON 精度,以及 thermal 无外部抓价路径。

M8-T17 — Home Assistant Source / Meter / Thermal 实体 [structural]

  • Status: todo
  • 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 回归测试不变;pytestruff check . 全绿。

Reviewer checklist

  • 核对 fixed/all-in 没被 provider double counttoday 是否沿用 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 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 lintnpm run typechecknpm run testnpm run build 全绿。
  • 后端 pytestruff 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 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 lintnpm run typechecknpm run testnpm run build 全绿。
  • 后端 pytestruff 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。
  • pytestruff check .、OpenAPI/codegen、全部前端闸门和真实 docker build 全绿。
  • 人工 walkthrough 全部通过;Roadmap/M8 任务状态与现实一致。

Reviewer checklist

  • 必须亲自检查 Dockerfile COPY、compose merge 后的 user/group/device,不接受只看 unit tests。
  • 抽查迁移对账和 rollback 文档,不允许任何自动化删除历史;独立跑完整闸门与 walkthrough。

11. 每张任务卡的校验矩阵

除任务卡另有更严格要求外,每张卡都必须在根目录运行:

pytest
ruff check .

只要改动 HTTP route、Pydantic schema 或 route docstring,还必须:

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/ 运行:

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 和仓库 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 为 donefixup 已按仓库规则收口。
  • 统一 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。