From d7f04aee8cdacd0dc4e477069d02dc6a3e1a7210 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 22 Aug 2026 21:23:19 +0200 Subject: [PATCH] M8: finalize WarmteLink implementation plan --- docs/design/README.md | 2 +- docs/design/m8-warmtelink-energy.md | 1146 +++++++++++++++++++++++++-- docs/roadmap.md | 44 +- 3 files changed, 1128 insertions(+), 64 deletions(-) diff --git a/docs/design/README.md b/docs/design/README.md index f1dbc4b..ca35914 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -10,7 +10,7 @@ - [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺 - [`m7-meter-epochs-archival.md`](./m7-meter-epochs-archival.md) — 电表生命周期 / 换表归档(Meter epochs) - [`pre-m8-warmtelink-p1-poc.md`](./pre-m8-warmtelink-p1-poc.md) — WarmteLink P1 真机概念验证(已完成;正式 CLI 长测与供暖变化均经物理表复核) -- [`m8-warmtelink-energy.md`](./m8-warmtelink-energy.md) — WarmteLink P1 与多数据源 Meter(Planning 已解锁;架构仍开放) +- [`m8-warmtelink-energy.md`](./m8-warmtelink-energy.md) — WarmteLink P1、多数据源 Meter 与热力计费(Planning 已完成;M8-T01~M8-T20 待实现) 本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。 diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index 2cbea3e..841459b 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -1,69 +1,1109 @@ -# M8 — WarmteLink P1 与多数据源 Meter(Planning 占位) +# M8 — WarmteLink P1、多数据源 Meter 与热力计费 -> **状态:Planning 已解锁;尚未拆解任务卡,尚未锁定架构。** [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md) -> 已完成正式 probe、测试和 10 分钟复验;这只满足进入 -> Planning 的证据门,不授权 schema、API、worker 或前端实现。 +> **状态:Planning 已完成,M8-T01~M8-T20 待实现。** +> [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md) 已完成并通过翻牌;本文锁定 +> M8 的目标架构、迁移顺序、外部契约和可由编排器逐张执行的原子任务。 -## 1. 候选目标 +## 1. 目标与完成边界 -把 Vattenfall WarmteLink 的 P1 数据接入现有 Energy 模块,至少支持: +M8 完成后,Energy 模块不再把“电表”“采集连接”和“协议设备”混为一谈。系统能够: -- 区域供暖累计热量(GJ)。 -- 生活热水累计量(真机已确认为 m³)。 -- 历史读数、当前状态以及按需暴露给 Home Assistant。 -- 与现有 Meter epoch/换表归档语义兼容。 +- 用统一的 `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 能启动、热更新、断线重连和干净停止。 -## 2. 当前已知边界 +M8 不承诺 WarmteLink 当前 telegram 未提供的瞬时流量、热功率、供/回水温度,也不推断 +“生活热水消耗了多少 GJ”。不把 WarmteLink 塞进 `modbus_device`,不把所有协议读数强行泛化成 +一张万能 telemetry 表,也不删除旧数据库、历史读数或旧 `app_config` 行。 -- 现有 DSMR 模块独立订阅 `dsmr/json`,把 DSMR Reader 已解析的 JSON 降采样写入 `dsmr_reading`。 -- 现有 `Meter` 表示物理计量表的安装 epoch,本身不订阅 MQTT,也不负责解析 telegram。 -- 当前 electricity Meter 与 `dsmr_reading` 之间没有显式 source FK/binding;电费计算通过代码约定直接查询 DSMR 电力寄存器。 -- `Meter.commodity` 后端已为 `heating` 等品类预留,但“增加 commodity”本身不会自动获得相应数据源或解析能力。 -- 当前 Devices UI/模型是 Modbus 专用,不能直接假设 WarmteLink 应复用 `modbus_device`。 -- 正式 CLI 复验中,一个 WarmteLink serial source 在 60/60 帧均输出两个累计 channel:channel 1 - 为生活热水 `m³`(device type `006`、`5.900 m³`),channel 2 为区域供暖 `GJ`(device type - `012`、基线 `0.017 GJ`);同日与物理表复核一致。最终人工走查开启供暖后,channel 2 又从 - `0.017 GJ` 增至 `0.018 GJ`,物理热量表同步显示 `0.018 GJ`。 -- 设备 timestamp 严格每 10 秒递进;frame 平均 256 bytes、范围 237–275,不能假定固定帧长。 - 当前 P1 telegram 没有瞬时流量、热功率、供水温度或回水温度字段。 -- 当前线材/设备组合以 `115200 7N1` 才能稳定解析正文。60/60 frame 缺少标准 `/`,故均为 - `unverifiable`;即使部分 footer 恰为四位十六进制也不能验证 CRC。正式 ingestion 必须显式 - 处理该数据质量状态,不能将数值与表盘相符视为 CRC valid。 +## 2. 已验证基线 -## 3. 下一轮 Planning 必须讨论的问题 +- 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. 锁定的架构决策 -1. Meter 是否显式绑定可配置的数据源,以及绑定的生命周期和基数。 -2. 如何表示现有 DSMR MQTT source 与新的 WarmteLink P1 serial source。 -3. “Device”与“Data Source”是否为同一概念;前端 Devices 是否需要改名或分组。 -4. 一个 P1 source 暴露多个 measurement channel 时,如何映射到一个或多个 Meter。 -5. 直接 P1 读数是否使用独立存储,还是将现有 `dsmr_reading` 泛化;如何保证多 source 去重和隔离。 -6. heating GJ 与 hot-water m³ 的 commodity、单位、累计/换表语义。 -7. M8 是否只做采集与展示;区域供暖合同、价格和成本计算是否留到后续里程碑。 -8. 串口 worker 的重连、停止、配置热更新、Docker device mapping 与权限边界。 +### D1 — Source、Device、Meter 是三个概念 -## 4. Planning 入口条件 +- `MeterSource`:应用如何连接并采集数据,M8 支持 `dsmr_mqtt`、`warmtelink_serial`。 +- `ModbusDevice`:Modbus 协议设备,继续保留现有模型与轮询配置。 +- `Meter`:一只物理计量表的安装 epoch,继续承担换表/搬家时间线。 +- 前端明确分成 **Sources / Modbus Devices / Meters**,不再把 Modbus 页面称为通用 Devices。 -进入 M8 Planning 的证据门已满足: +### D2 — 多 channel 先发现,再由人确认绑定 -- [x] Pre-M8 通过并留下脱敏字段清单。 -- [x] 正式 probe 复验两个累计量、单位、channel、device type 与更新时间;equipment identifier - 保持脱敏。 -- [x] 记录 parser 适配结论和异常 framing/CRC 状态;10 分钟样本的稳定累计值不构成消费更新、 - reset/wrap 或长期稳定性的证明。 -- [x] 最终人工走查确认供暖消费时累计量按 `0.001 GJ` 更新并与物理表一致;精确更新延迟、 - reset/wrap 和长期接纳策略仍留给 Planning。 +Source 首次成功解码时 upsert channel。parser 可以根据 device type/unit 给出 commodity 建议,但 +不得自动创建 Meter 或自动绑定。用户在 UI/API 中确认 channel 与 Meter epoch 的关系。 -正式编写 M8 目标架构、数据模型和原子任务卡前,仍需: +### D3 — Binding 有独立时间线 -- 重新走查现有 DSMR ingest、Meter epoch、Modbus device、expose/HA 和 Energy 前端边界。 -- 与用户讨论并锁定 Meter ↔ source 的配置体验后,再决定 migration/API/UI 方案。 +`MeterSourceBinding` 表示一个 channel 在 `[started_at, ended_at)` 为某个 Meter 提供读数。 +同一 Meter 和同一 channel 在任一时刻都最多有一个有效 binding。切换 MQTT topic、USB 适配器或 +采集主机时,可以结束旧 binding 并创建新 binding,而无需伪造一次物理换表;真正换表仍创建新的 +Meter epoch。 -## 5. 当前明确不做 +### D4 — 协议读数分表,身份链统一 -- 本占位不创建 implementation task,不授权 schema/API/frontend 变更。 -- 不承诺当前 P1 未提供的瞬时流量、热功率或温度,也不承诺可拆分“空间供暖 GJ”和 - “生活热水 GJ”。 -- 不提前把 WarmteLink 塞进 `modbus_device` 或现有 `dsmr_reading`。 -- 不在缺少真机证据时设计通用 telemetry framework。 +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、索引和唯一约束。 + +### 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**: `todo` +- **Depends**: none +- **Context**: 先建立协议无关的身份链和时间约束;本卡只改 schema/model,不接运行时或 HTTP。 + +**Files** +- `create app/models/meter_source.py` +- `modify app/models/__init__.py` +- `modify app/models/energy.py` +- `create alembic_app/versions/20260822_15_meter_sources.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. migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何 + 外部数据库文件。 +5. 测试 Alembic 空库升级、模型默认值、唯一约束、FK RESTRICT 和无 delete cascade。 + +**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,测试覆盖约束。 +- [ ] `pytest`、`ruff check .` 全绿。 + +**Reviewer checklist** +- migration 是否完全无 drop/truncate/用户文件操作;SQLite 重建后 FK 是否仍启用。 +- 时间窗是否统一为 `[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_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**: `todo` +- **Depends**: M8-T02 +- **Context**: 先把既有电力链路安全迁到统一模型,之后才能让 runtime 和计费真正按 source 工作。 + +**Files** +- `modify app/models/energy.py` +- `create alembic_app/versions/20260822_16_dsmr_source_adoption.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 保持 nullable;migration 注释和测试必须明确这是历史边界,而非当前 binding。 +4. 每一阶段在 migration 内核对 source/readings/cost 行数与 orphan FK;不一致立即抛错回滚。 +5. 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。 + +**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 完整保留。 +- [ ] 历史升级 fixture、空库升级、`pytest`、`ruff check .` 全绿。 + +**Reviewer checklist** +- 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。 +- 是否存在“把所有历史强绑当前 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 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**: `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 终点相减。 +- [ ] `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.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**: `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 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**: `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` +- `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 不触碰任何外部文件。 + +**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 列。 +- [ ] `pytest`、`ruff check .` 全绿。 + +**Reviewer checklist** +- Numeric precision 是否覆盖合理长期累计值;timezone 与唯一键是否使用设备时间。 +- migration 是否数据安全、约束命名稳定且可在 SQLite 正确执行。 + +### M8-T09 — WarmteLink 质量接纳、发现与分钟采样 [structural] + +- **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 泄漏;`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.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**: `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 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**: `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 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`,旧行确定性回填 `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;重导契约。 + +**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。 +- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿。 + +**Reviewer checklist** +- 并发/事务失败是否可能留下同 scope 双 active 或误停用另一 scope。 +- 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` 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**: `todo` +- **Depends**: M8-T13 +- **Context**: 热力成本不能硬塞进 electricity 专用账本;先建立按 commodity 审计的独立表。 + +**Files** +- `modify app/models/energy.py` +- `create alembic_app/versions/20260822_19_meter_cost_periods.py` +- `create tests/test_meter_cost_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。 + +**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 影响。 +- [ ] `pytest`、`ruff check .` 全绿。 + +**Reviewer checklist** +- 降级 nullable 不能放松正常写入的 service invariant;FK 删除策略必须保护审计。 +- 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 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**: `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 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**: `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/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. 在备份数据库副本演练 Alembic 14→head 并对账;新空库迁移到 head。 +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。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d2b159..cc24171 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ 本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。 -> 每个里程碑的设计与**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)、Pre-M8 [`pre-m8-warmtelink-p1-poc.md`](./design/pre-m8-warmtelink-p1-poc.md)、M8 [`m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md)。Pre-M8 已完成;M8 仅解锁 Planning,架构仍开放。 +> 每个里程碑的设计与**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)、Pre-M8 [`pre-m8-warmtelink-p1-poc.md`](./design/pre-m8-warmtelink-p1-poc.md)、M8 [`m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md)。Pre-M8 已完成;M8 Planning 也已完成并拆成 M8-T01~M8-T20,等待后续由编排器按依赖逐张实现。 ## 当前基线(v1.0.3) @@ -41,7 +41,7 @@ | **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 | | **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI | | **Pre-M8** ✅ | WarmteLink P1 真机概念验证 | 正式只读 CLI 长测通过;人工开启供暖后累计量 `0.017 → 0.018 GJ` 且与物理表一致,所有 frame 的 CRC 状态仍为 `unverifiable` | -| **M8** 📝 | WarmteLink P1 与多数据源 Meter | Pre-M8 已解锁 Planning;再讨论一个 serial source 的两个累计 channel 与 Meter 的可配置关系,尚未锁定架构或实施 | +| **M8** 📋 | WarmteLink P1、多数据源 Meter 与热力计费 | Planning 已完成:统一 Source/Channel/Binding、WarmteLink 双 channel、DSMR 迁移、thermal 合同/成本、HA/UI/部署;M8-T01~M8-T20 待实现 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。 @@ -282,18 +282,42 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用, --- -## M8 — WarmteLink P1 与多数据源 Meter(📝 Planning 已解锁) +## M8 — WarmteLink P1、多数据源 Meter 与热力计费(📋 Planning 已完成,待实现) -### 候选目标 +### 目标 -在 Pre-M8 事实基础上,把 WarmteLink P1 已确认存在的区域供暖 GJ 和生活热水 m³ 接入 -Energy 模块,并讨论 Meter 如何与实际数据源建立可配置关系。 +以统一的 `MeterSource → MeterSourceChannel → MeterSourceBinding → Meter epoch` 链路承载现有 +DSMR MQTT 与新的 WarmteLink serial source;从一个 WarmteLink source 只读采集已由真机确认的 +区域供暖累计 `GJ` 和生活热水累计 `m³`,并完成历史、计费、API、前端、HA 与部署闭环。 -Pre-M8 的证据门已满足,但当前仍不锁定数据库、API、后台 worker 或 UI 结构;特别是 DSMR MQTT -source、P1 serial source、Device/Data Source 的定义和多 channel 映射,都留到下一轮 Planning 讨论 -并与用户锁定体验后再拆原子任务。 +### 已锁定范围 -> Planning 占位与待决问题:[`docs/design/m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md) +- Source、Modbus Device、Meter 分离;channel 先发现、再由用户确认绑定,source 切换与物理换表 + 使用两条独立半开时间线。 +- DSMR JSON 和 WarmteLink Decimal scalar 分表存储,但都显式关联 source/binding;既有 DSMR + 历史和电费安全回填,正常电费数字保持不变,跨 source/binding 周期降级。 +- WarmteLink `unverifiable` frame 需要连续双帧确认后接纳,质量标签不漂白;约 10 秒更新 latest、 + 每分钟保存 history,不保存 raw telegram 或 raw equipment identifier。 +- 串口 worker 使用 `115200 7N1`、只读、短 DB session、可热更新和 `1…60s` 退避重连;以可选 + compose overlay、稳定 by-id、serial GID 和非 root 容器部署。 +- 合同增加 electricity/thermal scope;一份 district-heating 合同覆盖 heating GJ 与 hot-water m³, + 生成按 commodity 的 15 分钟成本和 contract-level 每日固定费。仓库不硬编码真实 tariff。 +- UI 分成 Sources / Modbus Devices / Meters,并让 Contracts / Prices / Costs 按 scope 切换;新增 + source、meter 与 thermal cost HA entities,开关默认关闭,旧 DSMR latest API 保持兼容。 + +### 原子实施链 + +- **M8-T01~T06**:统一 source/channel/binding schema、DSMR 历史/runtime/电费迁移和管理 API。 +- **M8-T07~T11**:共享 P1 parser、WarmteLink 标量存储、质量接纳、serial worker、发现与历史 API。 +- **M8-T12~T16**:合同 scope、district-heating profile、thermal cost 账本/引擎/API。 +- **M8-T17~T20**:HA、Sources/Meters UI、scope-aware 计费 UI、compose/文档/全链收尾。 + +完成判据不仅是单元闸门全绿,还包括历史副本迁移对账、OpenAPI/codegen、全部前端闸门、真实 +`docker build`、非 root 串口部署和真机端到端 walkthrough。任何任务都不得删除旧数据库、历史 +读数、旧 config 行或 volume;push/tag 仍需用户单独授权。 + +> 完整架构、HTTP 契约、质量/计费规则、依赖图与 M8-T01~M8-T20 任务卡: +> [`docs/design/m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md) ---