Compare commits

...
7 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
tliu93 b405aea88b docs: require frontend codegen alongside the OpenAPI export in the gates
frontend / frontend (push) Successful in 9m59s
pytest / test (push) Successful in 11m51s
docker-image / build-and-push (push) Successful in 12m49s
The local gate list only covered `scripts/export_openapi.py` + a clean
`openapi/` diff, but `frontend/src/api/schema.d.ts` is generated from that JSON
and CI re-runs `npm run codegen` with `git diff --exit-code`. d07a083 changed a
route docstring, refreshed openapi.json, and skipped codegen — local green,
remote red on a one-line comment diff. Spell out both steps and note that route
docstrings feed the OpenAPI description too.

Also add AGENTS.md as a symlink to CLAUDE.md so other agent tooling picks up
the same contract.
2026-07-27 19:03:03 +02:00
tliu93 bfc7aa3031 chore(frontend): regenerate API schema after sell_fee docstring change
frontend / frontend (push) Successful in 9m59s
pytest / test (push) Successful in 12m27s
d07a083 changed the /api/energy/prices docstring (sell now deducts sell_fee)
and refreshed openapi/openapi.json, but frontend/src/api/schema.d.ts was not
regenerated, so CI's "check codegen is in sync" step failed. Comment-only diff.
2026-07-27 18:29:54 +02:00
tliu93 2f63b9630c fix(prices): keep hover marker on the hovered slot past midnight
The price chart keyed its X axis on formatLocalTime() "HH:mm" labels, which
repeat across a today+tomorrow range. Recharts resolves axis tooltips by value
(combineTooltipPayload -> findEntryInArray), so hovering a slot after midnight
matched today's identically labelled point: the tooltip showed today's prices
and the active dot jumped back to today's position instead of following the
cursor.

Key the axis on the ISO instant instead (buildChartRows, sorted by slot start)
and format down to HH:mm in the tick formatter; the tooltip label now carries
the date so today and tomorrow are distinguishable.

Also mark the price slot currently in effect by default: findActiveSlotIndex()
locates the slot containing now, rendered as a ReferenceDot on the buy and sell
lines plus a caption, re-evaluated every 30s.

Regression test drives a real mousemove over a sized chart in jsdom and asserts
the resolved slot and active-dot position.
2026-07-27 18:29:08 +02:00
17 changed files with 964 additions and 231 deletions
+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
```
-181
View File
@@ -1,181 +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)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
### 构建上下文完整性(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):
"""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``
"""
currency: str
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
metered_import: float = Field(
description="Σ import_cost for non-degraded periods (money, in `currency`)."
)
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(
description="Standing charges (network_fee + management_fee) apportioned over the interval."
)
+29 -10
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,
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
------------
- **Decimal arithmetic throughout**: all monetary computations use
@@ -731,16 +737,18 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
-------
dict with keys:
currency str ISO 4217 currency (from contract, or "EUR" fallback)
metered_import float Σ import_cost from non-degraded periods
metered_export float Σ export_revenue from non-degraded periods
metered_net float Σ net_cost from non-degraded periods
fixed_costs float standing charges for elapsed whole local days
credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits
period_count int number of non-degraded periods in range
degraded_count int number of degraded periods in range
days float interval length in days (total_seconds / 86400)
currency str ISO 4217 currency (from contract, or "EUR" fallback)
metered_import float Σ import_cost from non-degraded periods (money)
metered_export float Σ export_revenue from non-degraded periods (money)
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
credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits
period_count int number of non-degraded periods in range
degraded_count int number of degraded periods in range
days float interval length in days (total_seconds / 86400)
"""
from datetime import timedelta as _td, date as _date
@@ -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_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) ---
total_seconds = (end_utc - start_utc).total_seconds()
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_export": float(sum_export),
"metered_net": float(sum_net),
"metered_import_kwh": float(sum_import_kwh),
"metered_export_kwh": float(sum_export_kwh),
"fixed_costs": float(fixed_dec),
"credits": float(credits_dec),
"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 + 前端侧边栏)
- [`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 真机概念验证(等待线到货)
- [`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` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
> 每个里程碑的**可执行原子任务**展开在 [`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
@@ -40,6 +40,8 @@
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
| **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 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** 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 — 开放与移动端(远期试水)
### 目标
+17 -5
View File
@@ -261,7 +261,7 @@ export interface paths {
*
* Response ``points`` carries per-slot:
* - ``buy = total`` (Tibber all-inclusive price)
* - ``sell = total energy_tax sell_adjust`` (from active version values)
* - ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)
* - ``level`` (Tibber price level, may be null)
*
* ``tariff`` is null.
@@ -2220,7 +2220,9 @@ export interface components {
* SummaryResponse
* @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``
*/
@@ -2229,19 +2231,29 @@ export interface components {
currency: string;
/**
* Metered Import
* @description Σ import_cost for non-degraded periods.
* @description Σ import_cost for non-degraded periods (money, in `currency`).
*/
metered_import: number;
/**
* Metered Export
* @description Σ export_revenue for non-degraded periods.
* @description Σ export_revenue for non-degraded periods (money, in `currency`).
*/
metered_export: number;
/**
* Metered Net
* @description Σ net_cost for non-degraded periods.
* @description Σ net_cost for non-degraded periods (money, in `currency`).
*/
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
* @description Standing charges (network_fee + management_fee) apportioned over the interval.
+11 -2
View File
@@ -61,9 +61,13 @@ const COST_PERIOD = {
const SUMMARY = {
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_export: 2.3,
metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0,
credits: 50.0,
total_payable: 12.5,
@@ -145,9 +149,14 @@ describe('CostView', () => {
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
})
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500')
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300')
// Main figure is energy (kWh), taken from the *_kwh fields.
expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300')
expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400')
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 () => {
+12 -3
View File
@@ -77,10 +77,12 @@ function getThisMonthRange(): { start: string; end: string } {
interface SummaryCardProps {
label: string
value: string
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
sub?: string
testId?: string
}
function SummaryCard({ label, value, testId }: SummaryCardProps) {
function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
return (
<Paper withBorder p="sm" data-testid={testId}>
<Stack gap={4}>
@@ -90,6 +92,11 @@ function SummaryCard({ label, value, testId }: SummaryCardProps) {
<Text fw={600} size="lg">
{value}
</Text>
{sub !== undefined && (
<Text size="xs" c="dimmed" data-testid={testId ? `${testId}-sub` : undefined}>
{sub}
</Text>
)}
</Stack>
</Paper>
)
@@ -207,12 +214,14 @@ export function CostView() {
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
<SummaryCard
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"
/>
<SummaryCard
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"
/>
<SummaryCard
+253 -3
View File
@@ -6,10 +6,14 @@
* 2. Empty state (no active contract / no kind).
* 3. Renders tibber chart when tibber kind data is available.
* 4. Shows tariff table for manual kind.
* 5. Marks the currently active price slot (dot + caption).
* 6. Hovering past midnight resolves tomorrow's slot, not today's (regression).
* 7. buildChartRows: unique X keys across midnight.
* 8. findActiveSlotIndex: which slot is currently active.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
// ---------------------------------------------------------------------------
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
// Import component
// ---------------------------------------------------------------------------
import { TibberPrices } from './TibberPrices'
import { TibberPrices, buildChartRows, findActiveSlotIndex } from './TibberPrices'
// ---------------------------------------------------------------------------
// Chart size harness
//
// jsdom reports every element as 0x0, so Recharts renders an empty plot and no
// pointer interaction is possible. These helpers hand the chart a fixed size:
// - the ResponsiveContainer gets 800x300 from its bounding rect + a ResizeObserver
// that reports the same size,
// - the chart wrapper reports 800x260 (the height the component asks for), which
// is what Recharts uses to translate clientX/clientY into chart coordinates,
// - everything else stays 0x0 so the legend does not eat the whole plot area.
// ---------------------------------------------------------------------------
const CHART_W = 800
const CONTAINER_H = 300
const CHART_H = 260
function fakeRect(width: number, height: number): DOMRect {
return {
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => {},
} as DOMRect
}
const originalResizeObserver = globalThis.ResizeObserver
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth',
)
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
)
function installChartSize() {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.classList.contains('recharts-responsive-container')) {
return fakeRect(CHART_W, CONTAINER_H)
}
if (this.classList.contains('recharts-wrapper')) return fakeRect(CHART_W, CHART_H)
return fakeRect(0, 0)
})
// Recharts divides rect size by offset size to undo CSS transform scaling;
// matching them keeps the scale factor at 1.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: CHART_W,
})
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
value: CHART_H,
})
globalThis.ResizeObserver = class implements ResizeObserver {
private readonly cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) {
this.cb = cb
}
observe() {
this.cb(
[{ contentRect: { width: CHART_W, height: CONTAINER_H } } as ResizeObserverEntry],
this,
)
}
unobserve() {}
disconnect() {}
}
}
function restoreChartSize() {
globalThis.ResizeObserver = originalResizeObserver
if (offsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthDescriptor)
}
if (offsetHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightDescriptor)
}
}
/** Hourly price points, one per hour starting at `startUtc`, with unique prices. */
function hourlyPoints(startUtc: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
starts_at: new Date(startUtc + i * 3600_000).toISOString(),
buy: 0.1 + i / 1000,
sell: 0.05 + i / 1000,
level: 'NORMAL',
}))
}
function tooltipText(): string {
return document.querySelector('.recharts-tooltip-wrapper')?.textContent ?? ''
}
// ---------------------------------------------------------------------------
// Tests
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
describe('TibberPrices', () => {
beforeEach(() => vi.clearAllMocks())
afterEach(() => {
vi.restoreAllMocks()
restoreChartSize()
})
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
})
it('marks the currently active price slot with a dot and a caption', async () => {
installChartSize()
const SLOT_MS = 15 * 60 * 1000
// Start of the quarter-hour slot that contains "now".
const currentSlot = Math.floor(Date.now() / SLOT_MS) * SLOT_MS
mockGet.mockResolvedValue({
data: {
kind: 'tibber',
currency: 'EUR',
points: [
{ starts_at: new Date(currentSlot - SLOT_MS).toISOString(), buy: 0.11, sell: 0.05 },
{ starts_at: new Date(currentSlot).toISOString(), buy: 0.2431, sell: 0.1102 },
{ starts_at: new Date(currentSlot + SLOT_MS).toISOString(), buy: 0.31, sell: 0.15 },
],
tariff: null,
},
})
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('tibber-current-price')).toBeInTheDocument()
})
const marker = screen.getByTestId('tibber-current-price')
expect(marker).toHaveTextContent('0.2431')
expect(marker).toHaveTextContent('0.1102')
// One dot on the buy line, one on the sell line — visible without hovering.
await waitFor(() => {
expect(document.querySelectorAll('.recharts-reference-dot')).toHaveLength(2)
})
})
it('resolves the hovered slot past midnight to tomorrow, not today', async () => {
installChartSize()
// 26 hourly points starting at 2020-01-01T00:00Z, so "00:00" and "01:00"
// each appear twice. Fixed past dates keep the "now" marker out of range.
const points = hourlyPoints(Date.UTC(2020, 0, 1), 26)
mockGet.mockResolvedValue({
data: { kind: 'tibber', currency: 'EUR', points, tariff: null },
})
renderWithProviders(<TibberPrices />)
await waitFor(() => expect(screen.getByTestId('tibber-chart')).toBeInTheDocument())
expect(screen.queryByTestId('tibber-current-price')).not.toBeInTheDocument()
const wrapper = document.querySelector('.recharts-wrapper')
expect(wrapper).not.toBeNull()
// Right edge of the plot area = the last slot (day 2, 01:00, buy 0.1250).
fireEvent.mouseMove(wrapper!, { clientX: 770, clientY: CHART_H / 2 })
await waitFor(() => expect(tooltipText()).toContain('0.1250'))
// Label carries the date, so day 2 is distinguishable from day 1.
expect(tooltipText()).toContain('1/2/2020')
expect(tooltipText()).toContain('0.0750')
// The active dots must sit on the hovered point (right half of the plot).
// The bug put them on day 1's identically-labelled slot near the left edge.
const dots = Array.from(document.querySelectorAll('.recharts-active-dot circle'))
expect(dots).toHaveLength(2)
for (const dot of dots) {
expect(Number(dot.getAttribute('cx'))).toBeGreaterThan(CHART_W / 2)
}
})
})
// ---------------------------------------------------------------------------
// buildChartRows
// ---------------------------------------------------------------------------
describe('buildChartRows', () => {
it('keeps X-axis keys unique across midnight', () => {
// Same local time-of-day on two consecutive days: as "HH:mm" labels these
// collided, which made Recharts resolve the hovered point to the first match
// (today) instead of the hovered one (tomorrow).
const rows = buildChartRows([
{ starts_at: '2026-07-26T22:00:00Z', buy: 0.1, sell: 0.05 },
{ starts_at: '2026-07-27T22:00:00Z', buy: 0.2, sell: 0.06 },
])
expect(rows).toHaveLength(2)
expect(new Set(rows.map((r) => r.ts)).size).toBe(2)
})
it('sorts rows by slot start and parses naive timestamps as UTC', () => {
const rows = buildChartRows([
{ starts_at: '2026-07-27T02:00:00', buy: 0.3, sell: 0.07 },
{ starts_at: '2026-07-27T01:00:00Z', buy: 0.2, sell: 0.06 },
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
])
expect(rows.map((r) => r.buy)).toEqual([0.1, 0.2, 0.3])
expect(rows.map((r) => r.ts)).toEqual([
'2026-07-27T00:00:00.000Z',
'2026-07-27T01:00:00.000Z',
'2026-07-27T02:00:00.000Z',
])
})
})
// ---------------------------------------------------------------------------
// findActiveSlotIndex
// ---------------------------------------------------------------------------
describe('findActiveSlotIndex', () => {
const rows = buildChartRows([
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
{ starts_at: '2026-07-27T00:15:00Z', buy: 0.2, sell: 0.06 },
{ starts_at: '2026-07-27T00:30:00Z', buy: 0.3, sell: 0.07 },
])
const at = (iso: string) => new Date(iso).getTime()
it('returns the slot containing now', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:20:00Z'))).toBe(1)
})
it('returns the slot at its exact start boundary', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:15:00Z'))).toBe(1)
})
it('returns null before the first slot', () => {
expect(findActiveSlotIndex(rows, at('2026-07-26T23:59:00Z'))).toBeNull()
})
it('stays on the last slot until its inferred end, then returns null', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:44:00Z'))).toBe(2)
expect(findActiveSlotIndex(rows, at('2026-07-27T00:45:00Z'))).toBeNull()
})
it('returns null for empty data', () => {
expect(findActiveSlotIndex([], Date.now())).toBeNull()
})
})
+139 -14
View File
@@ -2,13 +2,15 @@
* TibberPrices — price curve visualization.
*
* - Fetches today + tomorrow price range using useEnergyPrices.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time,
* with the currently active price slot marked by a dot.
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
* - Handles: no active contract, empty data, loading, error.
*
* Recharts imports are isolated to this file only.
*/
import { useEffect, useMemo, useState } from 'react'
import {
Stack,
Text,
@@ -29,10 +31,20 @@ import {
CartesianGrid,
Tooltip,
Legend,
ReferenceDot,
ResponsiveContainer,
} from 'recharts'
import { useEnergyPrices } from './hooks'
import { formatLocalTime } from '../utils/datetime'
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
const BUY_COLOR = '#2196f3'
const SELL_COLOR = '#4caf50'
/** Slot length assumed for the very last point, when no next point bounds it. */
const FALLBACK_SLOT_MS = 60 * 60 * 1000
/** How often the "current price" marker re-evaluates which slot is active. */
const NOW_TICK_MS = 30 * 1000
// ---------------------------------------------------------------------------
// Time range helpers
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
return d.toISOString()
}
// ---------------------------------------------------------------------------
// Chart data helpers
// ---------------------------------------------------------------------------
export interface PricePoint {
starts_at: string
buy: number
sell: number
level?: string | null
}
export interface ChartRow {
/**
* X-axis category key — the full instant, NOT a "HH:mm" label.
*
* Must be unique per slot: Recharts resolves the hovered point by *value*
* (findEntryInArray on the axis dataKey), so a repeated key makes the tooltip
* and the active dot snap back to the first match. With "HH:mm" labels, every
* time of day appears twice in a today+tomorrow range, which pinned the dot on
* today once the cursor passed midnight. Formatting to HH:mm happens in the
* tick / tooltip formatters instead.
*/
ts: string
/** Slot start as epoch ms; NaN when starts_at is unparseable. */
tsMs: number
buy: number
sell: number
}
/** Map API price points to chart rows with unique X keys, sorted by slot start. */
export function buildChartRows(points: PricePoint[]): ChartRow[] {
return points
.map((p) => {
const d = parseBackendTimestamp(p.starts_at)
const tsMs = d.getTime()
return {
ts: Number.isFinite(tsMs) ? d.toISOString() : p.starts_at,
tsMs,
buy: p.buy,
sell: p.sell,
}
})
.sort((a, b) => {
// Unparseable timestamps sort last so the ascending scan below can stop early.
if (!Number.isFinite(a.tsMs)) return Number.isFinite(b.tsMs) ? 1 : 0
if (!Number.isFinite(b.tsMs)) return -1
return a.tsMs - b.tsMs
})
}
/**
* Index of the row whose slot contains `nowMs`, or null when now is outside the
* fetched range. A slot ends where the next one starts; the last row has no next
* slot, so it falls back to the series spacing (quarter-hourly for Tibber).
*/
export function findActiveSlotIndex(rows: ChartRow[], nowMs: number): number | null {
let idx = -1
for (let i = 0; i < rows.length; i += 1) {
if (!Number.isFinite(rows[i].tsMs) || rows[i].tsMs > nowMs) break
idx = i
}
if (idx < 0) return null
const spacing = rows.length > 1 ? rows[1].tsMs - rows[0].tsMs : NaN
const slotMs = Number.isFinite(spacing) && spacing > 0 ? spacing : FALLBACK_SLOT_MS
const slotEnd = idx + 1 < rows.length ? rows[idx + 1].tsMs : rows[idx].tsMs + slotMs
return nowMs < slotEnd ? idx : null
}
/** Ticking clock so the active-slot marker follows slot boundaries while open. */
function useNowMs(intervalMs = NOW_TICK_MS): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
return now
}
// ---------------------------------------------------------------------------
// Tibber chart
// ---------------------------------------------------------------------------
interface TibberChartProps {
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
points: PricePoint[]
currency: string
}
function TibberChart({ points, currency }: TibberChartProps) {
const data = points.map((p) => ({
time: formatLocalTime(p.starts_at),
buy: p.buy,
sell: p.sell,
}))
const data = useMemo(() => buildChartRows(points), [points])
const nowMs = useNowMs()
const activeIndex = findActiveSlotIndex(data, nowMs)
const activeRow = activeIndex == null ? null : data[activeIndex]
return (
<Stack gap="xs" data-testid="tibber-chart">
<Title order={6} c="dimmed">
Price curve ({currency})
</Title>
<Group gap="xs" justify="space-between" align="baseline">
<Title order={6} c="dimmed">
Price curve ({currency})
</Title>
{activeRow && (
<Text size="xs" c="dimmed" data-testid="tibber-current-price">
Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '}
{activeRow.sell.toFixed(4)}
</Text>
)}
</Group>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="time"
dataKey="ts"
tick={{ fontSize: 10 }}
interval="preserveStartEnd"
tickFormatter={(v: string) => formatLocalTime(v)}
/>
<YAxis
tick={{ fontSize: 10 }}
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
labelFormatter={(label) =>
typeof label === 'string'
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
: label
}
/>
<Legend />
<Line
type="monotone"
dataKey="buy"
stroke="#2196f3"
stroke={BUY_COLOR}
dot={false}
strokeWidth={2}
name="Buy"
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
<Line
type="monotone"
dataKey="sell"
stroke="#4caf50"
stroke={SELL_COLOR}
dot={false}
strokeWidth={2}
name="Sell"
/>
{/* Currently active price slot, marked by default (no hover needed). */}
{activeRow && (
<ReferenceDot
x={activeRow.ts}
y={activeRow.buy}
r={4}
fill={BUY_COLOR}
stroke="#fff"
strokeWidth={2}
/>
)}
{activeRow && (
<ReferenceDot
x={activeRow.ts}
y={activeRow.sell}
r={4}
fill={SELL_COLOR}
stroke="#fff"
strokeWidth={2}
/>
)}
</LineChart>
</ResponsiveContainer>
</Stack>
@@ -83,6 +83,8 @@ const SUMMARY = {
metered_import: 10.5,
metered_export: 2.3,
metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0,
credits: 50.0,
total_payable: 12.5,
+16 -4
View File
@@ -4727,17 +4727,27 @@
"metered_import": {
"type": "number",
"title": "Metered Import",
"description": "Σ import_cost for non-degraded periods."
"description": "Σ import_cost for non-degraded periods (money, in `currency`)."
},
"metered_export": {
"type": "number",
"title": "Metered Export",
"description": "Σ export_revenue for non-degraded periods."
"description": "Σ export_revenue for non-degraded periods (money, in `currency`)."
},
"metered_net": {
"type": "number",
"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": {
"type": "number",
@@ -4776,6 +4786,8 @@
"metered_import",
"metered_export",
"metered_net",
"metered_import_kwh",
"metered_export_kwh",
"fixed_costs",
"credits",
"total_payable",
@@ -4784,7 +4796,7 @@
"days"
],
"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": {
"properties": {
+20 -4
View File
@@ -3659,15 +3659,25 @@ components:
metered_import:
type: number
title: Metered Import
description: Σ import_cost for non-degraded periods.
description: Σ import_cost for non-degraded periods (money, in `currency`).
metered_export:
type: number
title: Metered Export
description: Σ export_revenue for non-degraded periods.
description: Σ export_revenue for non-degraded periods (money, in `currency`).
metered_net:
type: number
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:
type: number
title: Fixed Costs
@@ -3699,6 +3709,8 @@ components:
- metered_import
- metered_export
- metered_net
- metered_import_kwh
- metered_export_kwh
- fixed_costs
- credits
- total_payable
@@ -3709,7 +3721,11 @@ components:
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``'
+33
View File
@@ -990,6 +990,38 @@ class TestSummarize:
# Σnet ≈ 2 × 0.4051 = 0.8102
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:
self._setup_two_periods(energy_db)
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
@@ -2708,6 +2740,7 @@ class TestSummarizeSettlementOffset:
expected_keys = {
"currency", "metered_import", "metered_export", "metered_net",
"metered_import_kwh", "metered_export_kwh",
"fixed_costs", "credits", "total_payable", "period_count",
"degraded_count", "days",
}