65 KiB
M8 — WarmteLink P1、多数据源 Meter 与热力计费
状态:Planning 已完成,M8-T01~M8-T20 待实现。 Pre-M8 真机概念验证 已完成并通过翻牌;本文锁定 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 type006)和 channel 2:区域供暖累计GJ(device type012)。人工开启供暖后,后者由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 为人工录入参考,
具体金额始终由用户提供并通过 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. 目标数据流
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 的任务还必须同时遵守以下仓库级契约:
- 新模型模块在首次引入时显式 import 到
alembic_app/env.py,保证Base.metadata完整;后续只在 已注册模块中新增模型时无需重复改 env。 - 同一任务把
scripts/app_db_adopt.py::APP_BASELINE_REVISION更新为该任务的新 head。现有 fail-closed 启动校验和多组部署/模型回归测试都要求该常量与唯一 Alembic head 完全相等;不得把 常量临时留在旧 revision 等后续任务补救。 scripts/run_migrations.py继续只调用 app DB adoption/upgrade,不新增第二条迁移链;每个任务验证 旧 head→新 head、空库→head 和重复运行幂等。- 实现、review 和 T20 收尾只使用 pytest
tmp_path、/tmp下明确命名的临时 SQLite 库以及合成的 历史 fixture。不得挂载、复制、打开或修改运行中 production 的 DB 路径、容器或 volume;所谓 “历史升级副本”在本轮指结构和边界场景等价的隔离合成副本,不含真实生产数据。 - 每次升级对账至少记录 migration 前后业务表行数、Alembic revision、孤儿 FK、关键唯一键/索引; 任何不一致必须在同一事务中失败并回滚。downgrade 仅用于临时测试库的 schema 可逆性验证,绝不 对生产或用户备份执行。
DSMR 回填算法
- 读取旧 runtime config,创建一个
dsmr_mqttsource 和稳定 electricity channel;无旧 config 时也 创建 disabled source,不猜 broker/topic。 - 所有既有
dsmr_reading指向该 source;行数必须完全相等。 - 对每个 electricity Meter epoch,以 Meter 的
[started_at, ended_at)与 DSMR 数据域相交创建 binding;没有数据交集的 epoch 也保留 Meter,不伪造读数。 - 回填
energy_cost_period.source_binding_id:能唯一解析的正常周期必须绑定;跨边界或无法唯一 解析的行保留账本但标为 degraded,禁止静默绑定到当前表。 - 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、7data bits、no parity、1stop bit;设备路径必须是/dev/...。 warmtelink_serialconfig 保存路径和串口参数,不保存 telegram/equipment id;敏感字段采用现有 secret mask 约定。- 新增可选
docker-compose.warmtelink.ymloverlay,把宿主机稳定/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. 依赖图与建议编排顺序
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.pymodify app/models/__init__.pymodify app/models/energy.pymodify alembic_app/env.pycreate alembic_app/versions/20260822_15_meter_sources.pymodify scripts/app_db_adopt.pycreate tests/test_meter_sources.pymodify tests/test_energy_models.py
Steps
- 新建
MeterSource、MeterSourceChannel、MeterSourceBinding,字段和唯一键严格按 §5; sourceconfig为 JSON,UUID 是外部稳定身份。 - binding 使用整型 FK 关联 Meter/channel,时间统一存 UTC;用服务前可复用的 model helper 表达
半开区间相交,数据库 FK 使用
RESTRICT,relationship 不配置 delete cascade。 - 给
EnergyCostPeriod增加 nullablesource_binding_idFK 和 relationship;迁移此时只加列, 不回填 DSMR 历史。 - 在
alembic_app/env.py注册新模型模块,并把APP_BASELINE_REVISION同步到 revision 15; 不改变scripts/run_migrations.py的单库行为。 - migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何 外部数据库文件。
- 测试 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.pycreate app/services/meter_sources.pycreate tests/test_meter_source_services.py
Steps
- 注册
dsmr_mqtt、warmtelink_serial两个 profile;声明 config 字段、默认值、secret 标记、 capabilities 和允许 channel 单位。serial 默认115200/7/N/1,path 必须以/dev/开头。 - 实现 config validate/sanitize/merge:未知字段报错,secret 响应遮罩,PATCH 遮罩值表示保持原值。
- 实现 source CRUD、channel discovery upsert、binding list/create/update/close 服务;所有写操作由 调用方持有事务,不在 helper 中悄悄 commit。
- binding 校验:Meter 与 channel 存在、commodity/unit 映射为 electricity→kWh、heating→GJ、 hot_water→m³;同 Meter 或同 channel 的有效区间不得重叠,边界相等允许。
- 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.pycreate alembic_app/versions/20260822_16_dsmr_source_adoption.pymodify scripts/app_db_adopt.pycreate tests/test_dsmr_source_migration.pymodify tests/test_energy_models.pymodify tests/test_meter_sources.py
Steps
- 把 ORM
DsmrReading.source_id重命名为 nullabletelegram_id,新增 non-nullmeter_source_idFK,唯一约束改为(meter_source_id, recorded_at);保留 JSON payload。 - migration 按 §5 DSMR 回填算法创建迁移 source/channel、回填所有读数和 electricity Meter
bindings,再回填可唯一解析的
energy_cost_period.source_binding_id。 - 不能唯一解析的旧成本行不删不猜:保留原金额与 pricing snapshot,设置
degraded=true并让 binding 保持 nullable;migration 注释和测试必须明确这是历史边界,而非当前 binding。 - 每一阶段在 migration 内核对 source/readings/cost 行数与 orphan FK;不一致立即抛错回滚。
- 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。
- 把
APP_BASELINE_REVISION同步到 revision 16;所有升级 fixture 仅在tmp_path中构造,不读取 真实 app DB 或 volume。 - 把 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.pymodify app/integrations/mqtt.pymodify app/services/config_page.pymodify app/main.pymodify tests/test_dsmr_ingest.pymodify tests/test_dsmr_subscription_apply.pymodify tests/test_api_config.pymodify tests/test_mqtt_subscribe.py
Steps
- 用
meter_source中所有 enableddsmr_mqttsources reconcile subscriptions;handler 捕获稳定 source id/config snapshot,把meter_source_id写入 reading。 - topic 或 config 变更时只替换受影响 source 的订阅;多个 source 可并存,禁用/删除会 unsubscribe; 相同 topic 如无法由 MQTT manager 区分 source,应在 profile/service 层拒绝重复 enabled topic。
- DSMR tariff 状态按 source 隔离,并提供按当前 electricity binding 解析 tariff 的 helper;不能继续用 单个模块全局值代表所有 source。
- startup/config-save 调用新的 reconcile;现有 flat DSMR config 从 config page/runtime 中移除读取, 但数据库 KV 不删除。
- 更新网络线程异常吞吐、短 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:
todo - Depends: M8-T04
- Context: DSMR 已多 source 后,电力 period 必须只在同一 Meter/binding 累计域内计算。
Files
modify app/services/energy_cost.pymodify app/models/energy.pymodify app/schemas/energy.pymodify tests/test_energy_cost.pymodify tests/test_api_energy.py
Steps
- 为 period 两个边界按时间解析唯一 electricity Meter、binding 和 DSMR source;查询 reading 时加入
meter_source_id,结果写EnergyCostPeriod.source_binding_id。 - 两端 Meter/binding/source 不同、缺失、overlap 歧义或读数 freshness 超限时生成 degraded 行, 不跨累计域相减。
- recompute 与 summary 保持现有 API 形状和 immutable/audit 语义;响应可追加 nullable binding identity, 不删除旧字段。
- 用现有 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 终点相减。
pytest、ruff 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.pycreate app/api/routes/api/meter_sources.pymodify app/schemas/meter.pymodify app/api/routes/api/meters.pymodify app/main.pycreate tests/test_meter_source_api.pymodify tests/test_api_meters.pymodify openapi/openapi.jsonmodify openapi/openapi.yamlmodify frontend/src/api/schema.d.ts
Steps
- 实现 source profiles、source CRUD、channel list、commodity catalog 和 binding list/create/patch; discover 在非 serial 实现上返回 capability-aware 结果,WarmteLink 真连接留给 T11。
- 映射领域错误:不存在
404,validation/overlap422,有依赖删除409;响应使用 source/channel UUID,绝不暴露 DB secret 或原始 equipment id。 - 扩展 Meter POST 可选
source_channel_uuid;Meter 声明和 binding 创建在一个事务,任一步失败 全部回滚。Meter response/list 暴露 bindings 或稳定的 binding summary。 - 写 auth/CSRF、secret mask、原子回滚、时间本地化、兼容 DSMR latest 的 API 测试。
- 运行 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 codegendiff 全绿且生成物已提交。
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.pymodify scripts/p1_probe.pymodify tests/test_p1_probe.pycreate tests/test_p1_parser.py
Steps
- 把变长 frame framing、文本解码、字段/channel 解析、CRC 状态和脱敏 fingerprint 提取为无 I/O 的 typed parser API;parser 输入 bytes、输出 frame/channel DTO 或明确错误。
- 保持 Pre-M8 对
/缺失、四位 footer、valid/invalid/unverifiable判定逐 fixture 不变;parser 本身不做双帧 ingestion 接纳决策。 - equipment identifier 仅在函数内用于 SHA-256,返回值/日志/异常/repr 不包含原值;原始 bytes 不 写文件或数据库。
p1_probe.py改为调用共享 parser,CLI 参数、stdout 脱敏格式、退出码和 read-only 约束不变。- 以现有真机 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:
todo - Depends: M8-T07
- Context: WarmteLink 与 DSMR payload 结构不同,建立可精确计算的 Decimal 标量历史表。
Files
modify app/models/meter_source.pycreate alembic_app/versions/20260822_17_warmtelink_readings.pymodify scripts/app_db_adopt.pycreate tests/test_warmtelink_models.py
Steps
- 新建
WarmteLinkReading:channel FK、recorded_at、received_at、定点 Decimalvalue、unit、 quality、equipment fingerprint;(channel_id, recorded_at)唯一并索引时间。 - quality 只允许 parser 的三个持久状态;数据库不存 raw telegram、raw identifier 或派生的 “estimated valid”。
- FK 使用
RESTRICT,不配置 orphan/delete cascade;选择足以容纳长期累计量与 0.001 精度的 Numeric precision/scale,并测试 Decimal round-trip。 - migration 覆盖空库/升级、唯一键、FK 和索引;downgrade 不触碰任何外部文件。
- 把
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:
todo - Depends: M8-T08
- Context: 把 parser 输出变成可审计的 latest/history;这里锁住最关键的 unverifiable 接纳策略。
Files
create app/services/warmtelink_ingest.pycreate tests/test_warmtelink_ingest.py
Steps
- 实现每 source 的接纳状态机:
valid单帧接纳、invalid拒绝、unverifiable只有连续两帧满足 时间递增、channel identity/metadata 完全相同、各累计值不下降才接纳第二帧;确认后保留第二帧 作为 sliding previous,后续每个连续一致 frame 都可接纳,不要求再成对跳帧。 - gap、乱序、metadata 变化、channel 缺失/新增或累计下降都会清空候选并留下诊断;不自动解释为 reset/wrap,不从另一 source 借候选帧。
- 接纳时按 source +
channel_key幂等 upsert channel,写 suggestion/unit/device_type/fingerprint, 更新 source/channel latest、last_seen/status;不自动创建 Meter/binding。 - 每个接纳 frame 更新 latest;历史以每个设备 timestamp UTC minute bucket 的首个接纳 frame 为样本,每 channel 每分钟最多一条,重投递/重启先查 bucket 并保持幂等。首次样本前不 backfill。
- 所有 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:
todo - Depends: M8-T09, M8-T06
- Context: 以可测试的 manager 为每个 enabled WarmteLink source 持有一个只读 worker。
Files
create app/services/warmtelink_worker.pymodify app/main.pymodify app/api/routes/api/meter_sources.pycreate tests/test_warmtelink_worker.pymodify tests/test_meter_source_api.py
Steps
- 定义 serial factory/clock/stop-event 协议,生产 factory 从 source profile 创建
115200 7N1read-only 连接;业务代码只依赖.read()/context close,不暴露.write()。 - manager
reconcile()对所有 enabledwarmtelink_serialsource 做 start/update/stop,保证每 source 至多一个 worker;config PATCH/enable/disable 后提交事务,再触发 reconcile。 - worker 用共享 framer/parser/ingest,DB 写入每批新开短 session;断连/打开失败按
1,2,4,8,16,32,60,60...s可中断退避,成功读到完整 frame 后重置退避。 - FastAPI lifespan 在 DB ready 后启动 manager,在 shutdown 先 set stop、关闭 serial、join 有界时间; manager/worker 异常只更新 source status,不阻止其他 source。
- 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:
todo - Depends: M8-T10
- Context: worker 链路稳定后,把一次发现、状态和规范化历史接到已建立的 source API。
Files
modify app/schemas/meter_source.pymodify app/api/routes/api/meter_sources.pymodify app/services/warmtelink_worker.pymodify tests/test_meter_source_api.pymodify openapi/openapi.jsonmodify openapi/openapi.yamlmodify frontend/src/api/schema.d.ts
Steps
POST .../discover对 WarmteLink 发起有界的一次 read/discovery request;已有常驻 worker 时通过 manager 协调,不并发抢同一 serial fd。响应返回 pending/completed/error 与脱敏 channel metadata。- channel list 加 latest value/time/quality/source status/binding summary;history 支持
from/to/limit和稳定时间升序,参数范围非法为422。 - 保证普通 HTTP 请求不阻塞等待无期限串口;timeout 后 source 可继续后台重连。
- API 测试覆盖无数据、unverifiable、分页、source/channel 归属校验、auth、timeout、secret/privacy。
- 重导 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:
todo - Depends: M8-T08
- Context: electricity 与 thermal 必须能各有一份 active 合同,同时保持旧客户端默认看 electricity。
Files
modify app/models/energy.pymodify app/services/contracts.pymodify app/schemas/energy_contract.pymodify app/api/routes/api/energy_contracts.pycreate alembic_app/versions/20260822_18_contract_scopes.pymodify scripts/app_db_adopt.pymodify tests/test_api_energy_contracts.pymodify tests/test_energy_models.pymodify openapi/openapi.jsonmodify openapi/openapi.yamlmodify frontend/src/api/schema.d.ts
Steps
- 给
EnergyContract增加 non-nullscope,旧行确定性回填electricity;service registry 声明 manual/tibber→electricity,后续 district_heating→thermal,客户端不能伪造不匹配 scope。 - active 互斥从全局改为同 scope;create/update/activate 都在事务内先锁定/停用同 scope 其他合同, 不影响另一 scope。
- list/detail/create/update responses 增加 scope;请求可省略 scope 并由 kind 推导。旧请求与无 query list 默认兼容 electricity 行为,新增可选 scope filter。
- migration 对账合同/version/cost 行数,不改 versions/pricing values,不删除任何合同。
- 覆盖两 scope 同时 active、同 scope 互斥、kind/scope mismatch、旧 payload 与升级 fixture;重导契约。
- 把
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:
todo - Depends: M8-T12
- Context: 用 profile 固定热力字段、单位和验证,不把用户的实际 Vattenfall 金额写进仓库。
Files
create app/integrations/pricing/profiles/district_heating.yamlmodify app/integrations/pricing/profiles.pymodify app/services/contracts.pymodify tests/test_pricing_profiles.pymodify tests/test_api_energy_contracts.py
Steps
- 新建
district_heatingkind,scope=thermal;按 §3 D11 定义 4 个 variable 与 5 个 standing Decimal 字段、EUR 单位、label/help、最小值0,standing defaults 全为0。 - profile loader 明确保留 Decimal/字符串精度,拒绝 float、未知字段、负值、缺失必填 variable; version snapshot 包含完整规范化 values。
- 合同 create/new-version 对该 profile 验证,既有 manual/tibber profile 和 Tibber fetch 选择逻辑不变。
- 测试完整/最小 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:
todo - Depends: M8-T13
- Context: 热力成本不能硬塞进 electricity 专用账本;先建立按 commodity 审计的独立表。
Files
modify app/models/energy.pycreate alembic_app/versions/20260822_19_meter_cost_periods.pymodify scripts/app_db_adopt.pycreate tests/test_meter_cost_models.py
Steps
- 新建
MeterCostPeriod,字段按 §5:commodity、period start/end、nullable Meter/binding、nullable contract version(降级时允许缺失)、Decimal quantity/cost、cost/pricing JSON、quality/degraded reason、 created/updated timestamps。 - 唯一键为
(commodity, period_start);Meter/binding/version 均RESTRICT,无 delete cascade;索引 commodity+time 和 binding。 - quantity/cost 使用定点 Numeric,ORM 不经 float;JSON 中的金额/数量统一序列化为十进制字符串。
- migration 只创建新表,不迁移/删除
energy_cost_period;测试空库/升级、Decimal round-trip、 唯一键、nullable degraded 和 FK RESTRICT。 - 把
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:
todo - Depends: M8-T14
- Context: 基于两个独立累计 Meter 生成可审计 variable 账本,并在 summary 层只计一次固定费。
Files
create app/services/meter_cost.pymodify app/main.pycreate tests/test_meter_cost.pymodify tests/test_energy_cost.py
Steps
- 实现 closed 15-minute period 计算:分别解析 heating/hot_water Meter、binding、channel 与两端
WarmteLinkReading;要求同 Meter/binding、quality 可接纳、两边界距目标不超过
120s。 - 用 Decimal 实现 D12 公式和 breakdown;负 delta、heating
>0.1 GJ、hot_water>1 m³、 跨 epoch/binding、读数/合同缺失或质量 invalid 都写幂等 degraded row,不猜值。 - recompute range 显式覆盖同一唯一键,正常 scheduler 不覆写已完成正常行;contract version 与完整 pricing snapshot 按 period 生效时间保存。
- summary 聚合 variable breakdown;standing fees 按 thermal contract version 的本地自然日片段
annual / 365计提,在01:05后才包含当日,且 contract-level 只计一次。 - 在现有每分钟成本 job 中调用 thermal compute;一个 scope 失败不得阻断另一 scope,随后 HA publish 仍 best-effort。
- 测试 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:
todo - Depends: M8-T15
- Context: 对外提供 scope-aware 价格和新的热力账本,不破坏既有 electricity 路由响应。
Files
create app/schemas/meter_cost.pycreate app/api/routes/api/meter_costs.pymodify app/schemas/energy.pymodify app/api/routes/api/energy.pymodify app/main.pycreate tests/test_meter_cost_api.pymodify tests/test_api_energy.pymodify openapi/openapi.jsonmodify openapi/openapi.yamlmodify frontend/src/api/schema.d.ts
Steps
GET /api/energy/prices增加 scope 参数:省略时保持 electricity 既有响应;thermal 返回当前 district_heating contract/version 的规范化 values 与有效期,不伪造 15 分钟市场价格。- 实现 §6 三个 meter-cost routes;list 支持时间窗/commodity/pagination,summary 返回 heating、 hot_water 三分项、variable subtotal、fixed breakdown、all-in 与 degraded counts。
- recompute 写入要求 CSRF,校验有界时间窗,scope 目前只接受 thermal;返回 processed/normal/ degraded counts,重试幂等。
- Decimal 在 JSON 中遵循既有金额 schema 约定且无 float 漂移;API 测试 auth/CSRF、空数据、 degraded、跨 version、默认 electricity compatibility 和非法 scope。
- 注册 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:
todo - Depends: M8-T11, M8-T16
- Context: 在完整采集和成本链上扩展现有 expose provider,保持默认关闭与稳定 identity。
Files
modify app/integrations/expose.pymodify app/services/ha_discovery.pymodify tests/test_energy_expose.pymodify tests/test_ha_discovery.pymodify tests/test_api_expose.py
Steps
- 注册 source online entities;为当前 Meter 注册 total/today(electricity 延续既有行为,heating GJ、 hot_water m³ 使用正确 device/state class)。latest 太旧或 source offline 时 availability 不可用。
- 注册 thermal cost total/today:heating、hot_water_heating、water、water_tax、fixed、all_in;金额单位 使用 contract currency,provider 只读 T15 summary,不重算另一套公式。
- 单 Meter unique_id/node_id 以 Meter UUID;thermal 组合成本以当前 heating/hot_water Meter UUID 排序后确定性组合。任一换表生成新 identity,并 best-effort 清理旧 retained discovery。
- 所有新 catalog toggle 默认 false;已有 toggle 和 DSMR Reader 直接 HA 暴露不受影响。
- 测试 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.tsxcreate frontend/src/energy/SourceManager.test.tsxcreate frontend/src/energy/SourceForm.tsxcreate frontend/src/energy/SourceForm.test.tsxcreate frontend/src/energy/SourceReadings.tsxcreate frontend/src/energy/SourceReadings.test.tsxmodify frontend/src/energy/hooks.tsmodify frontend/src/energy/hooks.test.tsxmodify frontend/src/energy/MeterManager.tsxmodify frontend/src/energy/MeterManager.test.tsxmodify frontend/src/energy/DsmrPanel.tsxmodify frontend/src/energy/DsmrPanel.test.tsxmodify frontend/src/pages/EnergyPage.tsxmodify frontend/src/pages/EnergyPage.test.tsx
Steps
- 新增 Sources tab/list/form/detail;表单由 source-profiles 渲染,secret mask 不覆盖旧值,serial 字段
展示
/dev与 115200 7N1 提示,列表显示 enabled/status/last seen/error。 - source detail 支持 discover、channel suggestion/quality/latest、分钟 history 与 binding summary;对
unverifiable显示解释,不使用绿色 verified 文案。DSMR panel 嵌入 DSMR source detail,移除顶层 DSMR tab 入口但复用其兼容 API 展示能力。 - 将原 Devices tab/标题改为 Modbus Devices,现有 Modbus CRUD/force-delete 行为不变。
- Meters 支持 electricity/heating/hot_water,声明时可选 unit-compatible unbound channel;POST 单次
发送
source_channel_uuid,成功后展示 Meter epoch 与 binding timeline。另提供 source switch 的 binding close/create 交互,明确不等于换表。 - 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.tsxmodify frontend/src/energy/ContractForm.test.tsxmodify frontend/src/energy/ContractManager.tsxmodify frontend/src/energy/ContractManager.test.tsxmodify frontend/src/energy/TibberPrices.tsxmodify frontend/src/energy/TibberPrices.test.tsxmodify frontend/src/energy/CostView.tsxmodify frontend/src/energy/CostView.test.tsxmodify frontend/src/energy/energy-hooks.test.tsxmodify frontend/src/pages/EnergyPage.tsxmodify frontend/src/pages/EnergyPage.test.tsx
Steps
- Contracts、Prices、Costs 各增加统一 Electricity/Thermal selector,并把 scope 放进 query key;切换时 不串缓存,默认 electricity 保持旧体验。
- thermal ContractForm 按 profile 渲染 D11 字段与单位,standing 零值可见可改;金额以 string 输入/提交,禁止 JS float 预计算,不预填真实 tariff。
- thermal Prices 显示当前 contract version/effective range/variable+standing snapshot,并说明这不是 15 分钟市场 spot price;electricity Tibber 页面保持现状。
- thermal Costs 显示 heating、hot-water 三分项、variable、fixed、all-in 的 total/today/time range, 可展开 15 分钟 rows、degraded reason,并提供带确认的显式 recompute。
- 测试 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.ymlmodify tests/test_deployment.pycreate docs/warmtelink-energy.mdmodify docs/architecture-overview.mdmodify docs/homeassistant-outbound.mdmodify docs/meter-epochs.mdmodify README.mdmodify docs/roadmap.mdmodify docs/design/README.mdmodify docs/design/m8-warmtelink-energy.md
Steps
- 新增可选 compose overlay:示例把宿主
/dev/serial/by-id/<stable-id>映射为容器/dev/warmtelink,以显式 serial GID/additional group 授权;服务保持非 root,默认 compose 无硬件 也能启动。不要写入用户真实设备 id。 - 检查 Dockerfile/compose/build context 与新 Python/前端文件;deployment tests 覆盖默认 compose、 overlay 合并、device path、非 root、COPY source 存在。
- 写运行文档:识别稳定 by-id、查 GID、启停 overlay、创建 source、discover/bind、质量含义、 reconnect/权限排障、backup/migration、合同录入、HA toggle 与安全回滚。明确不删除旧 config/data。
- 在
/tmp下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。 不挂载、复制或打开运行中 production 的数据库、容器或 volume。 - 跑全量后端/前端/OpenAPI/codegen 闸门和真实
docker build;启动迁移后的 app,确认关键路由 不 500。 - 按 §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. 每张任务卡的校验矩阵
除任务卡另有更严格要求外,每张卡都必须在根目录运行:
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 收尾时必须在备份数据库/可回滚部署上完成以下人工验证:
- 不启用 overlay 启动默认 stack,确认 app、现有 DSMR、Modbus、电价/电费和前端均无回归。
- 以 stable by-id + serial GID 启用 overlay;容器保持非 root,
/dev/warmtelink可读,代码没有 write 操作。 - UI 创建 WarmteLink source,discover 后看到两个 channel、正确 unit/device type、质量
unverifiable;原始 equipment id 在日志、DB、API、UI 均不可见。 - 分别创建/选择 heating 与 hot_water Meter 并确认 binding;观察 latest 约 10 秒更新、history 每分钟一条。拔线后 source offline/reconnect,插回后自动恢复且不重复 history。
- 人工制造 source switch,确认没有创建假换表;再声明一次 Meter swap,确认两条时间线和 HA identity 分别按设计变化,边界 period degraded。
- 创建 thermal contract version,录入一组专用测试费率;等待/重算 15 分钟 period,手算核对 heating、hot-water 三分项、variable、fixed、all-in 与 01:05 日结,确认 fixed 只计一次。
- 同时保留 active electricity contract,确认 electricity 与 thermal UI/成本/HA 不串 scope,已有 electricity 正常数字与 M8 前一致。
- 开启少量新 expose toggles,核对 HA unit、state class、availability、today reset 和换表后的新 identity;关闭 toggle 后 retained discovery 被正确清理。
- 重启 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。