Compare commits

...
4 Commits
Author SHA1 Message Date
tliu93 16b050d821 PRE-M8: add staged WarmteLink bring-up reference
frontend / frontend (push) Successful in 29s
pytest / test (push) Successful in 2m23s
2026-08-20 12:11:05 +02:00
tliu93 d3d914b117 PRE-M8: add WarmteLink P1 planning placeholders
frontend / frontend (push) Successful in 27s
pytest / test (push) Successful in 2m20s
2026-08-18 17:20:31 +02:00
tliu93 fc4af857e3 AGENTS: make repository guidance harness-neutral
frontend / frontend (push) Successful in 46s
pytest / test (push) Successful in 2m33s
2026-08-18 13:28:23 +02:00
tliu93 0958d9a2e9 fix(energy): report real kWh in the cost Summary instead of mislabelled money
docker-image / build-and-push (push) Successful in 1m37s
frontend / frontend (push) Successful in 10m24s
pytest / test (push) Successful in 11m57s
The Summary cards labelled `metered_import` / `metered_export` as "(kWh)", but
both fields are monetary totals (Σ import_cost / Σ export_revenue).  Today's
page therefore showed "Import 1.339 kWh" when the meter had actually imported
4.188 kWh — the 1.339 was EUR.  Cross-checked against the DSMR cumulative
registers and Home Assistant: our energy figures were correct all along, only
the label was wrong.

summarize() now also aggregates the metered energy, reusing the already-fetched
non-degraded rows so no extra query is issued:

  metered_import_kwh = Σ (d1_kwh + d2_kwh)
  metered_export_kwh = Σ (r1_kwh + r2_kwh)

The Import/Export cards show kWh as the headline figure and keep the monetary
equivalent as a sub-line, so the split between energy cost and standing
charges/credits behind total_payable stays visible.

The `_kwh` suffix is now the only thing separating energy from money in this
payload, so the docstrings on both summarize() and SummaryResponse call that
out explicitly.

app/integrations/expose.py reads only the money keys, so the HA outbound
sensors are unaffected by the additive fields.
2026-08-06 22:20:56 +02:00
15 changed files with 571 additions and 231 deletions
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+208
View File
@@ -0,0 +1,208 @@
# AGENTS.md — Home Automation Backend
本文件是本仓库 coding agent 指引的 **single source of truth**`CLAUDE.md` 通过符号链接指向本文件。它定义本项目的**工作流程、文档位置、commit 规范**。支持对应项目指引的 agent 在动手前应完整读取本文件。
## 项目速览
- 个人用 home-automation 应用:**FastAPI + React SPA + SQLite + SQLAlchemy + Alembic**,前后端同源托管。
- 单 admin 鉴权(Argon2 + server-side session cookie),runtime config 落 `app_config` 表。
- 模块:public IPv4 monitor、SMTP 通知、location / poo recorder、Home Assistant in/out、TickTick OAuth、Modbus / DSMR 能耗采集、MQTT / HA Discovery、动态电价与电费计算。
- 已发布 `v1.5.1`。M1、M2、M4-M7 已完成;M3 token / 移动端仍为远期方向。
- **当前现实**:已收敛为单一 `app.db`、一套 DeclarativeBase 和一条 Alembic 链;只有历史数据迁移 runbook 会读取旧 location / poo 数据库。
- 明确不做:Notion 模块。
## 文档地图与「开工前必读」
文档都在 `docs/`
| 路径 | 作用 |
| --- | --- |
| `docs/roadmap.md` | 全局规划与里程碑总览 |
| `docs/design/README.md` | **协作契约**:任务卡格式、原子任务定义、校验闸门、数据安全红线 |
| `docs/design/m1-db-consolidation.md` | M1 原子任务(含真实代码现状盘点 + 人工 runbook) |
| `docs/design/m2-frontend-v2.md` | M2 原子任务 + API 契约 + 前端校验闸门 |
| `docs/design/m3-token-mobile.md` | M3(远期,暂缓) |
| `docs/design/m4-login-hardening.md` | M4 登录加固(已完成) |
| `docs/design/m5-iot-energy.md` | M5 IoT / 能耗采集(已完成) |
| `docs/design/m6-tibber-dynamic-energy.md` | M6 动态电价、DSMR 与电费计算(已完成) |
| `docs/design/m7-meter-epochs-archival.md` | M7 电表生命周期 / 换表归档(已完成) |
| `docs/*.md`auth / public-ip-monitor / location-recorder …) | 各模块说明,按需读 |
**开工时读取顺序**
1. `docs/design/README.md`(每轮都读,它是流程与验收的共同契约)。
2. 本轮对应的 milestone 文档(如 `docs/design/m1-db-consolidation.md`),定位要做的任务卡。
3. 任务卡 `Files` 列出的源文件 + 该模块的 `docs/*.md`(按需)。
4. `docs/roadmap.md` 仅在需要全局视角时读。
## 工作流程
### 实现模式(由用户的提示词决定)
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(按下方**『默认能力档位』**选择模型,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。当前 harness 支持独立 sub-agent 时,使用其原生机制按下方**『默认能力档位』**派发;不支持时不得假装已创建 sub-agent,应明确说明限制,并仅在用户允许 fallback 时由主 agent 继续。
### 默认能力档位(实现模式 sub-agent;可被人工指定覆盖)
起 implementer / reviewer / fixer sub-agent 时,**默认**按下列能力档位选择当前 harness 支持的模型,无需用户每次人工指定:
| 角色 | 通用模型要求 | 推理档位 | Harness 示例(非强制) |
| --- | --- | --- | --- |
| **Implementer** | 平衡型代码实现模型 | `medium` 或等效档位 | Claude CodeSonnetCodex/OpenAIGPT-5.6 Terra (`gpt-5.6-terra`) |
| **Fixer**(返工) | 平衡型代码实现模型 | `medium` 或等效档位 | Claude CodeSonnetCodex/OpenAIGPT-5.6 Terra (`gpt-5.6-terra`) |
| **Reviewer** | 当前 harness 支持的最强通用推理 / 代码模型 | `extra-high` / `xhigh` 或等效档位 | Claude CodeOpusCodex/OpenAIGPT-5.6 Sol (`gpt-5.6-sol`) |
- **示例非强制**:示例模型只表示当前推荐映射,不构成跨 harness 的硬性模型 ID;当前 harness 不支持时,选择最符合「通用模型要求」的可用模型。
- **选择优先级**:用户显式指定 > 当前 harness 的原生角色配置 > 上表的 harness 示例 > 按通用模型要求自动选择。
- **推理档位说明**:若 harness 提供独立的 reasoning-effort 设置,按上表设置;若不提供,在 spawn prompt 中明确 implementer/fixer 按平衡深度思考,reviewer 按对抗性外部审计强度复核。
### 角色(Orchestrator → Implementer → Reviewer → Fixer
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`
- **Implementer**(平衡型代码实现模型,medium 或等效档位):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**(最强通用推理 / 代码模型,extra-high / xhigh 或等效档位):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
- **Fixer**(平衡型代码实现模型,medium 或等效档位):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
#### Reviewer 盲审纪律(M1 教训)
M1 里 review **从未触发过一次 rework**,根因是 orchestrator 把自己的结论 / 辩护喂给了 reviewer,造成 context bleed、review 沦为橡皮图章。所以:
- reviewer 必须**使用全新、独立的 sub-agent / thread 冷启动,并最小化喂料**——spawn prompt 只给:① 任务卡(`Acceptance criteria` + `Reviewer checklist`)、② 对应的 `review-notes/<task>-impl|rework-<n>.md` 路径、③ 要审的 diff / commit 范围。
- **不要**在 prompt 里塞 orchestrator 自己的判断、"我觉得没问题"、对实现选择的辩护,或上一轮 reviewer 的倾向性结论。让它**独立得出结论、独立重跑校验闸门**。
- 事后另起的整库**独立盲审**(如对抗复审)同理:使用全新独立的 agent / thread、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
### 校验闸门(每个任务结束都要全绿)
根目录、激活 `.venv` 后:
```bash
pytest # 权威闸门(CI 跑的就是它)
ruff check . # line-length=100
python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路由/schema 才需要,且产物须入库
```
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
**Repo-meta 例外**:纯文档、agent 指引、符号链接等不影响可执行代码、构建与 API 契约的变更,可在用户明确同意时跳过代码闸门。仍须完成针对性校验(如链接目标、文件类型、diff 与 Git 状态),并在结果中明确记录未运行哪些闸门。
#### API 契约同步:`openapi/` 与 `schema.d.ts` 是**两步**v1.4.0 后教训)
**只跑 `export_openapi.py` 不够。** 前端的 `frontend/src/api/schema.d.ts` 是由 `openapi/openapi.json` 二次生成的,CI`.github/workflows/frontend.yml`*Check codegen is in sync*)会重跑 codegen 并 `git diff --exit-code src/api/schema.d.ts`。漏了第二步 → 本地闸门全绿、远端 CI 红。真出过:`d07a083` 改了 `/api/energy/prices` 的 docstring 并同步了 `openapi.json`,但没重跑 codegen,只差一行注释就把 CI 挂了。
所以**只要动了路由 / Pydantic schema / 路由 docstring**docstring 也会进 OpenAPI description!),两步都要跑,两个产物都要入库:
```bash
# 1) 后端契约
python scripts/export_openapi.py && git diff --exit-code openapi/
# 2) 前端类型(在 frontend/ 下)
npm run codegen && git diff --exit-code src/api/schema.d.ts
```
- 判据:`git diff --exit-code openapi/` 有输出 → **必然**还要跑一次 `npm run codegen`
- 反过来也成立:`schema.d.ts` 不要手改,它是生成物。
- Reviewer 审"动了路由 / schema / 路由 docstring"类任务时,把**这两个产物是否都已重新生成并入库**当作 acceptance 的一部分。
### 构建上下文完整性(M1 Dockerfile 教训)
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile``COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
- 任务**删除 / 移动 / 重命名文件或目录**时,必须 grep 构建清单是否还在引用它们:`Dockerfile`(尤其 `COPY` 源)、`docker/``*.ini`、CI workflow、`requirements*.txt` 等。
- 已有回归测试 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 守"Dockerfile `COPY` 源必须存在于构建上下文";新增 / 改动 `COPY` 时确保它仍覆盖得到。
- Reviewer 审"删 / 移文件"类任务时,**必须顺带核对构建清单引用**,把它当 acceptance 的一部分。
## 每轮简报(`review-notes/`
由 milestone 任务卡驱动的每轮实现、返工或 review,都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。纯讨论、只读分析与不进入正式任务链的 repo-meta 变更无需产出简报,除非用户明确要求。
- **实现 / 返工简报**:每轮实现完成后(无论首次实现还是返工),写一份。文件名建议 `<task-id>-impl-<n>.md` / `<task-id>-rework-<n>.md`(如 `M1-T03-impl-1.md``M1-T03-rework-1.md`)。至少包含:
1. **本轮修改的具体内容**(改了哪些文件、做了什么、为什么)。
2. **自动化测试结果**`pytest` / `ruff` / 前端闸门的实际输出或结论,通过/失败逐项写清)。
3. **若需人工 walkthrough**:写明具体步骤(怎么启动、点哪里、预期看到什么);若无需人工验证,明确写"无需人工 walkthrough"。
- **review 简报**:每轮 review 后写一份,文件名建议 `<task-id>-review-<n>.md`(如 `M1-T03-review-1.md`)。至少包含:评审结论(`PASS` 或带编号的返工清单)、对照任务卡 `Acceptance criteria` + `Reviewer checklist` 的逐条核对、reviewer 独立重跑校验闸门的结果。
**用途**:① reviewer 审核时参考对应的实现简报;② implementer 返工时参考对应的 review 简报;③ 人类(用户)通读这些简报确认有无问题。简报之间用文件名里的 `<task-id>` 与轮次 `<n>` 对应起来。
### Orchestrator 派发契约(让简报真正被读到)
**关键**:sub-agent 冷启动、不继承主线上下文,**不会因为本文件提到简报就自动去读**对应文件。简报能流转,靠的是 orchestrator(主线)在**每次 spawn 时把路径显式写进 prompt**,而不是被动约定。所以派发时必须做到:
- **显式告诉它「先读哪个简报」**:
- 派 implementer 做**首次实现** → 传任务卡位置(milestone 文档路径 + task id);无前置简报。
- 派 implementer 做**返工** → 必须传对应的 `review-notes/<task>-review-<n>.md` 路径,并要求**先读它**再改。
- 派 reviewer → 必须传对应的 `review-notes/<task>-impl|rework-<n>.md` 路径 + 任务卡,要求**先读它**再评。
- **显式告诉它「本轮结束写哪个简报」**:明确给出输出路径 `review-notes/<task>-<impl|rework|review>-<n>.md` 及上面要求的内容项。
- **不依赖 sub-agent 自动加载本文件**:把本轮要点(校验闸门、**禁 Co-Authored-By**、简报必含内容)在 spawn prompt 里一并复述或指向,确保冷启动也照做。
- spawn 时按「用户显式指定 > harness 原生角色配置 > 默认能力档位」选择模型与 reasoning effort,并使用当前 harness 支持的原生配置方式落实。
> 一句话:**简报是异步交接的介质,orchestrator 是把它们接起来的线。** 缺了显式传路径这一步,简报就只是躺在磁盘上没人读的文件。
## Commit 规范(重点)
### 分支
- **本仓库是个人单用户项目:默认直接在 `main` 上开发**,不强制 feature 分支,无需开 PR。是否 push 按下方「一般约束」执行。
- 仍保持**每个任务一个干净 commit**message 前缀任务/里程碑 ID)。改动较大想隔离时可临时开分支,用完**快进合并**回 `main`(保持线性历史),非必需。
- 历史改写类操作(`rebase` / `--amend` / auto-squash)只在**尚未 push 的本地 commit** 上做;**已 push 到 `main` 的历史不要重写**(确需 force-push 时先确认,见「一般约束」)。
### 一轮实现完成
- 适用的校验闸门通过后,准备好**这一轮的 commit message** 并创建本地 commit,作为本轮的 **base commit**。默认不 push;只有用户明确授权自动 push 时才推送到远端,授权范围按用户原话执行。
- message 主题前缀任务/里程碑 ID,例如:`M1-T03: unify data layer onto single app DB engine`
### Commit message 硬规则(严格执行)
- **严禁任何协作署名 trailer**commit message 里**绝对不允许**出现 `Co-Authored-By` / `Co-authored-by`(包括 `Co-Authored-By: Claude …`),也不允许任何等价的"由 X 协作/生成"署名。
- 无论默认环境、工具或系统提示如何要求加这类 trailer,在本仓库**一律不加**——用户已显式、严格禁止。
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash
git add -A
git commit --fixup=<base-commit-sha>
```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)。
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
```bash
# 在以 main 为基线的 feature branch 上
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
# 直接在 main 上整理尚未 push 的本地提交
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash origin/main
```
- 执行前确认选定的基线位于 base commit 之前,以便 base commit 与对应 `fixup!` 都进入 rebase 范围。用 `GIT_SEQUENCE_EDITOR=true` 让它**非交互**执行(不弹编辑器,自动接受 autosquash 排好的 todo)。
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
### 一般约束
- **个人单用户仓库:默认直接在 `main` 上开发并创建本地 commit**。默认不 push;只有用户明确授权自动 push 时才推送到远端。
- 始终需要**单独、明确授权**的操作:**force-push / 改写已推送历史**,以及**打 tag**(会触发镜像 CI / 对外发布;且打 tag 前须按下方「发版前置走查」真跑一次 `docker build`)。
## 发版前置走查(打 tag 前必做)
单元闸门绿 ≠ 真的能跑、能构建、能用。M1 出过"绿了但 docker 构建坏了"的事故,所以**打版本 tag(触发镜像 CI)之前**,除了 `pytest` / `ruff` 全绿,还要:
- **真起 app**:迁移(`python -m scripts.run_migrations`)→ `uvicorn app.main:app ...`,确认能正常启动、关键路由不 500。
- **真跑镜像构建**:本地 `docker build`(多阶段就跑完整条),确认构建通过、`COPY` 源都在。
- **关键功能人工瞄一眼**:尤其前端 / 可视化类(M2 的热力图、首页地图)——自动闸门判断不了"渲染对不对、UX 顺不顺",这部分**靠看跑起来的 app,不靠读代码**。
- 上述任一不过 → **不打 tag**。tag 一旦 push 会触发 docker 镜像 CI / 对外发布,属对外操作,**先确认**。
## 数据安全红线(不可违反)
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
- 涉及历史数据的迁移**先在备份副本上演练**;迁移脚本必须幂等且搬完对账行数。
- Review 时只要发现"删文件 / drop 有数据的表 / truncate"出现在自动化任务里,直接判返工。
## 常用命令
```bash
# 环境
python -m venv .venv && source .venv/bin/activate && pip install -r dev-requirements.txt
# 迁移(初始化/适配 DB
python -m scripts.run_migrations
# 起服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 测试 / lint / OpenAPI 导出
pytest
ruff check .
python scripts/export_openapi.py
```
-198
View File
@@ -1,198 +0,0 @@
# CLAUDE.md — Home Automation Backend
本文件每次会话自动加载。它定义本项目的**工作流程、文档位置、commit 规范**。请在动手前先读完。
## 项目速览
- 个人用 home-automation 后端:**FastAPI + SQLite + SQLAlchemy + Alembic**,服务端模板(JinjaM2 将换成 React SPA)。
- 单 admin 鉴权(Argon2 + server-side session cookie),runtime config 落 `app_config` 表。
- 模块:public IPv4 monitor、SMTP 通知、location recorder、poo recorder、Home Assistant in/out、TickTick OAuth。
- 已发布 `v1.0.3`。下一阶段方向:**M1 单库化 → M2 React 前端 → M3 token/移动端(远期,M2 后再说)**。
- **当前现实**:在 M1 完成前仍是**三个独立 SQLite 库**app / location / poo),三套 DeclarativeBase、三条 Alembic 链。不要假设已经单库——以代码现状为准。
- 明确不做:Notion 模块。
## 文档地图与「开工前必读」
文档都在 `docs/`
| 路径 | 作用 |
| --- | --- |
| `docs/roadmap.md` | 全局规划与里程碑总览 |
| `docs/design/README.md` | **协作契约**:任务卡格式、原子任务定义、校验闸门、数据安全红线 |
| `docs/design/m1-db-consolidation.md` | M1 原子任务(含真实代码现状盘点 + 人工 runbook) |
| `docs/design/m2-frontend-v2.md` | M2 原子任务 + API 契约 + 前端校验闸门 |
| `docs/design/m3-token-mobile.md` | M3(远期,暂缓) |
| `docs/*.md`auth / public-ip-monitor / location-recorder …) | 各模块说明,按需读 |
**开工时读取顺序**
1. `docs/design/README.md`(每轮都读,它是流程与验收的共同契约)。
2. 本轮对应的 milestone 文档(如 `docs/design/m1-db-consolidation.md`),定位要做的任务卡。
3. 任务卡 `Files` 列出的源文件 + 该模块的 `docs/*.md`(按需)。
4. `docs/roadmap.md` 仅在需要全局视角时读。
## 工作流程
### 实现模式(由用户的提示词决定)
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(模型用下方**『默认模型档位』**,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时按下方**『默认模型档位』**选择模型(用户人工指定则以人工指定为准),用 Agent 工具的 `model` 字段落实。
### 默认模型档位(实现模式 sub-agent;可被人工指定覆盖)
起 implementer / reviewer / fixer sub-agent 时,**默认**用下列模型档位,无需用户每次人工指定:
| 角色 | 默认模型 | 推理档位 |
| --- | --- | --- |
| **Implementer** | **Sonnet** | high reasoning effort |
| **Fixer**(返工) | **Sonnet** | high reasoning effort |
| **Reviewer** | **Opus** | extra-high reasoning effort |
- **人工指定覆盖**:若用户在提示词里**显式指定了其他模型**(针对任一角色),则**以用户人工指定为准**,覆盖上述默认。
- 用 Agent 工具的 `model` 字段落实模型选择;该字段当前仅支持 `sonnet` / `opus` / `haiku` / `fable`
- **推理档位说明**:Agent 工具未暴露独立的 reasoning-effort 旋钮,"high / extra-high reasoning" 通过 spawn prompt 里的显式指令传达(要求 implementer/fixer 动手前充分推理边界条件;要求 reviewer 以对抗性外部审计心态最高强度复核)。若所在 harness 提供真正的 effort 设置,则一并按此档位设置。
### 角色(Orchestrator → Implementer → Reviewer → Fixer
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`
- **Implementer**(默认 **Sonnet**high reasoning;见上方『默认模型档位』):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**(默认 **Opus**extra-high reasoning):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
- **Fixer**(默认 **Sonnet**high reasoning):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
#### Reviewer 盲审纪律(M1 教训)
M1 里 review **从未触发过一次 rework**,根因是 orchestrator 把自己的结论 / 辩护喂给了 reviewer,造成 context bleed、review 沦为橡皮图章。所以:
- reviewer 必须**冷启动(Clear-Agent)、最小化喂料**——spawn prompt 只给:① 任务卡(`Acceptance criteria` + `Reviewer checklist`)、② 对应的 `review-notes/<task>-impl|rework-<n>.md` 路径、③ 要审的 diff / commit 范围。
- **不要**在 prompt 里塞 orchestrator 自己的判断、"我觉得没问题"、对实现选择的辩护,或上一轮 reviewer 的倾向性结论。让它**独立得出结论、独立重跑校验闸门**。
- 事后另起的整库**独立盲审**(如对抗复审)同理:Clear-Agent、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
### 校验闸门(每个任务结束都要全绿)
根目录、激活 `.venv` 后:
```bash
pytest # 权威闸门(CI 跑的就是它)
ruff check . # line-length=100
python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路由/schema 才需要,且产物须入库
```
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
#### API 契约同步:`openapi/` 与 `schema.d.ts` 是**两步**v1.4.0 后教训)
**只跑 `export_openapi.py` 不够。** 前端的 `frontend/src/api/schema.d.ts` 是由 `openapi/openapi.json` 二次生成的,CI`.github/workflows/frontend.yml`*Check codegen is in sync*)会重跑 codegen 并 `git diff --exit-code src/api/schema.d.ts`。漏了第二步 → 本地闸门全绿、远端 CI 红。真出过:`d07a083` 改了 `/api/energy/prices` 的 docstring 并同步了 `openapi.json`,但没重跑 codegen,只差一行注释就把 CI 挂了。
所以**只要动了路由 / Pydantic schema / 路由 docstring**docstring 也会进 OpenAPI description!),两步都要跑,两个产物都要入库:
```bash
# 1) 后端契约
python scripts/export_openapi.py && git diff --exit-code openapi/
# 2) 前端类型(在 frontend/ 下)
npm run codegen && git diff --exit-code src/api/schema.d.ts
```
- 判据:`git diff --exit-code openapi/` 有输出 → **必然**还要跑一次 `npm run codegen`
- 反过来也成立:`schema.d.ts` 不要手改,它是生成物。
- Reviewer 审"动了路由 / schema / 路由 docstring"类任务时,把**这两个产物是否都已重新生成并入库**当作 acceptance 的一部分。
### 构建上下文完整性(M1 Dockerfile 教训)
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile``COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
- 任务**删除 / 移动 / 重命名文件或目录**时,必须 grep 构建清单是否还在引用它们:`Dockerfile`(尤其 `COPY` 源)、`docker/``*.ini`、CI workflow、`requirements*.txt` 等。
- 已有回归测试 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 守"Dockerfile `COPY` 源必须存在于构建上下文";新增 / 改动 `COPY` 时确保它仍覆盖得到。
- Reviewer 审"删 / 移文件"类任务时,**必须顺带核对构建清单引用**,把它当 acceptance 的一部分。
## 每轮简报(`review-notes/`
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
- **实现 / 返工简报**:每轮实现完成后(无论首次实现还是返工),写一份。文件名建议 `<task-id>-impl-<n>.md` / `<task-id>-rework-<n>.md`(如 `M1-T03-impl-1.md``M1-T03-rework-1.md`)。至少包含:
1. **本轮修改的具体内容**(改了哪些文件、做了什么、为什么)。
2. **自动化测试结果**`pytest` / `ruff` / 前端闸门的实际输出或结论,通过/失败逐项写清)。
3. **若需人工 walkthrough**:写明具体步骤(怎么启动、点哪里、预期看到什么);若无需人工验证,明确写"无需人工 walkthrough"。
- **review 简报**:每轮 review 后写一份,文件名建议 `<task-id>-review-<n>.md`(如 `M1-T03-review-1.md`)。至少包含:评审结论(`PASS` 或带编号的返工清单)、对照任务卡 `Acceptance criteria` + `Reviewer checklist` 的逐条核对、reviewer 独立重跑校验闸门的结果。
**用途**:① reviewer 审核时参考对应的实现简报;② implementer 返工时参考对应的 review 简报;③ 人类(用户)通读这些简报确认有无问题。简报之间用文件名里的 `<task-id>` 与轮次 `<n>` 对应起来。
### Orchestrator 派发契约(让简报真正被读到)
**关键**:sub-agent 冷启动、不继承主线上下文,**不会因为本文件提到简报就自动去读**对应文件。简报能流转,靠的是 orchestrator(主线)在**每次 spawn 时把路径显式写进 prompt**,而不是被动约定。所以派发时必须做到:
- **显式告诉它「先读哪个简报」**:
- 派 implementer 做**首次实现** → 传任务卡位置(milestone 文档路径 + task id);无前置简报。
- 派 implementer 做**返工** → 必须传对应的 `review-notes/<task>-review-<n>.md` 路径,并要求**先读它**再改。
- 派 reviewer → 必须传对应的 `review-notes/<task>-impl|rework-<n>.md` 路径 + 任务卡,要求**先读它**再评。
- **显式告诉它「本轮结束写哪个简报」**:明确给出输出路径 `review-notes/<task>-<impl|rework|review>-<n>.md` 及上面要求的内容项。
- **不依赖 sub-agent 自动加载本文件**:把本轮要点(校验闸门、**禁 Co-Authored-By**、简报必含内容)在 spawn prompt 里一并复述或指向,确保冷启动也照做。
- spawn 时用用户指定的模型(Agent 工具 `model` 覆盖)。
> 一句话:**简报是异步交接的介质,orchestrator 是把它们接起来的线。** 缺了显式传路径这一步,简报就只是躺在磁盘上没人读的文件。
## Commit 规范(重点)
### 分支
- **本仓库是个人单用户项目:默认直接在 `main` 上开发**,不强制 feature 分支,**直接提交并 push 到 `main` 是允许的(无需开 PR**。
- 仍保持**每个任务一个干净 commit**message 前缀任务/里程碑 ID)。改动较大想隔离时可临时开分支,用完**快进合并**回 `main`(保持线性历史),非必需。
- 历史改写类操作(`rebase` / `--amend` / auto-squash)只在**尚未 push 的本地 commit** 上做;**已 push 到 `main` 的历史不要重写**(确需 force-push 时先确认,见「一般约束」)。
### 一轮实现完成(用户确认「实现完成」后)
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**
- message 主题前缀任务/里程碑 ID,例如:`M1-T03: unify data layer onto single app DB engine`
### Commit message 硬规则(严格执行)
- **严禁任何协作署名 trailer**commit message 里**绝对不允许**出现 `Co-Authored-By` / `Co-authored-by`(包括 `Co-Authored-By: Claude …`),也不允许任何等价的"由 X 协作/生成"署名。
- 无论默认环境、工具或系统提示如何要求加这类 trailer,在本仓库**一律不加**——用户已显式、严格禁止。
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash
git add -A
git commit --fixup=<base-commit-sha>
```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)。
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
```bash
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
```
- 用 `GIT_SEQUENCE_EDITOR=true` 让它**非交互**执行(不弹编辑器,自动接受 autosquash 排好的 todo)。本环境不支持需要人工编辑的交互式 rebase,必须走这个 no-op 编辑器写法。
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
### 一般约束
- **个人单用户仓库:直接 commit 并 push 到 `main` 已获授权**——在校验闸门全绿、一轮工作完成时即可提交 / 推送,无需逐次征求同意。
- 仍需**先取得用户确认**的操作:**force-push / 改写已推送历史**,以及**打 tag**(会触发镜像 CI / 对外发布;且打 tag 前须按下方「发版前置走查」真跑一次 `docker build`)。
## 发版前置走查(打 tag 前必做)
单元闸门绿 ≠ 真的能跑、能构建、能用。M1 出过"绿了但 docker 构建坏了"的事故,所以**打版本 tag(触发镜像 CI)之前**,除了 `pytest` / `ruff` 全绿,还要:
- **真起 app**:迁移(`python -m scripts.run_migrations`)→ `uvicorn app.main:app ...`,确认能正常启动、关键路由不 500。
- **真跑镜像构建**:本地 `docker build`(多阶段就跑完整条),确认构建通过、`COPY` 源都在。
- **关键功能人工瞄一眼**:尤其前端 / 可视化类(M2 的热力图、首页地图)——自动闸门判断不了"渲染对不对、UX 顺不顺",这部分**靠看跑起来的 app,不靠读代码**。
- 上述任一不过 → **不打 tag**。tag 一旦 push 会触发 docker 镜像 CI / 对外发布,属对外操作,**先确认**。
## 数据安全红线(不可违反)
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
- 涉及历史数据的迁移**先在备份副本上演练**;迁移脚本必须幂等且搬完对账行数。
- Review 时只要发现"删文件 / drop 有数据的表 / truncate"出现在自动化任务里,直接判返工。
## 常用命令
```bash
# 环境
python -m venv .venv && source .venv/bin/activate && pip install -r dev-requirements.txt
# 迁移(初始化/适配 DB
python -m scripts.run_migrations
# 起服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 测试 / lint / OpenAPI 导出
pytest
ruff check .
python scripts/export_openapi.py
```
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+18 -4
View File
@@ -121,15 +121,29 @@ class CostsResponse(BaseModel):
class SummaryResponse(BaseModel): class SummaryResponse(BaseModel):
"""Response for GET /api/energy/costs/summary. """Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``. Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
only the ``_kwh``-suffixed fields carry kWh.
``total_payable = metered_net + fixed_costs credits`` ``total_payable = metered_net + fixed_costs credits``
""" """
currency: str currency: str
metered_import: float = Field(description="Σ import_cost for non-degraded periods.") metered_import: float = Field(
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.") description="Σ import_cost for non-degraded periods (money, in `currency`)."
metered_net: float = Field(description="Σ net_cost for non-degraded periods.") )
metered_export: float = Field(
description="Σ export_revenue for non-degraded periods (money, in `currency`)."
)
metered_net: float = Field(
description="Σ net_cost for non-degraded periods (money, in `currency`)."
)
metered_import_kwh: float = Field(
description="Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
)
metered_export_kwh: float = Field(
description="Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
)
fixed_costs: float = Field( fixed_costs: float = Field(
description="Standing charges (network_fee + management_fee) apportioned over the interval." description="Standing charges (network_fee + management_fee) apportioned over the interval."
) )
+22 -3
View File
@@ -18,6 +18,12 @@ M6 design document, extended in M7-T03 to be meter-aware:
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting, ÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
apportioned at EUR/year ÷ 365 per day). apportioned at EUR/year ÷ 365 per day).
The summary reports **both** money and energy: ``metered_import`` /
``metered_export`` are monetary totals (Σ import_cost / Σ export_revenue),
while ``metered_import_kwh`` / ``metered_export_kwh`` are the corresponding
metered energy totals in kWh. The ``_kwh`` suffix is the only thing that
distinguishes them — always check it before labelling a value in a UI.
Design notes Design notes
------------ ------------
- **Decimal arithmetic throughout**: all monetary computations use - **Decimal arithmetic throughout**: all monetary computations use
@@ -732,9 +738,11 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
dict with keys: dict with keys:
currency str ISO 4217 currency (from contract, or "EUR" fallback) currency str ISO 4217 currency (from contract, or "EUR" fallback)
metered_import float Σ import_cost from non-degraded periods metered_import float Σ import_cost from non-degraded periods (money)
metered_export float Σ export_revenue from non-degraded periods metered_export float Σ export_revenue from non-degraded periods (money)
metered_net float Σ net_cost from non-degraded periods metered_net float Σ net_cost from non-degraded periods (money)
metered_import_kwh float Σ (d1_kwh + d2_kwh) from non-degraded periods (energy)
metered_export_kwh float Σ (r1_kwh + r2_kwh) from non-degraded periods (energy)
fixed_costs float standing charges for elapsed whole local days fixed_costs float standing charges for elapsed whole local days
credits float energy-tax credit for elapsed whole local days credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits total_payable float metered_net + fixed_costs credits
@@ -763,6 +771,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0")) sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0")) sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
# Σ metered energy (kWh), summed across both tariff registers. Reuses the
# already-fetched ``good_rows`` so no extra query is issued.
sum_import_kwh = sum(
(_to_decimal(r.d1_kwh) + _to_decimal(r.d2_kwh) for r in good_rows), Decimal("0")
)
sum_export_kwh = sum(
(_to_decimal(r.r1_kwh) + _to_decimal(r.r2_kwh) for r in good_rows), Decimal("0")
)
# --- Interval length in days (window, not elapsed — kept for API compat) --- # --- Interval length in days (window, not elapsed — kept for API compat) ---
total_seconds = (end_utc - start_utc).total_seconds() total_seconds = (end_utc - start_utc).total_seconds()
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
@@ -885,6 +902,8 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
"metered_import": float(sum_import), "metered_import": float(sum_import),
"metered_export": float(sum_export), "metered_export": float(sum_export),
"metered_net": float(sum_net), "metered_net": float(sum_net),
"metered_import_kwh": float(sum_import_kwh),
"metered_export_kwh": float(sum_export_kwh),
"fixed_costs": float(fixed_dec), "fixed_costs": float(fixed_dec),
"credits": float(credits_dec), "credits": float(credits_dec),
"total_payable": float(total_payable), "total_payable": float(total_payable),
+2
View File
@@ -9,6 +9,8 @@
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏) - [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
- [`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 真机概念验证(等待线到货)
- [`m8-warmtelink-energy.md`](./m8-warmtelink-energy.md) — WarmteLink P1 与多数据源 MeterPlanning 占位)
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。 本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
+50
View File
@@ -0,0 +1,50 @@
# M8 — WarmteLink P1 与多数据源 MeterPlanning 占位)
> **状态:Planning 占位;尚未拆解任务卡,尚未锁定架构。** M8 必须等待 [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md)完成后再进入正式设计。
## 1. 候选目标
把 Vattenfall WarmteLink 的 P1 数据接入现有 Energy 模块,至少支持:
- 区域供暖累计热量(GJ)。
- 真机 telegram 确认存在时的生活热水累计量(预计为 m³,最终以实测为准)。
- 历史读数、当前状态以及按需暴露给 Home Assistant。
- 与现有 Meter epoch/换表归档语义兼容。
## 2. 当前已知边界
- 现有 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`
## 3. 下一轮 Planning 必须讨论的问题
以下问题当前全部保持开放,不在本占位文档中拍板:
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 与权限边界。
## 4. Planning 入口条件
正式编写 M8 目标架构、数据模型和原子任务卡前,至少需要:
- Pre-M8 通过并留下脱敏字段清单。
- 确认实际存在几个累计量及其单位、equipment id/channel 和更新时间。
- 确认原始 telegram 的稳定性与 parser 适配方式。
- 重新走查现有 DSMR ingest、Meter epoch、Modbus device、expose/HA 和 Energy 前端边界。
- 与用户讨论并锁定 Meter ↔ source 的配置体验后,再决定 migration/API/UI 方案。
## 5. 当前明确不做
- 本占位不创建 implementation task,不授权 schema/API/frontend 变更。
- 不假设生活热水 m³ 一定可读,也不承诺可拆分“空间供暖 GJ”和“生活热水 GJ”。
- 不提前把 WarmteLink 塞进 `modbus_device` 或现有 `dsmr_reading`
- 不在缺少真机证据时设计通用 telemetry framework。
+126
View File
@@ -0,0 +1,126 @@
# Pre-M8 — WarmteLink P1 真机概念验证
> **状态:等待 USB→P1 线到货后执行。** 本文只定义验证边界与证据要求;当前仓库尚未实现下文所示的 probe 命令。
## 1. 目的
在进入 M8 正式设计和实现前,先用新家的 Vattenfall WarmteLink 做一次只读真机验证,回答以下问题:
1. 当前 USB→P1 线、主机串口权限和 WarmteLink P1 端口能否稳定输出完整 telegram。
2. telegram 的 framing、CRC、时间戳、OBIS/M-Bus channel 和单位能否被 parser 正确识别。
3. 实际能够读取哪些累计量:区域供暖热量(GJ)、生活热水体积(m³)或其它字段。
4. 读数的精度、更新频率和累计语义,是否与热力表/水表面板上的数字一致。
Pre-M8 是 M8 的证据门:在真机字段和语义确认前,不决定数据库结构、Meter 数据源绑定、后台采集服务或前端布局。
## 2. 执行边界
本阶段只建立下面这条最短链路:
```text
WarmteLink P1 → USB serial → 完整 telegram → CRC 校验 → 字段解析 → 终端输出
```
明确不做:
- 不写入 `app.db`,不新增 Alembic migration。
- 不新增 FastAPI API、后台常驻 worker、配置页面或 Energy 前端。
- 不发布 MQTT / Home Assistant Discovery。
- 不修改现有 DSMR Reader MQTT、电费计算或 Meter 逻辑。
- 不在本阶段决定 WarmteLink 应落在哪个正式 Device/Source 模型中。
## 3. 预期操作方式
线到货后,在 workspace 的 virtual environment 中实现并运行一个只读 probe。命令形态暂定为:
```bash
source .venv/bin/activate
python -m scripts.p1_probe \
--device /dev/serial/by-id/<usb-p1-device> \
--duration 600 \
--show-changes \
--raw-output /tmp/warmtelink-p1-telegram.txt
```
最终参数名可在实现 probe 时调整,但应保留这些能力:
- 使用稳定的 `/dev/serial/by-id/...` 路径,而不是依赖可能变化的 `/dev/ttyUSB0`
- 连续读取多帧,而不是只看一帧偶然样本。
- 同时显示完整帧/CRC 结果、原始 OBIS 字段和解析后的值/单位。
- 枚举 telegram 中出现的所有 M-Bus channel、device type、equipment id、capture timestamp、value 和 unit,不依赖固定字段顺序。
- 可只显示发生变化的字段,便于观察更新频率。
- 原始 telegram 默认只写到 `/tmp`;未经脱敏不提交到 Git。
### 3.1 分两步 bring-upBash 冒烟验证 → Python probe
Home Assistant Community 的一份 WarmteLink 实例提供了一个适合作为硬件
bring-up 起点的[最小 Bash 读取方法](https://community.home-assistant.io/t/solved-dsmr-add-warmtelink-as-data-source/485255/2)
先把串口设为 115200 baud,逐行读取设备,并从带 `GJ` 的行中取出累计值。该帖展示的
telegram 样例还给出了以下**候选事实**:
- 设备头为 `/NWA-WARMTELINK`,版本字段为 `1-3:0.2.8(50)`
- M-Bus channel 1 的 device type 样例为 `004`
- 累计热量样例位于 `0-1:24.2.1(<capture timestamp>)(<value>*GJ)`
- telegram 以 `!` 加四位 CRC 结束。
这些是其他用户在 2022 年记录的单机样本,只用于提出假设,不能替代本机 firmware、线材和
实际 telegram 的验证。当前 Home Assistant 的
[DSMR 文档](https://www.home-assistant.io/integrations/dsmr/)确认其 DSMR 集成支持 DSMR v5 与
M-Bus subdevice;该集成底层使用
[`dsmr_parser`](https://github.com/ndokter/dsmr_parser)。实现 probe 时可把它作为候选解析基线
进行对照,但是否引入为本项目正式依赖留到 M8 Planning 决定。
线到货后的执行顺序调整为:
1. **Bash 冒烟验证**:用稳定的 `/dev/serial/by-id/...` 路径配置串口并短时读取;先保留完整
原始字节流,再确认是否能看到 `/NWA-WARMTELINK`、帧尾和带 `GJ` 的行。论坛脚本中的
`GJ` 文本提取只能用作快速可见性检查,不能算解析或验收通过。
2. **Python probe**:在已确认物理链路工作的前提下,实现上面的 `scripts.p1_probe`,完成
完整 framing、CRC、全部字段枚举、结构化解析、连续多帧变化观察和人工面板对照。
不复制论坛脚本的 MQTT 发布步骤:Pre-M8 仍只输出到终端和 `/tmp`MQTT / Home Assistant
集成属于 M8 设计范围。
## 4. 人工对照
probe 运行期间,人工从热力表和相关水表面板记录同一时间附近的显示值,并与终端结果对照:
| 检查项 | 需要记录 |
| --- | --- |
| 区域供暖 | 面板累计值、P1 值、单位、两者时间差 |
| 生活热水 | 面板累计值、P1 是否存在对应字段、单位、两者时间差 |
| 更新时间 | 连续 telegram 中数值变化的间隔 |
| 累计语义 | 数值是否单调累计,是否出现每日归零或其它重置 |
允许 P1 capture time 与按表时间之间存在合理延迟;不能只凭数值接近就认定字段含义,必须同时核对单位、channel/device type 和时间戳。
## 5. 通过条件
Pre-M8 完成需留下以下证据:
- [ ] 连续收到可识别为 WarmteLink 的完整 telegram。
- [ ] CRC 校验通过;若失败,已区分串口/线材问题与 parser 问题。
- [ ] parser 不依赖字段固定顺序,并列出全部实际 channel/OBIS 字段。
- [ ] 找到 GJ 累计值并与热力表面板对照,误差可由显示精度或 capture 延迟解释。
- [ ] 明确实际 telegram 是否包含独立的生活热水 m³ 累计量;若包含,已与水表面板对照。
- [ ] 记录数值精度、telegram 频率、字段更新频率和累计/重置行为。
- [ ] 形成一份脱敏结果摘要,足以支持下一轮 M8 Planning。
如果只能确认 GJ、没有独立生活热水 m³,这也是有效结论,不视为 Pre-M8 失败。
## 6. 失败分类
- 完全无数据:优先检查 USB 识别、串口权限、P1 request line、线材方向/供电。
- 输出乱码或不成帧:优先检查串口参数、信号反相和线材兼容性。
- 原始帧完整但解析失败:保存脱敏样本,调整 parser/字段映射。
- 解析成功但面板对不上:检查 capture timestamp、累计语义、单位和 WarmteLink firmware 差异。
## 7. 向 M8 的交付物
Pre-M8 只向 M8 交付事实,不交付正式架构:
- 已脱敏的 telegram 结构与字段清单。
- GJ / 可选 m³ 的实际 channel、OBIS、单位、精度和更新时间。
- 串口参数、稳定设备路径与部署权限要求。
- parser 适配结论以及需要保留的异常样本。
- 对“一个来源包含几个可用计量通道”的实测结论。
+27 -1
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)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计 > 每个里程碑的设计与**可执行原子任务**展开在 [`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 占位,尚无可派发的实现任务卡
## 当前基线(v1.0.3 ## 当前基线(v1.0.3
@@ -40,6 +40,8 @@
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 | | **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
| **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 真机概念验证 | USB→P1 到货后用 workspace venv 只读采集实际 telegram,校验 CRC/解析,并与热力表和水表面板对照 |
| **M8** 📝 | WarmteLink P1 与多数据源 Meter | 等 Pre-M8 后规划直接 P1 采集、区域供暖读数及 Meter 与数据源的可配置关系;当前仅占位 |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
@@ -257,6 +259,30 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
--- ---
## Pre-M8 — WarmteLink P1 真机概念验证(⏳ 等待硬件)
### 目标
USB→P1 线到货后,先在 workspace virtual environment 中运行只读 probe,直接采集 Vattenfall WarmteLink 的实际 telegram。验证完整帧、CRC、OBIS/M-Bus channel、单位、精度和更新频率,并把解析出的 GJ 与热力表面板、可选 m³ 与水表面板进行人工对照。
本阶段不落库、不接 API/前端/HA,也不决定正式 Device/Source/Meter 关系。它只向 M8 提供脱敏的真机事实,避免在未知 firmware/字段语义上提前设计。
> 验证计划与通过条件:[`docs/design/pre-m8-warmtelink-p1-poc.md`](./design/pre-m8-warmtelink-p1-poc.md)
---
## M8 — WarmteLink P1 与多数据源 Meter(📝 Planning 占位)
### 候选目标
在 Pre-M8 事实基础上,把 WarmteLink P1 的区域供暖 GJ、以及真机确认存在时的生活热水 m³ 接入 Energy 模块,并讨论 Meter 如何与实际数据源建立可配置关系。
当前不锁定数据库、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)
---
## M3 — 开放与移动端(远期试水) ## M3 — 开放与移动端(远期试水)
### 目标 ### 目标
+16 -4
View File
@@ -2220,7 +2220,9 @@ export interface components {
* SummaryResponse * SummaryResponse
* @description Response for GET /api/energy/costs/summary. * @description Response for GET /api/energy/costs/summary.
* *
* All monetary values are in ``currency``. * Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
* in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
* only the ``_kwh``-suffixed fields carry kWh.
* *
* ``total_payable = metered_net + fixed_costs credits`` * ``total_payable = metered_net + fixed_costs credits``
*/ */
@@ -2229,19 +2231,29 @@ export interface components {
currency: string; currency: string;
/** /**
* Metered Import * Metered Import
* @description Σ import_cost for non-degraded periods. * @description Σ import_cost for non-degraded periods (money, in `currency`).
*/ */
metered_import: number; metered_import: number;
/** /**
* Metered Export * Metered Export
* @description Σ export_revenue for non-degraded periods. * @description Σ export_revenue for non-degraded periods (money, in `currency`).
*/ */
metered_export: number; metered_export: number;
/** /**
* Metered Net * Metered Net
* @description Σ net_cost for non-degraded periods. * @description Σ net_cost for non-degraded periods (money, in `currency`).
*/ */
metered_net: number; metered_net: number;
/**
* Metered Import Kwh
* @description Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh).
*/
metered_import_kwh: number;
/**
* Metered Export Kwh
* @description Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh).
*/
metered_export_kwh: number;
/** /**
* Fixed Costs * Fixed Costs
* @description Standing charges (network_fee + management_fee) apportioned over the interval. * @description Standing charges (network_fee + management_fee) apportioned over the interval.
+11 -2
View File
@@ -61,9 +61,13 @@ const COST_PERIOD = {
const SUMMARY = { const SUMMARY = {
currency: 'EUR', currency: 'EUR',
// Money totals and kWh totals are deliberately distinct so the assertions
// below prove the cards read the *_kwh fields, not the monetary ones.
metered_import: 10.5, metered_import: 10.5,
metered_export: 2.3, metered_export: 2.3,
metered_net: 8.2, metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0, fixed_costs: 5.0,
credits: 50.0, credits: 50.0,
total_payable: 12.5, total_payable: 12.5,
@@ -145,9 +149,14 @@ describe('CostView', () => {
expect(screen.getByTestId('summary-import')).toBeInTheDocument() expect(screen.getByTestId('summary-import')).toBeInTheDocument()
}) })
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500') // Main figure is energy (kWh), taken from the *_kwh fields.
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300') expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300')
expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400')
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50') expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
// Sub-line carries the monetary equivalent, so money is still visible.
expect(screen.getByTestId('summary-import-sub')).toHaveTextContent('10.50 EUR')
expect(screen.getByTestId('summary-export-sub')).toHaveTextContent('2.30 EUR')
}) })
it('shows recompute confirmation modal on button click', async () => { it('shows recompute confirmation modal on button click', async () => {
+12 -3
View File
@@ -77,10 +77,12 @@ function getThisMonthRange(): { start: string; end: string } {
interface SummaryCardProps { interface SummaryCardProps {
label: string label: string
value: string value: string
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
sub?: string
testId?: string testId?: string
} }
function SummaryCard({ label, value, testId }: SummaryCardProps) { function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
return ( return (
<Paper withBorder p="sm" data-testid={testId}> <Paper withBorder p="sm" data-testid={testId}>
<Stack gap={4}> <Stack gap={4}>
@@ -90,6 +92,11 @@ function SummaryCard({ label, value, testId }: SummaryCardProps) {
<Text fw={600} size="lg"> <Text fw={600} size="lg">
{value} {value}
</Text> </Text>
{sub !== undefined && (
<Text size="xs" c="dimmed" data-testid={testId ? `${testId}-sub` : undefined}>
{sub}
</Text>
)}
</Stack> </Stack>
</Paper> </Paper>
) )
@@ -207,12 +214,14 @@ export function CostView() {
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary"> <SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
<SummaryCard <SummaryCard
label="Import (kWh)" label="Import (kWh)"
value={summaryQuery.data.metered_import.toFixed(3)} value={summaryQuery.data.metered_import_kwh.toFixed(3)}
sub={`${summaryQuery.data.metered_import.toFixed(2)} ${currency}`}
testId="summary-import" testId="summary-import"
/> />
<SummaryCard <SummaryCard
label="Export (kWh)" label="Export (kWh)"
value={summaryQuery.data.metered_export.toFixed(3)} value={summaryQuery.data.metered_export_kwh.toFixed(3)}
sub={`${summaryQuery.data.metered_export.toFixed(2)} ${currency}`}
testId="summary-export" testId="summary-export"
/> />
<SummaryCard <SummaryCard
@@ -83,6 +83,8 @@ const SUMMARY = {
metered_import: 10.5, metered_import: 10.5,
metered_export: 2.3, metered_export: 2.3,
metered_net: 8.2, metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0, fixed_costs: 5.0,
credits: 50.0, credits: 50.0,
total_payable: 12.5, total_payable: 12.5,
+16 -4
View File
@@ -4727,17 +4727,27 @@
"metered_import": { "metered_import": {
"type": "number", "type": "number",
"title": "Metered Import", "title": "Metered Import",
"description": "Σ import_cost for non-degraded periods." "description": "Σ import_cost for non-degraded periods (money, in `currency`)."
}, },
"metered_export": { "metered_export": {
"type": "number", "type": "number",
"title": "Metered Export", "title": "Metered Export",
"description": "Σ export_revenue for non-degraded periods." "description": "Σ export_revenue for non-degraded periods (money, in `currency`)."
}, },
"metered_net": { "metered_net": {
"type": "number", "type": "number",
"title": "Metered Net", "title": "Metered Net",
"description": "Σ net_cost for non-degraded periods." "description": "Σ net_cost for non-degraded periods (money, in `currency`)."
},
"metered_import_kwh": {
"type": "number",
"title": "Metered Import Kwh",
"description": "Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
},
"metered_export_kwh": {
"type": "number",
"title": "Metered Export Kwh",
"description": "Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
}, },
"fixed_costs": { "fixed_costs": {
"type": "number", "type": "number",
@@ -4776,6 +4786,8 @@
"metered_import", "metered_import",
"metered_export", "metered_export",
"metered_net", "metered_net",
"metered_import_kwh",
"metered_export_kwh",
"fixed_costs", "fixed_costs",
"credits", "credits",
"total_payable", "total_payable",
@@ -4784,7 +4796,7 @@
"days" "days"
], ],
"title": "SummaryResponse", "title": "SummaryResponse",
"description": "Response for GET /api/energy/costs/summary.\n\nAll monetary values are in ``currency``.\n\n``total_payable = metered_net + fixed_costs credits``" "description": "Response for GET /api/energy/costs/summary.\n\nMonetary values are in ``currency``; the ``*_kwh`` fields are energy totals\nin kWh. ``metered_import``/``metered_export`` are **money**, not energy —\nonly the ``_kwh``-suffixed fields carry kWh.\n\n``total_payable = metered_net + fixed_costs credits``"
}, },
"TibberTestPriceSchema": { "TibberTestPriceSchema": {
"properties": { "properties": {
+20 -4
View File
@@ -3659,15 +3659,25 @@ components:
metered_import: metered_import:
type: number type: number
title: Metered Import title: Metered Import
description: Σ import_cost for non-degraded periods. description: Σ import_cost for non-degraded periods (money, in `currency`).
metered_export: metered_export:
type: number type: number
title: Metered Export title: Metered Export
description: Σ export_revenue for non-degraded periods. description: Σ export_revenue for non-degraded periods (money, in `currency`).
metered_net: metered_net:
type: number type: number
title: Metered Net title: Metered Net
description: Σ net_cost for non-degraded periods. description: Σ net_cost for non-degraded periods (money, in `currency`).
metered_import_kwh:
type: number
title: Metered Import Kwh
description: Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported,
kWh).
metered_export_kwh:
type: number
title: Metered Export Kwh
description: Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported,
kWh).
fixed_costs: fixed_costs:
type: number type: number
title: Fixed Costs title: Fixed Costs
@@ -3699,6 +3709,8 @@ components:
- metered_import - metered_import
- metered_export - metered_export
- metered_net - metered_net
- metered_import_kwh
- metered_export_kwh
- fixed_costs - fixed_costs
- credits - credits
- total_payable - total_payable
@@ -3709,7 +3721,11 @@ components:
description: 'Response for GET /api/energy/costs/summary. description: 'Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``. Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
only the ``_kwh``-suffixed fields carry kWh.
``total_payable = metered_net + fixed_costs credits``' ``total_payable = metered_net + fixed_costs credits``'
+33
View File
@@ -990,6 +990,38 @@ class TestSummarize:
# Σnet ≈ 2 × 0.4051 = 0.8102 # Σnet ≈ 2 × 0.4051 = 0.8102
assert abs(result["metered_net"] - 0.8102) < 1e-6 assert abs(result["metered_net"] - 0.8102) < 1e-6
def test_metered_kwh_sums(self, energy_db: Session) -> None:
"""The *_kwh totals sum both tariff registers and are distinct from the money totals."""
self._setup_two_periods(energy_db)
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
# Per period: d1=0.5, d2=1.2 → import 1.7 kWh; r1=0.0, r2=0.1 → export 0.1 kWh.
assert abs(result["metered_import_kwh"] - 3.4) < 1e-6, (
f"expected Σ(d1+d2) = 2 × 1.7 = 3.4 kWh, got {result['metered_import_kwh']}"
)
assert abs(result["metered_export_kwh"] - 0.2) < 1e-6, (
f"expected Σ(r1+r2) = 2 × 0.1 = 0.2 kWh, got {result['metered_export_kwh']}"
)
# Regression guard for the mislabelled-unit bug: energy and money totals
# must never be conflated (import 3.4 kWh vs 0.8202 EUR of import cost).
assert result["metered_import_kwh"] != result["metered_import"]
assert result["metered_export_kwh"] != result["metered_export"]
def test_metered_kwh_excludes_degraded_periods(self, energy_db: Session) -> None:
"""Degraded periods contribute no kWh, mirroring the money totals."""
self._setup_two_periods(energy_db)
# Degrade the first period; its kWh must drop out of the totals.
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
row.degraded = True
energy_db.commit()
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert result["degraded_count"] == 1
assert abs(result["metered_import_kwh"] - 1.7) < 1e-6
assert abs(result["metered_export_kwh"] - 0.1) < 1e-6
def test_period_count(self, energy_db: Session) -> None: def test_period_count(self, energy_db: Session) -> None:
self._setup_two_periods(energy_db) self._setup_two_periods(energy_db)
result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
@@ -2708,6 +2740,7 @@ class TestSummarizeSettlementOffset:
expected_keys = { expected_keys = {
"currency", "metered_import", "metered_export", "metered_net", "currency", "metered_import", "metered_export", "metered_net",
"metered_import_kwh", "metered_export_kwh",
"fixed_costs", "credits", "total_payable", "period_count", "fixed_costs", "credits", "total_payable", "period_count",
"degraded_count", "days", "degraded_count", "days",
} }