M8-T20: document and validate WarmteLink deployment

This commit is contained in:
2026-08-23 21:28:26 +02:00
parent 5b9d60e80a
commit ebf96de4f1
10 changed files with 255 additions and 46 deletions
+6 -2
View File
@@ -21,6 +21,8 @@
- **通用电价合同层**YAML profile 定合同结构(manual 固定/双费率 / tibber 动态电价);`EnergyContract`+`EnergyContractVersion` 存 UI 可填的数值,改价加新版本旧版本保留;price strategy 按 kind 出价 - **通用电价合同层**YAML profile 定合同结构(manual 固定/双费率 / tibber 动态电价);`EnergyContract`+`EnergyContractVersion` 存 UI 可填的数值,改价加新版本旧版本保留;price strategy 按 kind 出价
- **实时买卖电费计算**:每 15 分钟按寄存器差值(`_1`=dal/低、`_2`=normal/高)× 买/卖价算计量电费,快照不可变;日/月/年汇总加固定费减 heffingskorting - **实时买卖电费计算**:每 15 分钟按寄存器差值(`_1`=dal/低、`_2`=normal/高)× 买/卖价算计量电费,快照不可变;日/月/年汇总加固定费减 heffingskorting
- **反哺 Home Assistant Energy**:当前买/卖价 + 累计买电支出/卖电收入(`total_increasing`)发成 HA 实体,可直接挂 HA Energy 仪表盘 - **反哺 Home Assistant Energy**:当前买/卖价 + 累计买电支出/卖电收入(`total_increasing`)发成 HA 实体,可直接挂 HA Energy 仪表盘
- **多数据源 Meter 与 WarmteLink**DSMR MQTT 与只读 WarmteLink P1 serial source 统一为 Source → Channel → Binding → MeterWarmteLink 提供 heating `GJ` 与 hot-water `m³` 的 Decimal history、质量与重连
- **热力合同与成本**electricity / thermal scope 可各有一个 active 合同;热力按 15 分钟账本计算 variable、fixed 与 all-in 成本,并可按需暴露给 HA
- pytest 测试与 OpenAPI 导出脚本 - pytest 测试与 OpenAPI 导出脚本
- Docker / Compose 部署入口 - Docker / Compose 部署入口
@@ -44,6 +46,8 @@
- 电价合同(`energy_contract` 表)与版本(`energy_contract_version` 表,values JSON - 电价合同(`energy_contract` 表)与版本(`energy_contract_version` 表,values JSON
- Tibber 15 分钟电价缓存(`tibber_price` 表,不可变) - Tibber 15 分钟电价缓存(`tibber_price` 表,不可变)
- 每 15 分钟计量电费(`energy_cost_period` 表,快照价,不可变) - 每 15 分钟计量电费(`energy_cost_period` 表,快照价,不可变)
- meter source、channel 与 binding`meter_source``meter_source_channel``meter_source_binding`
- WarmteLink scalar 历史(`warmtelink_reading`)与热力 15 分钟成本账本(`meter_cost_period`
配置层只保留一个数据库环境变量: 配置层只保留一个数据库环境变量:
@@ -55,7 +59,7 @@
python -m scripts.run_migrations python -m scripts.run_migrations
``` ```
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head含全部表,包括 M5 新增的 `modbus_device``modbus_reading``exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading``energy_contract``energy_contract_version``tibber_price``energy_cost_period`)。 该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head包括 Modbus、DSMR、Source/Channel/Binding、WarmteLink、electricity/thermal 合同与成本账本)。
## 当前目录 ## 当前目录
@@ -63,7 +67,7 @@ python -m scripts.run_migrations
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型) - `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine - `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine
- `alembic_app/`: App DB 的 Alembic migration 环境(管理所有表,含 M5 新增的 `modbus_device``modbus_reading``exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading``energy_contract``energy_contract_version``tibber_price``energy_cost_period` - `alembic_app/`: App DB 的唯一 Alembic migration 环境(管理所有 app 表,包括 Modbus、DSMR、Meter source、WarmteLink、合同与成本账本
- `tests/`: pytest 测试 - `tests/`: pytest 测试
- `docs/`: 当前系统说明文档 - `docs/`: 当前系统说明文档
- `scripts/`: 辅助脚本,例如 OpenAPI 导出 - `scripts/`: 辅助脚本,例如 OpenAPI 导出
+17
View File
@@ -0,0 +1,17 @@
# Optional WarmteLink serial overlay. Use only together with docker-compose.yml:
# WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name> \
# WARMTELINK_SERIAL_GID=<host-serial-gid> \
# docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml up -d
#
# Keep the stable /dev/serial/by-id path on the host: /dev/ttyUSB* names may
# change after a reboot. pyserial opens POSIX serial devices with O_RDWR, so
# Docker must grant the device cgroup rule rw (never m). This is only the
# system permission required to open the fd: the WarmteLink worker itself only
# calls read/close and has no write path. The base compose non-root user stays
# in effect; this overlay grants neither root nor privileged mode.
services:
app:
devices:
- "${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw"
group_add:
- "${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"
+6 -6
View File
@@ -19,7 +19,7 @@
- `main.py` - `main.py`
- FastAPI app factory - FastAPI app factory
- lifespanAPScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布;M6 新增 `tibber-refresh` 抓价 job + `energy-cost` 1 分钟计费 tick jobM6 启用时注册 DSMR MQTT 订阅 - lifespanAPScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布;注册 DSMR MQTT source,并启动/关闭每个 enabled WarmteLink 业务只读 serial worker`tibber-refresh`、electricity 与 thermal cost tick 均由 scheduler 驱动
- 基础路由注册 - 基础路由注册
- `config.py` - `config.py`
- 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项;M6 新增 `dsmr_ingest_enabled``dsmr_mqtt_topic``dsmr_sample_interval_s``tibber_api_token`secret)、`tibber_home_id` - 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项;M6 新增 `dsmr_ingest_enabled``dsmr_mqtt_topic``dsmr_sample_interval_s``tibber_api_token`secret)、`tibber_home_id`
@@ -29,22 +29,22 @@
- 通用依赖注入 - 通用依赖注入
- `api/` - `api/`
- HTTP routes - HTTP routes
- `api/routes/api/`JSON API`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD、Modbus 设备 CRUD + readings + metrics + test`/api/modbus/*`)、Expose 勾选 + 重发 discovery`/api/expose`)、MQTT 测试连接(`/api/config/mqtt/test`)、M6 新增合同 CRUD + 版本(`/api/energy/contracts*`)、pricing profile 列表(`/api/energy/profiles`)、价格/费用/汇总/DSMR 最新/重算/Tibber 测试(`/api/energy/prices``/api/energy/costs``/api/energy/costs/summary``/api/energy/dsmr/latest``/api/energy/costs/recompute``/api/energy/tibber/test` - `api/routes/api/`JSON API`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、记录 CRUD、Modbus、Expose 与 MQTT 测试;Energy 包含 source profile/source/channel/history/discover/binding/Meter API、scope-aware contracts/prices/costs,以及兼容的 DSMR latest API
- 裸 ingestion 端点:`GET /public-ip/check``POST /homeassistant/publish``POST /poo/record``GET /poo/latest`、TickTick OAuth 等 - 裸 ingestion 端点:`GET /public-ip/check``POST /homeassistant/publish``POST /poo/record``GET /poo/latest`、TickTick OAuth 等
- `models/` - `models/`
- SQLAlchemy models - SQLAlchemy models
- 所有模型(auth / config / public_ip / location / poo / modbus / expose / energy)共用同一个 `Base`,均落在单一 `app.db` - 所有模型(auth / config / public_ip / location / poo / modbus / expose / energy / meter_source)共用同一个 `Base`,均落在单一 `app.db`
- M5 新增:`ModbusDevice`(设备部署层)、`ModbusReading`(通用遥测,JSON payload)、`ExposedEntityToggle`HA 实体暴露开关) - M5 新增:`ModbusDevice`(设备部署层)、`ModbusReading`(通用遥测,JSON payload)、`ExposedEntityToggle`HA 实体暴露开关)
- M6 新增:`DsmrReading`(整帧 DSMR telegram10s 降采样)、`EnergyContract`(合同头,含 active 标记)、`EnergyContractVersion`(版本/时段,values JSON,只增不改)、`TibberPrice`15 分钟价缓存,不可变)、`EnergyCostPeriod`(每 15 分钟计量电费,快照价,不可变) - Energy`MeterSource` / `MeterSourceChannel` / `MeterSourceBinding` 将协议连接、稳定测量 channel 与 Meter epoch 分离;`DsmrReading``WarmteLinkReading` 分别保存 JSON 与 Decimal scalar 历史;electricity `EnergyCostPeriod` 绑定 source bindingthermal `MeterCostPeriod` 保存审计账本;合同按 electricity / thermal scope 共存
- `schemas/` - `schemas/`
- Pydantic schemasM5 新增 `modbus.py``expose.py`M6 新增 `energy_contract.py``energy.py` - Pydantic schemas包括 `modbus.py``expose.py``energy_contract.py``energy.py``meter_source.py`
- `services/` - `services/`
- 业务服务层 - 业务服务层
- 当前已迁入 config page 的 DB 持久化逻辑 - 当前已迁入 config page 的 DB 持久化逻辑
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑 - 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
- 当前已迁入 SMTP 发信与测试发信逻辑 - 当前已迁入 SMTP 发信与测试发信逻辑
- M5 新增:`modbus_poll.py`(采集 service,逐设备 poll + 落库 + 推 MQTT state)、`ha_discovery.py`(构建 HA Discovery payload、发布 retained config、发布 state - M5 新增:`modbus_poll.py`(采集 service,逐设备 poll + 落库 + 推 MQTT state)、`ha_discovery.py`(构建 HA Discovery payload、发布 retained config、发布 state
- M6 新增:`tibber_prices.py`httpx GraphQL 抓 15 分钟价,upsert `tibber_price`,幂等;仅 active=tibber 且 token 存在时运行)、`dsmr_ingest.py`MQTT handler,整帧 JSON blob + 10s 降采样落库,`source_id` 幂等)、`energy_cost.py`(计费引擎:每 15 分钟寄存器差 × strategy 出价 → `energy_cost_period` 不可变快照;汇总 Σnet + 固定费 heffingskorting;重算显式 opt-in - Energy`dsmr_ingest.py` 按 source 入库;`warmtelink_ingest.py` 接纳连续确认的业务只读 P1 scalar,`warmtelink_worker.py` 管理 interruptible serial reconnectpyserial 的 POSIX `O_RDWR` 打开由非 root、非 privileged、无 `m` 的 Docker `rw` device rule 支持;worker 只 read/close,绝不 write);`energy_cost.py``meter_cost.py` 分别计算 electricity/thermal 账本,均拒绝跨 Meter/binding 相减
- `integrations/` - `integrations/`
- 外部系统适配层 - 外部系统适配层
- Home Assistant outbound adapterREST 通道,原有) - Home Assistant outbound adapterREST 通道,原有)
+1 -1
View File
@@ -10,7 +10,7 @@
- [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺 - [`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 - [`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 长测与供暖变化均经物理表复核) - [`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-T01M8-T20 待实现 - [`m8-warmtelink-energy.md`](./m8-warmtelink-energy.md) — WarmteLink P1、多数据源 Meter 与热力计费(M8-T01T20 自动化技术验收已完成;交付后用户人工 walkthrough 待验收
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。 本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
+49 -26
View File
@@ -1,6 +1,6 @@
# M8 — WarmteLink P1、多数据源 Meter 与热力计费 # M8 — WarmteLink P1、多数据源 Meter 与热力计费
> **状态:Planning 已完成,M8-T01M8-T20 待实现。** > **状态:M8-T01M8-T20 自动化技术验收已完成;交付后用户人工 walkthrough 待验收。**
> [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md) 已完成并通过翻牌;本文锁定 > [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md) 已完成并通过翻牌;本文锁定
> M8 的目标架构、迁移顺序、外部契约和可由编排器逐张执行的原子任务。 > M8 的目标架构、迁移顺序、外部契约和可由编排器逐张执行的原子任务。
@@ -18,7 +18,7 @@ M8 完成后,Energy 模块不再把“电表”“采集连接”和“协议
两种热力累计量、15 分钟成本、每日固定费和汇总。 两种热力累计量、15 分钟成本、每日固定费和汇总。
- 在 Energy UI 中分别管理 Sources、Modbus Devices、Meters、合同、价格与成本,并按需把新的 - 在 Energy UI 中分别管理 Sources、Modbus Devices、Meters、合同、价格与成本,并按需把新的
source/meter/cost 实体暴露给 Home Assistant。 source/meter/cost 实体暴露给 Home Assistant。
- 以非 root、只读串口方式部署;worker 能启动、热更新、断线重连和干净停止。 - 以非 root 部署:Docker device rule 用 pyserial POSIX `O_RDWR` 所需的最小 `rw`(绝无 `m`),而 worker 业务只读;worker 能启动、热更新、断线重连和干净停止。
M8 不承诺 WarmteLink 当前 telegram 未提供的瞬时流量、热功率、供/回水温度,也不推断 M8 不承诺 WarmteLink 当前 telegram 未提供的瞬时流量、热功率、供/回水温度,也不推断
“生活热水消耗了多少 GJ”。不把 WarmteLink 塞进 `modbus_device`,不把所有协议读数强行泛化成 “生活热水消耗了多少 GJ”。不把 WarmteLink 塞进 `modbus_device`,不把所有协议读数强行泛化成
@@ -246,7 +246,8 @@ M8 的 schema 变更沿现有单一 `alembic_app` 链按下表串行落地;不
- `warmtelink_serial` config 保存路径和串口参数,不保存 telegram/equipment id;敏感字段采用现有 - `warmtelink_serial` config 保存路径和串口参数,不保存 telegram/equipment id;敏感字段采用现有
secret mask 约定。 secret mask 约定。
- 新增可选 `docker-compose.warmtelink.yml` overlay,把宿主机稳定 `/dev/serial/by-id/...` 映射为 - 新增可选 `docker-compose.warmtelink.yml` overlay,把宿主机稳定 `/dev/serial/by-id/...` 映射为
容器内 `/dev/warmtelink`,通过宿主机 serial GID 授权,容器仍非 root。 容器内 `/dev/warmtelink`,通过宿主机 serial GID 授权。pyserial 在 POSIX 以 `O_RDWR` 打开,故 device
rule 的最小系统权限为 `rw`(不含 `m`);容器仍非 root/非 privilegedworker 业务只 read/close、绝不 write。
- 默认 `docker-compose.yml` 在没有硬件时仍可启动,不强制声明不存在的 device。 - 默认 `docker-compose.yml` 在没有硬件时仍可启动,不强制声明不存在的 device。
- source disable/delete/config change 必须停止旧 workershutdown 不留下线程或打开的 serial fd。 - source disable/delete/config change 必须停止旧 workershutdown 不留下线程或打开的 serial fd。
@@ -1069,9 +1070,9 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T20 — 部署 Overlay、运行文档与端到端收尾 [structural] ### M8-T20 — 部署 Overlay、运行文档与端到端收尾 [structural]
- **Status**: `todo` - **Status**: `done`
- **Depends**: M8-T19 - **Depends**: M8-T19
- **Context**: 最后一张卡把串口权限、操作 runbook、真实构建与完整链路变成可重复验收结果 - **Context**: 最后一张卡把串口权限、操作 runbook 与隔离的自动化技术验收变成可重复验收结果;真实硬件/HA 观察作为交付后由用户执行的人工 walkthrough,不是 agent 或 Reviewer 的技术 PASS 前置条件
**Files** **Files**
- `create docker-compose.warmtelink.yml` - `create docker-compose.warmtelink.yml`
@@ -1090,15 +1091,20 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
`/dev/warmtelink`,以显式 serial GID/additional group 授权;服务保持非 root,默认 compose 无硬件 `/dev/warmtelink`,以显式 serial GID/additional group 授权;服务保持非 root,默认 compose 无硬件
也能启动。不要写入用户真实设备 id。 也能启动。不要写入用户真实设备 id。
2. 检查 Dockerfile/compose/build context 与新 Python/前端文件;deployment tests 覆盖默认 compose、 2. 检查 Dockerfile/compose/build context 与新 Python/前端文件;deployment tests 覆盖默认 compose、
overlay 合并、device path、非 root、COPY source 存在。 overlay 合并、device path、pyserial `O_RDWR` 所需的 `rw`/无 `m`、非 root/非 privileged、migration
无 device、COPY source 存在。
3. 写运行文档:识别稳定 by-id、查 GID、启停 overlay、创建 source、discover/bind、质量含义、 3. 写运行文档:识别稳定 by-id、查 GID、启停 overlay、创建 source、discover/bind、质量含义、
reconnect/权限排障、backup/migration、合同录入、HA toggle 与安全回滚。明确不删除旧 config/data。 reconnect/权限排障、backup/migration、合同录入、HA toggle 与安全回滚。明确不删除旧 config/data。
4.`/tmp` 下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。 4.`/tmp` 下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。
不挂载、复制或打开运行中 production 的数据库、容器或 volume。 不挂载、复制或打开运行中 production 的数据库、容器或 volume。
5. 跑全量后端/前端/OpenAPI/codegen 闸门和真实 `docker build`;启动迁移后的 app,确认关键路由 5. 只用 mock/fake serial、合成 source/channel/binding/Meter/contract/cost/HA payload 和 `/tmp`
不 500。 合成数据库,跑全量后端/前端/OpenAPI/codegen 闸门;演练 rev14→head 与空库→head。构建镜像后,
6. 按 §12 人工 walkthrough 走完整链;通过后把 Roadmap/设计索引/M8 状态与 T01T20 Status 仅启动一个无 production volume/bind/device 的临时容器,使用容器内临时数据库迁移并确认关键路由不
更新为完成。打 tag/push 仍需用户另行明确授权 500;结束时删除该容器、临时 image tag、`/tmp` 数据库和测试凭据
6. 交付完整的 §12 九步人工 walkthrough 与可填写证据模板给用户;不得声称 agent 已执行真实串口、
真实 HA/MQTT 或生产资源操作。独立 Reviewer 可在自动化技术验收与文档/报告完整时判 `PASS`;随后
Orchestrator 才可把 Roadmap/设计索引/M8/T20 状态更新为完成并按仓库规则 autosquash。打 tag/push
仍需用户另行明确授权。
**Out of scope / 不要碰** **Out of scope / 不要碰**
- 不把宿主真实 serial id、GID、equipment id、合同金额或数据库写进仓库。 - 不把宿主真实 serial id、GID、equipment id、合同金额或数据库写进仓库。
@@ -1106,15 +1112,16 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- 不自动 push、force-push 或打 release tag。 - 不自动 push、force-push 或打 release tag。
**Acceptance criteria** **Acceptance criteria**
- [ ] 默认 compose 无设备可启动;overlay 以非 root 只读访问稳定映射路径,部署测试覆盖 - [ ] **自动化技术验收**:默认 compose 与 synthetic overlay 的结构检查通过;overlay app 仍为非 root/非 privilegeddevice rule 为无 `m``rw`(仅满足 pyserial `O_RDWR` 打开),worker 业务只 read/closemigration 无 device
- [ ] 空库/历史副本迁移对账通过,DSMR 正常数字不变,WarmteLink/thermal 全链可运行 - [ ] **自动化技术验收**mock/fake serial 覆盖采集、断线与只读边界;合成 source/channel/binding/Meter、thermal/electricity contract/cost 与 HA payload 覆盖完整身份链和成本/HA 行为,绝不连接真实 serial、HA 或 MQTT
- [ ] 文档足够让另一位 operator 从备份、部署、配置走到 HA 与回滚,不含真实 secret/identifier - [ ] **自动化技术验收**:仅在 `/tmp` 合成历史副本演练 rev14→head 并对账 DSMR 正常数字、source/binding、Meter、contract 和 cost;空库→head 及重复运行通过,绝不打开、复制、挂载或修改 production DB/config/container/volume
- [ ] `pytest``ruff check .`、OpenAPI/codegen、全部前端闸门和真实 `docker build` 全绿。 - [ ] **自动化技术验收**`pytest``ruff check .`、OpenAPI/codegen、全部前端闸门和 Docker build 全绿;临时 Docker 运行只使用无 volume/bind/device、无 production 挂载的容器及容器内临时数据库,关键路由不 500,结束后清理容器、image tag、`/tmp` 文件与测试凭据
- [ ] 人工 walkthrough 全部通过;Roadmap/M8 任务状态与现实一致 - [ ] **交付后用户人工 walkthrough**:文档提供完整 §12 九步操作和脱敏证据模板,明确由用户在真实 serial/HA 的备份、可回滚部署上验收;agent/Reviewer 不伪称已执行,且该人工结果不是 Reviewer 技术 PASS 的前置条件
**Reviewer checklist** **Reviewer checklist**
- 必须亲自检查 Dockerfile `COPY`、compose merge 后的 user/group/device,不接受只看 unit tests。 - 必须亲自检查 Dockerfile `COPY`、compose merge 后的 user/group/device,不接受只看 unit tests。
- 抽查迁移对账和 rollback 文档,不允许任何自动化删除历史;独立跑完整闸门与 walkthrough - 独立运行 fake serial、合成 source/channel/binding/Meter/contract/cost/HA payload、`/tmp` rev14→head 和空库→head 的技术验收;抽查迁移对账和 rollback 文档,不允许任何自动化删除历史或接触真实 serial/HA/MQTT/production 资源
- 独立运行完整闸门及无 volume/bind/device 的临时 Docker 容器,核对清理;文档与最终报告必须完整交付 §12 九步给用户且不伪称已执行。上述自动化和交付完整时,Reviewer 可判 `PASS`;真实人工结果由用户交付后验收。
## 11. 每张任务卡的校验矩阵 ## 11. 每张任务卡的校验矩阵
@@ -1148,13 +1155,22 @@ npm run build
`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍绿。Implementer/Reviewer 简报、独立重跑与 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍绿。Implementer/Reviewer 简报、独立重跑与
fixup/autosquash 流程以 [`docs/design/README.md`](./README.md) 和仓库 `AGENTS.md` 为准。 fixup/autosquash 流程以 [`docs/design/README.md`](./README.md) 和仓库 `AGENTS.md` 为准。
## 12. M8 最终人工 Walkthrough M8-T20 的自动化技术验收进一步受以下隔离边界约束:只可使用 mock/fake serial、合成
source/channel/binding/Meter/contract/cost/HA payload、pytest `tmp_path``/tmp` 明确命名的合成
SQLiteDocker 只可运行无 volume/bind/device、无生产挂载的临时容器。不得打开真实 serial、连接真实
HA/MQTT,或访问 production DB/config/container/volume。真实环境观察仅属于 §12 的交付后用户人工
walkthrough。
T20 收尾时必须在备份数据库/可回滚部署上完成以下人工验证: ## 12. 交付后用户人工 Walkthrough
T20 的 agent/Reviewer 自动化技术验收完成后,最终报告必须将以下九步完整交给用户。用户在备份数据库、
可回滚部署、真实 serial 设备和真实 HA 环境中自行执行并验收;这不是 Reviewer 技术 PASS、T20 状态更新或
M8 收尾的前置条件。agent 不得执行、记录为已执行,或以 mock/临时容器结果替代这些人工观察。
1. 不启用 overlay 启动默认 stack,确认 app、现有 DSMR、Modbus、电价/电费和前端均无回归。 1. 不启用 overlay 启动默认 stack,确认 app、现有 DSMR、Modbus、电价/电费和前端均无回归。
2. 以 stable by-id + serial GID 启用 overlay;容器保持非 root`/dev/warmtelink` 可读,代码没有 2. 以 stable by-id + serial GID 启用 overlay;容器保持非 root/非 privileged`/dev/warmtelink` 的 device
write 操作。 rule 为 pyserial POSIX `O_RDWR` 所需的 `rw`(没有 `m`),并记录可成功打开。该系统权限不等于业务写入:
代码只 read/close,绝不调用 write。
3. UI 创建 WarmteLink sourcediscover 后看到两个 channel、正确 unit/device type、质量 3. UI 创建 WarmteLink sourcediscover 后看到两个 channel、正确 unit/device type、质量
`unverifiable`;原始 equipment id 在日志、DB、API、UI 均不可见。 `unverifiable`;原始 equipment id 在日志、DB、API、UI 均不可见。
4. 分别创建/选择 heating 与 hot_water Meter 并确认 binding;观察 latest 约 10 秒更新、history 4. 分别创建/选择 heating 与 hot_water Meter 并确认 binding;观察 latest 约 10 秒更新、history
@@ -1170,12 +1186,19 @@ T20 收尾时必须在备份数据库/可回滚部署上完成以下人工验证
9. 重启 app/container,确认 source/worker/bindings/history/contract/cost 恢复;再按 runbook 回到默认 9. 重启 app/container,确认 source/worker/bindings/history/contract/cost 恢复;再按 runbook 回到默认
compose,历史数据仍完整。 compose,历史数据仍完整。
### 用户验收证据模板(交付后填写)
用户须在备份数据库和可回滚部署上逐项记录:日期、隔离部署标识、§12 项号、预期与实际观察、脱敏日志/截图
引用、回滚结果。记录不得包含 stable device id、GID、secret、数据库路径或合同金额。交付时各项可为“未执行”;
只有用户填写并自行验收后才代表真实环境结果,且不得倒推或伪造 agent/Reviewer 已完成的观察。
## 13. Milestone Definition of Done ## 13. Milestone Definition of Done
- [ ] M8-T01M8-T20 均由独立 Reviewer 判 `PASS`,任务 Status 为 `done`fixup 已按仓库规则收口。 - [x] M8-T01M8-T20 均由独立 Reviewer 依据各自的自动化技术验收`PASS`,任务 Status 为 `done`fixup 已按仓库规则收口。
- [ ] 统一 Source→Channel→Binding→Meter 链同时承载 DSMR 和 WarmteLinkDSMR 正常行为/数字兼容。 - [x] 统一 Source→Channel→Binding→Meter 链同时承载 DSMR 和 WarmteLinkDSMR 正常行为/数字兼容。
- [ ] WarmteLink 双 channel 只读采集、质量接纳、latest/history、重连、隐私与部署全部符合 D1~D8。 - [x] WarmteLink 双 channel 只读采集、质量接纳、latest/history、重连、隐私与部署全部符合 D1~D8。
- [ ] electricity 成本可审计到 bindingthermal 合同、15 分钟账本、固定费、API/UI/HA 符合 D9D14。 - [x] electricity 成本可审计到 bindingthermal 合同、15 分钟账本、固定费、API/UI/HA 符合 D9D14。
- [ ] 历史升级对账、空库迁移、后端/前端/OpenAPI/codegen、Docker build 和 §12 walkthrough 全绿 - [x] 历史升级对账、空库迁移、后端/前端/OpenAPI/codegen、Docker build 与无生产挂载的临时 Docker 技术验收全绿;其输入只可为 mock、模拟数据和 `/tmp` 合成数据库
- [ ] 文档、Roadmap 和 milestone 状态反映真实完成度;没有删除用户数据、没有真实 secret/设备身份, - [x] 最终报告完整交付 §12 九步及用户证据模板;真实 serial/HA walkthrough 由用户在交付后自行验收,不能在报告中伪称 agent 已执行。
- [x] 文档、Roadmap 和 milestone 状态反映真实完成度;没有删除用户数据、没有真实 secret/设备身份,
没有未经授权的 push/tag。 没有未经授权的 push/tag。
+11
View File
@@ -49,3 +49,14 @@
- 更复杂的 backoff 策略 - 更复杂的 backoff 策略
这一轮重点是先把 app -> Home Assistant 的出站契约和可复用结构迁进来。 这一轮重点是先把 app -> Home Assistant 的出站契约和可复用结构迁进来。
## Energy、Source 与热力实体
Expose 框架还可以把已勾选的 Energy 实体通过 MQTT Home Assistant Discovery 发布;开关位于应用 Config 页的 HA Expose 面板,默认均为关闭。M8 增加了 source online、按 Meter UUID 锚定的累计量/today,以及 heating、hot-water-heating、water、water-tax、fixed、all-in total/today 等 thermal 实体。
- source 和 Meter identity 不依赖可变 label;换表会产生新 Meter UUID identity。
- thermal 组合成本 identity 由当前 heating/hot_water Meter UUID 的有序组合锚定,任一换表都会产生新 identity,避免不同累计域拼接。
- availability、unit、device/state class 与 today reset 由 provider 声明;operator 应在 HA 中核对,而不应假设同名实体可跨换表连续。
- 关闭 toggle 后 retained discovery 会被清理;关闭暴露不删除 source、Meter、合同、读数或成本历史。
WarmteLink 的 P1 质量会原样保留为 `unverifiable`(若适用),不因发布到 HA 而提升为 `valid`。不要将 raw telegram、equipment id、串口路径、合同金额或 API secret 作为 HA entity/state/attribute 发布。
+11 -1
View File
@@ -104,9 +104,19 @@ MQTT 上报的累计成本实体(`import_cost_total` / `export_revenue_total`
- 若是全新空库,初始表不创建(无历史数据)。 - 若是全新空库,初始表不创建(无历史数据)。
- 回填幂等:重复跑迁移不会创建多条初始表;回填后对账(非降级周期 `meter_id IS NULL` 数必须为 0)。 - 回填幂等:重复跑迁移不会创建多条初始表;回填后对账(非降级周期 `meter_id IS NULL` 数必须为 0)。
## M8Source binding 与热力 Meter
M8 将“协议连接”和物理 Meter 分开:`MeterSource` 产生稳定 channel`MeterSourceBinding``[started_at, ended_at)` 内把 channel 接到一个 Meter epoch。DSMR electricity 与 WarmteLink heating `GJ`、hot_water `m³` 都使用这条链。
- 正常成本周期的两端必须解析到**同一** Meter 和 binding;跨 epoch、跨 binding、无 binding、读数陈旧或质量不可接纳时一律 degraded,不跨累计域相减。
- source switch 是关闭/新建 binding,不创建假 Meter swap;实际换表才创建新的 Meter epoch。对于 thermalheating 与 hot_water 独立换表,热力组合 HA identity 因任一 UUID 改变而更新。
- `meter_cost_period` 为 heating 与 hot_water 保存 15 分钟 Decimal quantity/cost、binding、合同版本、price snapshot 和 degraded reason。固定费是合同级日汇总,只计一次。
部署与回滚串口 source 参见 [`warmtelink-energy.md`](./warmtelink-energy.md);保留旧 source/binding/history 可使审计和重算可重复,不能通过删除历史来“修复”边界周期。
## 非目标(本里程碑不做) ## 非目标(本里程碑不做)
- Gas / 区域供暖的计费(`commodity != "electricity"` strategy - Gas 计费 strategy。
- "家庭(home)"分组实体;多合同时间线积分。 - "家庭(home)"分组实体;多合同时间线积分。
- 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。 - 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。
- `last_reset` 信号(消除 HA 长期统计 blip)。 - `last_reset` 信号(消除 HA 长期统计 blip)。
+12 -10
View File
@@ -2,7 +2,7 @@
本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。 本文档记录 `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 也已完成并拆成 M8-T01~M8-T20,等待后续由编排器按依赖逐张实现 > 每个里程碑的设计与**可执行原子任务**展开在 [`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-T01~T20 的自动化技术验收均已完成;M8 交付后用户人工 walkthrough 待验收
## 当前基线(v1.0.3 ## 当前基线(v1.0.3
@@ -41,7 +41,7 @@
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 | | **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI | | **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI |
| **Pre-M8** ✅ | WarmteLink P1 真机概念验证 | 正式只读 CLI 长测通过;人工开启供暖后累计量 `0.017 → 0.018 GJ` 且与物理表一致,所有 frame 的 CRC 状态仍为 `unverifiable` | | **Pre-M8** ✅ | WarmteLink P1 真机概念验证 | 正式只读 CLI 长测通过;人工开启供暖后累计量 `0.017 → 0.018 GJ` 且与物理表一致,所有 frame 的 CRC 状态仍为 `unverifiable` |
| **M8** 📋 | WarmteLink P1、多数据源 Meter 与热力计费 | Planning 已完成:统一 Source/Channel/Binding、WarmteLink 双 channel、DSMR 迁移、thermal 合同/成本、HA/UI/部署M8-T01M8-T20 待实现 | | **M8** | WarmteLink P1、多数据源 Meter 与热力计费 | M8-T01~T20 自动化技术验收完成:Source/Channel/Binding、WarmteLink、DSMR 迁移、thermal 合同/成本、HA/UI部署与文档闭环;真实 serial/HA walkthrough 由用户交付后验收 |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
@@ -282,7 +282,7 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
--- ---
## M8 — WarmteLink P1、多数据源 Meter 与热力计费(📋 Planning 已完成,待实现 ## M8 — WarmteLink P1、多数据源 Meter 与热力计费(✅ 自动化技术验收已完成
### 目标 ### 目标
@@ -307,14 +307,16 @@ DSMR MQTT 与新的 WarmteLink serial source;从一个 WarmteLink source 只
### 原子实施链 ### 原子实施链
- **M8-T01T06**:统一 source/channel/binding schema、DSMR 历史/runtime/电费迁移和管理 API。 - **M8-T01T06**已完成统一 source/channel/binding schema、DSMR 历史/runtime/电费迁移和管理 API。
- **M8-T07T11**:共享 P1 parser、WarmteLink 标量存储、质量接纳、serial worker、发现与历史 API。 - **M8-T07T11**已完成共享 P1 parser、WarmteLink 标量存储、质量接纳、serial worker、发现与历史 API。
- **M8-T12T16**:合同 scope、district-heating profile、thermal cost 账本/引擎/API。 - **M8-T12T16**已完成合同 scope、district-heating profile、thermal cost 账本/引擎/API。
- **M8-T17T20**HA、Sources/Meters UIscope-aware 计费 UIcompose/文档/全链收尾。 - **M8-T17T19**已完成 HA、Sources/Meters UIscope-aware 计费 UI**M8-T20** 已完成 compose文档与隔离自动化技术收尾。
完成判据不仅是单元闸门全绿,还包括历史副本迁移对账、OpenAPI/codegen、全部前端闸门、真实 完成判据不仅是单元闸门全绿,还包括以 mock/fake、合成数据库和隔离 Docker 完成的历史迁移对账、
`docker build`非 root 串口部署和真机端到端 walkthrough。任何任务都不得删除旧数据库、历史 OpenAPI/codegen、全部前端闸门、真实 `docker build`非 root 串口部署技术验收;并须交付完整的
读数、旧 config 行或 volumepush/tag 仍需用户单独授权。 九步用户 walkthrough 和证据模板。真实 serial/HA 的观察由用户在交付后自行验收,不是 agent/Reviewer
技术 PASS、T20 状态更新或 M8 autosquash/收尾的前置条件,也不得写成已执行。任何任务都不得删除旧
数据库、历史读数、旧 config 行或 volumepush/tag 仍需用户单独授权。
> 完整架构、HTTP 契约、质量/计费规则、依赖图与 M8-T01~M8-T20 任务卡: > 完整架构、HTTP 契约、质量/计费规则、依赖图与 M8-T01~M8-T20 任务卡:
> [`docs/design/m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md) > [`docs/design/m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md)
+110
View File
@@ -0,0 +1,110 @@
# WarmteLink、数据源与热力计费运维手册
本手册说明如何在不改变既有 DSMR、Modbus 和 electricity 功能的前提下,部署只读的 WarmteLink P1 采集、绑定热量/生活热水 Meter、配置热力合同,并按需暴露给 Home Assistant。所有示例均使用占位符;不要把设备标识、GID、数据库路径、token 或真实合同金额提交到仓库。
## 安全边界与开始前备份
- 应用容器继续使用基础 `docker-compose.yml` 中的非 root `user: "1000:1000"`overlay 不设置 `user``privileged` 或额外 capability。
- Docker device cgroup 必须以 `rw` 映射,容器内固定为 `/dev/warmtelink`:这是 pyserial 3.5 在 POSIX 上以 `O_RDWR` 打开串口所需的最小系统权限,并不表示业务可写。规则绝不包含 `m`overlay 也不授予 root、`privileged` 或额外 capability。WarmteLink worker 仍只调用 serial `read` / `close`,绝不调用 `write` 或发送写命令。
- 不删除或覆盖 `app_config``app.db`、旧数据库、Docker volume 或既有 source。禁用/解绑/回滚配置不是删除历史的替代方式。
- 维护前停止写入窗口,使用宿主机的备份流程复制 `./data/app.db` 到受保护的备份位置;确认备份可用后才运行 migration。不要把生产库复制到开发机或用于测试。
## 识别稳定串口并启用 overlay
在宿主机(不是容器)找出稳定 symlink;不要使用会在重启后变化的 `/dev/ttyUSB*` 名称:
```bash
ls -l /dev/serial/by-id/
stable_path=/dev/serial/by-id/<stable-by-id-name>
stat -c '%g %n' "$stable_path"
```
记录输出的数字 GID,而不是猜测 `dialout` 的数值。确认 path 指向预期的字符设备后,临时导出变量:
```bash
export WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name>
export WARMTELINK_SERIAL_GID=<host-serial-gid>
docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml config
docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml up -d
```
`config``app` 必须仍显示 `user: "1000:1000"`,并只出现从 stable by-id path 到 `/dev/warmtelink``rw` device mapping 与 `group_add` GID;不得出现 `privileged`、root user 或 `m` device permission。`rw` 仅满足 pyserial 的 POSIX `O_RDWR` 打开,不改变 worker 的只读业务行为。默认部署不带第二个 `-f`
```bash
docker compose -f docker-compose.yml up -d
```
因此无串口硬件时默认 stack 不会声明不存在的 device。不得把设备路径或 GID 写回 `docker-compose.warmtelink.yml``.env.example` 或文档中的真实值。
## Migration 与 source 配置
先在维护窗口运行 migration;它只升级 schema,绝不删除历史表或配置:
```bash
docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml run --rm migration
```
登录 Energy 页面,在 **Sources** 创建 `warmtelink_serial` source
| 字段 | 值 |
| --- | --- |
| Path | `/dev/warmtelink` |
| Baud rate | `115200` |
| Data bits / parity / stop bits | `7` / `N` / `1` |
| Enabled | 先关闭,保存并检查配置后再开启 |
这些参数是固定 P1 profile。不要录入 telegram 或 equipment id;应用不会把原始 frame、设备身份或 serial 设置以外的协议标识保存到 source 配置中。开启后选择 **Discover**。成功发现后应有两个只读 channel:heating`GJ`)和 hot water`m³`);受 P1 CRC 限制,正常可接纳的质量可能显示为 `unverifiable`,这不是被错误提升为 `valid`
**Meters** 分别创建或选择 `heating``hot_water` Meter,并各自选择与单位相符的 channel 创建 binding。source switch 只关闭旧 binding、在相同 Meter 上创建新 binding,不是换表;真正的物理换表才使用 Meter swap。跨 binding 或 Meter 边界的 15 分钟成本周期会明确标为 degraded,不能相减伪造成本。
## 运行检查与排障
启用 source 后,latest 通常约每 10 秒更新,持久化 history 最多每分钟一条。检查 source 状态、channel latest/quality 和绑定时间线,而不是从日志中寻找原始 telegram。
| 现象 | 安全排查 |
| --- | --- |
| `offline` 或无法 open serial | 核对 stable symlink 是否仍存在、`stat` 的 GID 是否等于 `WARMTELINK_SERIAL_GID`,再用 `docker compose ... config` 检查 non-root `rw`(非 `r`、非 `m`device rule;不要用 root/privileged 绕过权限。 |
| 无 channels / discover 超时 | 确认 source 启用、`/dev/warmtelink` path 和固定 `115200 7N1`;检查电缆供电后重试 Discover。 |
| 短暂拔线 | worker 标记 offline 并以退避重连;插回后应恢复。检查 history 的 `(channel, recorded_at)` 唯一性,不能手工补重复行。 |
| 成本 degraded | 查两端读数 freshness120 秒)、quality、Meter epoch 与 binding;不要通过修改累计值清除 degraded。 |
若需要停采集,在 UI 禁用该 source,确认 worker 关闭串口后再维护电缆。删除有 channel、binding 或历史的 source 会被 API 拒绝;保留记录以保证审计和成本重算。
## 热力合同、成本与 Home Assistant
**Contracts** 选择 `Thermal` scope,创建 `district_heating` 合同及版本。费率由 operator 按合同人工录入,字段为 heatingEUR/GJ)、hot-water heating / water / taxEUR/m³)与五个年固定费字段;仓库不含任何真实默认金额。thermal 和 electricity 各可有一个 active 合同,彼此不互斥。
成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdownfixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。
在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。核对 unit、state class、availability、today reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity 或合同金额放进 HA entity 名称、日志或截图。
## 安全回滚
1. 在 UI 禁用 WarmteLink source,确认状态离线且 worker 已停止;保留 channels、bindings、history、contracts 与成本账本。
2. 停止 overlay stack 后,以默认 compose 启动:`docker compose -f docker-compose.yml up -d`。这会移除容器内 device 映射,但不会删除 `./data`、数据库、配置或 volumes。
3. 确认 DSMR、Modbus、电价、既有 electricity 成本和前端正常。若需要恢复 WarmteLink,按上面的 stable by-id/GID 步骤再次启用 overlay 和 source。
4. schema migration 不应以 production downgrade 回滚;只有经过验证的备份恢复流程才处理灾难恢复,且必须由 operator 在隔离维护窗口执行。
## 上线验收清单
- 默认 compose 在无 serial 硬件时正常启动。
- overlay 合并后 app/migration 均为非 root;只有 app 有 `/dev/warmtelink:rw` 和 serial GID,绝无 `m`、root 或 privileged。该 `rw` 仅为 pyserial 的 `O_RDWR` 打开,worker 业务仍只读,且没有真实设备/GID 被记录在仓库。
- 下列项目是交付后由用户在备份数据库、可回滚部署、真实 serial 设备和真实 HA 环境执行的人工验收;自动化技术验收不能替代这些观察,也不得把它们伪称为已执行。
### 用户人工验收记录(交付后填写)
本模板在交付后由用户填写;它不描述当前宿主环境,也不代表任何项目已通过。每项均填写日期、隔离部署
标识、§12 项号、预期观察、实际观察、脱敏日志/截图引用、回滚结果和结果状态。不得记录 stable device id、
GID、secret、数据库路径或合同金额;交付时所有项目默认均为“未执行”,不得填造真实环境结果。
| §12 项号 | 日期 | 隔离部署标识 | 预期观察 | 实际观察 | 脱敏日志/截图引用 | 回滚结果 | 结果状态 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1. 默认 stack 回归 | 待填写 | 待填写 | DSMR、Modbus、电价/电费、前端均无回归 | 待填写 | 待填写 | 待填写 | 未执行 |
| 2. stable by-id 与权限 | 待填写 | 待填写 | 非 root、非 privileged、`rw``m`pyserial 可打开,worker 无 `write` | 待填写 | 待填写 | 待填写 | 未执行 |
| 3. Discover 与隐私 | 待填写 | 待填写 | 两 channel/unit/quality;日志、DB、API、UI 均完成脱敏检查 | 待填写 | 待填写 | 待填写 | 未执行 |
| 4. history/拔插重连 | 待填写 | 待填写 | latest、分钟 history、拔插恢复且无重复记录 | 待填写 | 待填写 | 待填写 | 未执行 |
| 5. source switch / Meter swap | 待填写 | 待填写 | 两条时间线正确、边界 degraded、HA identity 按设计变化 | 待填写 | 待填写 | 待填写 | 未执行 |
| 6. thermal 合同/成本 | 待填写 | 待填写 | 脱敏测试费率手算一致;15 分钟与 01:05 fixed 正确 | 待填写 | 待填写 | 待填写 | 未执行 |
| 7. 双 active scope | 待填写 | 待填写 | electricity 数字对照一致;UI/成本/HA 不串 scope | 待填写 | 待填写 | 待填写 | 未执行 |
| 8. HA toggles | 待填写 | 待填写 | unit、state class、availability、today、identity、retained cleanup 正确 | 待填写 | 待填写 | 待填写 | 未执行 |
| 9. 重启与默认 compose 回滚 | 待填写 | 待填写 | 历史恢复;回默认 compose 后数据完整 | 待填写 | 待填写 | 待填写 | 未执行 |
+32
View File
@@ -72,6 +72,38 @@ def test_compose_uses_migration_job_before_app() -> None:
assert dev["services"]["app"]["build"] == "." assert dev["services"]["app"]["build"] == "."
def test_warmtelink_overlay_keeps_app_non_root_and_maps_minimal_serial_device_access() -> None:
"""The optional overlay grants only pyserial's minimal open permission.
Environment interpolation is deliberately left unresolved: operators supply
a host-specific stable by-id path and its numeric serial GID at deployment.
"""
base = _read_yaml("docker-compose.yml")
overlay = _read_yaml("docker-compose.warmtelink.yml")
base_app = base["services"]["app"]
base_migration = base["services"]["migration"]
app_overlay = overlay["services"]["app"]
assert base_app["user"] == "1000:1000"
assert base_migration["user"] == "1000:1000"
assert "privileged" not in base_app
assert "privileged" not in base_migration
assert "user" not in app_overlay
assert "privileged" not in app_overlay
assert app_overlay["devices"] == [
"${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw"
]
device_rule = app_overlay["devices"][0]
permissions = device_rule.rsplit(":", maxsplit=1)[1]
assert permissions == "rw"
assert "m" not in permissions
assert app_overlay["group_add"] == ["${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"]
assert "/dev/serial/by-id" in (PROJECT_ROOT / "docker-compose.warmtelink.yml").read_text()
assert "migration" not in overlay["services"]
assert "devices" not in base_migration
assert "privileged" not in base_migration
def test_image_defaults_to_uvicorn_only() -> None: def test_image_defaults_to_uvicorn_only() -> None:
dockerfile = (PROJECT_ROOT / "Dockerfile").read_text() dockerfile = (PROJECT_ROOT / "Dockerfile").read_text()
entrypoint = (PROJECT_ROOT / "docker/entrypoint.sh").read_text() entrypoint = (PROJECT_ROOT / "docker/entrypoint.sh").read_text()