Compare commits
12
Commits
v1.4.3
..
d7f04aee8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f04aee8c | ||
|
|
8dbb59a3b7 | ||
|
|
2992bbb0ef | ||
|
|
22faeb45bb | ||
|
|
c37dfacfc7 | ||
|
|
16b050d821 | ||
|
|
d3d914b117 | ||
|
|
fc4af857e3 | ||
|
|
0958d9a2e9 | ||
|
|
b405aea88b | ||
|
|
bfc7aa3031 | ||
|
|
2f63b9630c |
@@ -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 Code:Sonnet;Codex/OpenAI:GPT-5.6 Terra (`gpt-5.6-terra`) |
|
||||
| **Fixer**(返工) | 平衡型代码实现模型 | `medium` 或等效档位 | Claude Code:Sonnet;Codex/OpenAI:GPT-5.6 Terra (`gpt-5.6-terra`) |
|
||||
| **Reviewer** | 当前 harness 支持的最强通用推理 / 代码模型 | `extra-high` / `xhigh` 或等效档位 | Claude Code:Opus;Codex/OpenAI:GPT-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
|
||||
```
|
||||
@@ -1,181 +0,0 @@
|
||||
# CLAUDE.md — Home Automation Backend
|
||||
|
||||
本文件每次会话自动加载。它定义本项目的**工作流程、文档位置、commit 规范**。请在动手前先读完。
|
||||
|
||||
## 项目速览
|
||||
|
||||
- 个人用 home-automation 后端:**FastAPI + SQLite + SQLAlchemy + Alembic**,服务端模板(Jinja,M2 将换成 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
|
||||
```
|
||||
+18
-4
@@ -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
@@ -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),
|
||||
|
||||
@@ -88,6 +88,8 @@ pyproject-hooks==1.2.0
|
||||
# via
|
||||
# build
|
||||
# pip-tools
|
||||
pyserial==3.5
|
||||
# via -r requirements.in
|
||||
pytest==8.4.2
|
||||
# via -r dev-requirements.in
|
||||
python-dotenv==1.2.2
|
||||
|
||||
@@ -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 真机概念验证(已完成;正式 CLI 长测与供暖变化均经物理表复核)
|
||||
- [`m8-warmtelink-energy.md`](./m8-warmtelink-energy.md) — WarmteLink P1、多数据源 Meter 与热力计费(Planning 已完成;M8-T01~M8-T20 待实现)
|
||||
|
||||
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
# Pre-M8 — WarmteLink P1 真机概念验证
|
||||
|
||||
> **状态:已完成。** 2026-08-22 已完成临时脚本真机 bring-up;随后仓库内正式 probe 以
|
||||
> `115200 7N1` 连续运行 10 分钟,形成脱敏的可重复验收证据;最终人工走查开启供暖后又观察到
|
||||
> 区域供暖累计量从 `0.017 GJ` 增至 `0.018 GJ`,并与物理表一致。Pre-M8 现已解除
|
||||
> **M8 Planning** 的入口限制;M8 的架构和实现范围仍未锁定。
|
||||
|
||||
## 1. 目的
|
||||
|
||||
在进入 M8 正式设计和实现前,用新家的 Vattenfall WarmteLink 建立一条只读 P1 验证链,回答:
|
||||
|
||||
1. USB→P1 线、主机串口权限和 WarmteLink P1 端口能否稳定输出可解析数据。
|
||||
2. 真机使用什么串口参数,telegram 的 framing、CRC、时间戳和 OBIS/M-Bus channel 有何特征。
|
||||
3. 实际能够读取哪些累计量,以及单位、精度、更新频率和累计语义。
|
||||
4. P1 值是否与热量表/生活热水表面板一致。
|
||||
|
||||
Pre-M8 是 M8 的证据门:它只交付真机事实和可重复 probe,不决定数据库结构、Meter 数据源
|
||||
绑定、后台采集服务或前端布局。
|
||||
|
||||
## 2. 执行边界
|
||||
|
||||
本阶段只建立下面这条最短链路:
|
||||
|
||||
```text
|
||||
WarmteLink P1 → USB serial → 原始 telegram → 完整性状态 → 字段解析 → 终端输出
|
||||
```
|
||||
|
||||
明确不做:
|
||||
|
||||
- 不写入 `app.db`,不新增 Alembic migration。
|
||||
- 不新增 FastAPI API、后台常驻 worker、配置页面或 Energy 前端。
|
||||
- 不发布 MQTT / Home Assistant Discovery。
|
||||
- 不修改现有 DSMR Reader MQTT、电费计算或 Meter 逻辑。
|
||||
- 不在本阶段决定 WarmteLink 应落在哪个正式 Device/Source 模型中。
|
||||
- 不写串口、不修改 FTDI EEPROM;probe 必须严格只读。
|
||||
|
||||
## 3. 2026-08-22 真机事实
|
||||
|
||||
### 3.1 USB、权限与线材
|
||||
|
||||
- 新 USB→P1 线是 FTDI FT232R,稳定路径形态为
|
||||
`/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_<redacted>-if00-port0`;正式配置不得依赖
|
||||
`/dev/ttyUSB1`。
|
||||
- 运行用户加入 `dialout` 后可以直接读取 tty,无需 root,也不应把容器作为硬件 bring-up
|
||||
的中间层。
|
||||
- 已用另一根已知正常的 DSMR 线在电表上读到连续、CRC 正确的 DSMR telegram,证明宿主机
|
||||
串口读取方法本身可用。
|
||||
- 曾临时清除新 FTDI 线 EEPROM 的 `INVERT_RXD` 做对照;输出发生变化但仍不可解析,随后已将
|
||||
EEPROM 逐字节恢复为原厂镜像并回读校验。正式方案不得依赖 EEPROM 修改。
|
||||
|
||||
### 3.2 串口参数矩阵
|
||||
|
||||
真机不是按最初假设的 `115200 8N1` 得到可读正文;当前线材与 WarmteLink 的实测最佳组合为:
|
||||
|
||||
```text
|
||||
115200 baud, 7 data bits, no parity, 1 stop bit(7N1)
|
||||
```
|
||||
|
||||
| 参数 | 实测结果 |
|
||||
| --- | --- |
|
||||
| `115200 7N1` | 正文稳定可读,可枚举 9 个 OBIS 字段 |
|
||||
| `115200 7N2` | 同样可读;没有理由增加停止位,正式默认仍用 `7N1` |
|
||||
| `115200 8N1/8E1/8O1` | 乱码,无有效 OBIS/CRC |
|
||||
| `115200 7E1/7O1` | 乱码,无有效 OBIS/CRC |
|
||||
| `120000–3000000`,分别用 `8N1`、`7N1` | 无完整报文;`120000` 仅残留少量可辨识文本 |
|
||||
| XON/XOFF 开/关 | 不改变 `7N1` 的字段解析结果 |
|
||||
|
||||
[DSMR 5.0.2 P1 Companion Standard](https://www.netbeheernederland.nl/sites/default/files/2024-02/dsmr_5.0.2_p1_companion_standard.pdf)
|
||||
规定 `115200 8N1`。因此 `7N1` 是当前设备/线材组合的实测事实,不应被文档或代码包装成
|
||||
标准 DSMR framing;CLI 必须允许显式覆盖数据位、校验位和停止位。
|
||||
|
||||
### 3.3 Framing 与 CRC 异常
|
||||
|
||||
- 临时 bring-up 的一次 8 帧盘点中每帧均为 256 字节;正式 10 分钟采样显示长度并不固定,
|
||||
详见 §3.6。
|
||||
- 正文结构稳定,版本、时间戳、两个 M-Bus channel、单位和值均可重复解析。
|
||||
- 实测头部为 `)TU)2NWA-MYRSKY`,偶见 `)TU{2NWA-MYRSKY`;没有标准要求的 `/` 起始符。
|
||||
- 帧尾存在 `!`,但其后的字符并非每帧都稳定为四位十六进制;即使恰好是四位,也无法从
|
||||
缺失的 `/` 起点完成标准 DSMR CRC16 验证。
|
||||
|
||||
因此正式 probe 必须把完整性明确表示为 `valid`、`invalid` 或 `unverifiable`,保留原始字节并
|
||||
输出原因。它可以在 `unverifiable` 状态下枚举字段用于 PoC,但绝不能把该帧报告为 CRC 已通过。
|
||||
M8 若要持久化这些读数,必须在 Planning 中单独锁定异常帧的接纳、重复确认和告警策略。
|
||||
|
||||
### 3.4 实际字段清单
|
||||
|
||||
连续 8 帧只出现以下 9 个字段;除 capture timestamp 外,字段集合和值均稳定:
|
||||
|
||||
| OBIS | 实测结构/值 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| `1-3:0.2.8` | `(50)` | DSMR P1 输出版本 5.0 |
|
||||
| `0-0:1.0.0` | `(YYMMDDhhmmssX)` | telegram/capture timestamp,每 10 秒变化 |
|
||||
| `0-0:96.1.1` | `<redacted>` | WarmteLink/gateway equipment identifier |
|
||||
| `0-1:24.1.0` | `(006)` | channel 1,M-Bus device type `0x06`,生活热水 |
|
||||
| `0-1:96.1.0` | `<redacted>` | channel 1 equipment identifier;样本制造商可解码为 `KAM` |
|
||||
| `0-1:24.2.1` | `(<timestamp>)(5.900*m3)` | 生活热水累计体积,输出到 `0.001 m³` 小数位 |
|
||||
| `0-2:24.1.0` | `(012)` | channel 2,M-Bus device type `0x0C`,热量表 |
|
||||
| `0-2:96.1.0` | `<redacted>` | channel 2 equipment identifier;样本制造商可解码为 `KAM` |
|
||||
| `0-2:24.2.1` | `(<timestamp>)(0.017*GJ)` | 区域供暖累计热量,输出到 `0.001 GJ` 小数位 |
|
||||
|
||||
人工面板在同一时间显示 `5.900 m³` 和 `0.017 GJ`,与 P1 值完全一致;用户已确认这两个累计量
|
||||
足以作为后续 Home Assistant Energy 展示的数据基础。
|
||||
|
||||
没有发现瞬时流量、当前热功率、供水温度或回水温度字段。完整 OMS/M-Bus 模型允许这些可选
|
||||
量,但 WarmteLink 当前 P1 telegram 没有导出它们;Pre-M8 和 M8 不得假设它们可用。参考
|
||||
[OMS Specification Vol. 2 Annex A](https://oms-group.org/wp-content/uploads/2024/05/OMS-Spec_Vol2_AnnexA_F121.pdf)。
|
||||
|
||||
### 3.5 对 M8 已经成立的事实
|
||||
|
||||
- 一个 WarmteLink serial source 同时暴露两个独立累计 measurement channel。
|
||||
- channel 1 是生活热水 `m³`;channel 2 是区域供暖 `GJ`,不能按论坛样例固定 channel。
|
||||
- 两个累计量都与物理表一致,可进入 M8 的 Meter/source 映射讨论。
|
||||
- 当前没有瞬时流量、温度或热功率;M8 只承诺累计量采集与展示。
|
||||
- 串口 framing 和 CRC 异常尚未消失,必须作为正式 ingestion 的显式质量状态处理。
|
||||
|
||||
### 3.6 正式 CLI 10 分钟复验(脱敏)
|
||||
|
||||
2026-08-22,仓库内 `python -m scripts.p1_probe` 在真实
|
||||
`/dev/serial/by-id/<redacted>` 上以 `115200 7N1` 运行 `--duration 600 --show-changes`,原始
|
||||
bytes 仅写入 `/tmp`。CLI 正常以 exit code 0 结束;期间没有 I/O error、未处理异常或断连。
|
||||
|
||||
- 读取到 60 个完整 frame、15,362 raw bytes;最后 2 bytes 是下一帧的不完整残片,60 个完整帧
|
||||
合计 15,360 bytes。
|
||||
- 设备 timestamp 从 `18:16:10` 至 `18:26:00`,严格每 10 秒递进。CLI 处理的 59 个相邻间隔中,
|
||||
53 个为 10.0 s、1 个为 9.0 s、3 个为 0.0 s、2 个为 20.0 s,均值 9.81 s;0/20 s 配对来自
|
||||
serial chunk 的批量交付,不代表设备 cadence 改变。
|
||||
- 完整 frame 长度分布为 237 bytes × 2、239 × 1、254 × 12、256 × 31、258 × 11、275 × 3,
|
||||
平均 256 bytes、范围 237–275。不能再把临时样本的「固定 256 bytes」视为帧格式契约;变长与
|
||||
非标准 footer/分块边界一致。
|
||||
- 60/60 的完整性均为 `unverifiable`,因为 60/60 缺少标准 `/` 起始符。footer 长度为 4 × 41、
|
||||
6 × 16、23 × 3;仅 16/60 的 footer 恰为四位十六进制,但仍不能在缺失 `/` 时完成标准 CRC16
|
||||
验证。**没有任何 frame 被报告为 CRC valid。**
|
||||
- 60/60 帧均解析到同一组 9 个 OBIS code:`1-3:0.2.8`、`0-0:1.0.0`、`0-0:96.1.1`、
|
||||
`0-1:24.1.0`、`0-1:96.1.0`、`0-1:24.2.1`、`0-2:24.1.0`、`0-2:96.1.0`、`0-2:24.2.1`。
|
||||
equipment identifier 已脱敏,不进入 Git。
|
||||
- channel 1 的 device type 是 `006`,累计值稳定为 `5.900 m³`;channel 2 的 device type 是
|
||||
`012`,累计值稳定为 `0.017 GJ`。同日人工表盘复核也显示 `5.900 m³` / `0.017 GJ`,正式 CLI
|
||||
因而复现了该基线。
|
||||
- 60 帧中未见瞬时流量、热功率、供水温度或回水温度字段。10 分钟内累计值未变化只能证明这段
|
||||
时间的累计值稳定;它不能证明发生消费时的更新频率,也不能证明 reset 或 wrap 行为。
|
||||
|
||||
### 3.7 最终人工走查:供暖累计量变化
|
||||
|
||||
正式长测交付后,用户又在终端直接运行只读 probe 十几分钟并开启供暖。channel 2 的区域供暖
|
||||
累计量在本次运行中从 `0.017 GJ` 增至 `0.018 GJ`,同一时刻物理热量表也显示 `0.018 GJ`;
|
||||
因此可以确认当前 P1 输出会在实际供暖消费下更新累计量,且 `0.001 GJ` 的变化与物理表一致。
|
||||
|
||||
这次人工走查没有改变完整性结论:telegram 仍缺少标准 `/`,数值与物理表一致不能替代 CRC
|
||||
验证。走查也没有覆盖 reset、wrap 或精确更新延迟;这些仍须由 M8 的接纳、重复确认和告警策略
|
||||
处理,而不能从一次累计量递增外推。
|
||||
|
||||
## 4. 正式 probe 的预期操作方式
|
||||
|
||||
实现后,在 workspace virtual environment 中运行只读 probe:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python -m scripts.p1_probe \
|
||||
--device /dev/serial/by-id/<usb-p1-device> \
|
||||
--baudrate 115200 \
|
||||
--bytesize 7 \
|
||||
--parity N \
|
||||
--stopbits 1 \
|
||||
--duration 600 \
|
||||
--show-changes \
|
||||
--raw-output /tmp/warmtelink-p1-telegram.bin
|
||||
```
|
||||
|
||||
参数名可在实现时小幅调整,但必须保留这些能力:
|
||||
|
||||
- 设备路径由用户显式传入,文档推荐 `/dev/serial/by-id/...`。
|
||||
- 串口默认采用本机实测 `115200 7N1`,同时允许显式覆盖 framing。
|
||||
- 连续读取多帧,输出 telegram cadence、帧长度和读取/重连错误。
|
||||
- 同时显示完整性/CRC 状态、原始 OBIS 字段和解析后的 channel、设备类型、值与单位。
|
||||
- parser 不依赖字段固定顺序,也不把 GJ/m³ 固定到 channel 1 或 2。
|
||||
- `--show-changes` 可只显示发生变化的字段,原始捕获可写入用户指定的 `/tmp` 路径。
|
||||
- 捕获文件默认按原始 bytes 保存;未经脱敏不得提交到 Git。
|
||||
- permission denied 时给出 `dialout` 指引;不得建议以 root 常驻运行。
|
||||
|
||||
Home Assistant Community 的
|
||||
[最小 WarmteLink Bash 读取方法](https://community.home-assistant.io/t/solved-dsmr-add-warmtelink-as-data-source/485255/2)
|
||||
仍可作为快速可见性检查,但论坛样例的 header、channel 和 device type 与本机均不同,不能作为
|
||||
parser 契约。是否引入 [`dsmr_parser`](https://github.com/ndokter/dsmr_parser) 也由 T01 的实测
|
||||
兼容性决定;若它要求标准 `/...!CRC` framing,则应保留小型专用 parser,而不是绕过其校验。
|
||||
|
||||
## 5. 实现任务
|
||||
|
||||
### PRE-M8-T01 — 纯函数 telegram framing、CRC 与 OBIS parser
|
||||
|
||||
- **Status**: `done`
|
||||
- **Depends**: `none`
|
||||
- **Context**: 先把串口 I/O 与解析分开,用脱敏 fixture 固定标准 DSMR 帧和本机异常帧行为。
|
||||
|
||||
**Files**
|
||||
|
||||
- `create scripts/p1_probe.py`
|
||||
- `create tests/fixtures/dsmr_p1_valid.txt`
|
||||
- `create tests/fixtures/warmtelink_p1_7n1.txt`
|
||||
- `create tests/test_p1_probe.py`
|
||||
|
||||
**Steps**
|
||||
|
||||
1. 实现不依赖串口的增量 framing、DSMR CRC16 和通用 OBIS 行解析函数。
|
||||
2. 用数据结构表达原始 header/footer、完整性状态、timestamp、channel、device type、equipment id、
|
||||
value 和 unit;数值使用 `Decimal`,不使用二进制浮点保存累计量。
|
||||
3. 加入一份完全脱敏的本机结构 fixture,并另造一份 CRC 正确的标准 DSMR fixture。
|
||||
4. 对字段重排、分块输入、缺失 `/`、非十六进制 footer、CRC mismatch、未知字段和两个 channel
|
||||
写单元测试。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
|
||||
- 不打开真实 serial device,不增加依赖,不写数据库/API/MQTT。
|
||||
- 不因本机正文可读而伪造 `/` header 或把 CRC 状态升级为 valid。
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] 标准 fixture 的 frame boundary 与 CRC 可验证为 `valid`。
|
||||
- [x] 脱敏本机 fixture 被标为 `unverifiable`,但能按字段而非位置解析 `m³` 和 `GJ` channel。
|
||||
- [x] 任意 chunk boundary 和字段顺序不影响结果,未知字段原样保留。
|
||||
- [x] 累计量以 `Decimal` + 原单位返回。
|
||||
- [x] `pytest tests/test_p1_probe.py`、`pytest`、`ruff check .` 全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
|
||||
- CRC 覆盖范围必须严格从 `/` 到 `!`(包含二者),不得对缺失字节做猜测性修补。
|
||||
- fixture 必须脱敏且保留足以复现 framing 异常的字节结构。
|
||||
- parser 不得硬编码 channel 1=GJ 或 channel 2=m³。
|
||||
|
||||
### PRE-M8-T02 — 只读 serial probe CLI
|
||||
|
||||
- **Status**: `done`
|
||||
- **Depends**: `PRE-M8-T01`
|
||||
- **Context**: 在纯 parser 通过后增加最小 serial I/O,使真机验证可以从仓库稳定复现。
|
||||
|
||||
**Files**
|
||||
|
||||
- `modify requirements.in`
|
||||
- `modify requirements.txt`
|
||||
- `modify dev-requirements.txt`
|
||||
- `modify scripts/p1_probe.py`
|
||||
- `modify tests/test_p1_probe.py`
|
||||
|
||||
**Steps**
|
||||
|
||||
1. 增加受约束的 `pyserial` runtime 依赖并用仓库既有 pip-compile 流程同步生成 requirements。
|
||||
2. 实现 `--device`、framing 参数、`--duration`、`--show-changes` 和 `--raw-output`。
|
||||
3. 默认使用 `115200 7N1`;串口只读,禁止 write、EEPROM 或自动修改设备配置。
|
||||
4. 输出每帧完整性状态、全部字段、值变化、cadence 和错误;SIGINT/超时后关闭串口并正常退出。
|
||||
5. 用 fake serial/chunk stream 测试 CLI,不要求 CI 存在 `/dev/ttyUSB*`。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
|
||||
- 不做 daemon、自动重连 worker、Docker device mapping、数据库、API、MQTT 或 HA Discovery。
|
||||
- 不内置本机 FTDI 序列号,不自动扫描或改写任意 USB 设备。
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] CLI 可用 `/dev/serial/by-id/...` 读取,且所有 framing 参数都可显式覆盖。
|
||||
- [x] 默认参数准确反映本机 `115200 7N1`,帮助文本说明它是实测值而非 DSMR 标准默认。
|
||||
- [x] raw output 保留原始 bytes;终端清楚区分 `valid`、`invalid`、`unverifiable`。
|
||||
- [x] permission/busy/disconnect 错误非零退出并给出可执行诊断,绝不建议常驻 root。
|
||||
- [x] 依赖输入与两个生成 requirements 文件同步。
|
||||
- [x] `pytest tests/test_p1_probe.py`、`pytest`、`ruff check .` 全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
|
||||
- 确认所有 serial write path 均不存在。
|
||||
- 确认测试完全 mock 硬件、没有 CI timing flake,退出路径总会关闭文件描述符。
|
||||
- 确认 requirements 是生成结果而非仅手改 lock file。
|
||||
|
||||
### PRE-M8-T03 — 正式真机验收与 M8 交接
|
||||
|
||||
- **Status**: `done`
|
||||
- **Depends**: `PRE-M8-T02`
|
||||
- **Context**: 用仓库内 probe 替代本轮临时脚本,形成可重复、脱敏且能支撑 M8 Planning 的证据。
|
||||
|
||||
**Files**
|
||||
|
||||
- `modify docs/design/pre-m8-warmtelink-p1-poc.md`
|
||||
- `modify docs/design/m8-warmtelink-energy.md`
|
||||
- `modify docs/design/README.md`
|
||||
- `modify docs/roadmap.md`
|
||||
|
||||
**Steps**
|
||||
|
||||
1. 在真实 `/dev/serial/by-id/...` 上运行 probe 至少 10 分钟,并保留原始捕获在 `/tmp`。
|
||||
2. 汇总帧数、cadence、长度、完整性状态、全部字段和读数变化;不得提交原始设备标识。
|
||||
3. 再次与物理表对照 GJ 和 m³,并记录累计/重置行为中本次能证实和不能证实的部分。
|
||||
4. 更新本节事实、通过条件和 M8 入口;只有证据齐全后才把 Pre-M8 标记为完成。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
|
||||
- 不为完成 checklist 而修补原始字节或放宽 CRC 结果。
|
||||
- 不进入 M8 schema/API/worker/frontend 实现。
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] 仓库内 probe 在真机连续运行至少 10 分钟,无未处理异常退出。
|
||||
- [x] 脱敏摘要列出两个累计 channel、单位、精度、cadence 和完整性异常。
|
||||
- [x] `0.017 GJ`、`5.900 m³` 的基线或运行时新值与物理表再次对照。
|
||||
- [x] 明确没有从当前 P1 输出读取到瞬时流量、功率或温度。
|
||||
- [x] Pre-M8 状态与 roadmap/M8 入口同步;代码闸门保持全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
|
||||
- 证据必须来自正式 CLI,不得只复述本轮临时脚本结果。
|
||||
- 任何 equipment id、FTDI serial 和未脱敏 raw capture 都不得进入 Git。
|
||||
- CRC/framing 风险必须原样交给 M8,不能用“数值看起来正确”替代完整性判断。
|
||||
|
||||
## 6. 当前通过条件
|
||||
|
||||
- [x] USB、tty 权限和稳定 `/dev/serial/by-id/...` 路径已验证。
|
||||
- [x] 串口参数矩阵已完成,实测正文可读参数为 `115200 7N1`。
|
||||
- [x] 已枚举全部实际 channel/OBIS 字段,并确认没有轮换出现的额外测量量。
|
||||
- [x] GJ 和生活热水 m³ 均与物理表面板完全一致。
|
||||
- [x] 已记录精度、约 10 秒 telegram cadence 和当前字段集合。
|
||||
- [x] framing/CRC 失败已保留为显式异常,没有误报为校验通过。
|
||||
- [x] 仓库内 parser、fixtures、probe CLI 和自动化测试完成。
|
||||
- [x] 正式 probe 完成至少 10 分钟真机复验并产出脱敏摘要。
|
||||
- [x] 最终人工走查在供暖开启后观察到 `0.017 → 0.018 GJ`,并再次与物理表核对一致。
|
||||
|
||||
Pre-M8 已完成并向 M8 解锁 Planning;它只交付下列真机事实,不锁定正式架构或实现任务。
|
||||
|
||||
## 7. 向 M8 的交付物
|
||||
|
||||
Pre-M8 完成后只向 M8 交付事实,不交付正式架构:
|
||||
|
||||
- 脱敏 telegram 结构、fixture 和全部字段清单。
|
||||
- GJ 与 m³ 的实际 channel、device type、单位、精度和更新时间。
|
||||
- 串口参数、稳定设备路径形态与 `dialout` 权限要求。
|
||||
- parser 适配结论和 `unverifiable` framing/CRC 异常样本。
|
||||
- “一个 serial source 包含两个独立累计计量 channel”的实测结论。
|
||||
- 当前 P1 不提供瞬时流量、热功率或温度的明确边界。
|
||||
+65
-1
@@ -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 也已完成并拆成 M8-T01~M8-T20,等待后续由编排器按依赖逐张实现。
|
||||
|
||||
## 当前基线(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 真机概念验证 | 正式只读 CLI 长测通过;人工开启供暖后累计量 `0.017 → 0.018 GJ` 且与物理表一致,所有 frame 的 CRC 状态仍为 `unverifiable` |
|
||||
| **M8** 📋 | WarmteLink P1、多数据源 Meter 与热力计费 | Planning 已完成:统一 Source/Channel/Binding、WarmteLink 双 channel、DSMR 迁移、thermal 合同/成本、HA/UI/部署;M8-T01~M8-T20 待实现 |
|
||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||
|
||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
||||
@@ -257,6 +259,68 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
|
||||
|
||||
---
|
||||
|
||||
## Pre-M8 — WarmteLink P1 真机概念验证(✅ 已完成)
|
||||
|
||||
### 目标
|
||||
|
||||
2026-08-22 的正式仓库 CLI 以 `115200 7N1` 连续运行 10 分钟,正常退出且没有 I/O error、未处理
|
||||
异常或断连。一个 WarmteLink source 在 60/60 完整 frame 中暴露 channel 1 的生活热水累计量
|
||||
`5.900 m³` 和 channel 2 的区域供暖累计量 `0.017 GJ`;二者再次与同日物理表一致。没有流量、
|
||||
热功率或温度字段。
|
||||
|
||||
所有完整 frame 都缺少标准 `/`,CRC 因而为 `unverifiable`;这项异常由正式 probe 原样报告,
|
||||
没有伪装成校验通过。frame 平均 256 bytes、范围 237–275,设备 timestamp 严格每 10 秒递进。
|
||||
10 分钟内累计值无变化只能证明稳定基线,不能证明消费时更新频率或 reset/wrap 行为。
|
||||
|
||||
最终人工走查又在终端连续运行 probe 十几分钟并开启供暖;区域供暖累计量从 `0.017 GJ` 增至
|
||||
`0.018 GJ`,物理热量表同步显示 `0.018 GJ`。这确认了实际消费时的累计递增与 `0.001 GJ`
|
||||
精度,但不改变 CRC `unverifiable` 结论,也不证明精确更新延迟或 reset/wrap 行为。
|
||||
|
||||
本阶段不落库、不接 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 已完成,待实现)
|
||||
|
||||
### 目标
|
||||
|
||||
以统一的 `MeterSource → MeterSourceChannel → MeterSourceBinding → Meter epoch` 链路承载现有
|
||||
DSMR MQTT 与新的 WarmteLink serial source;从一个 WarmteLink source 只读采集已由真机确认的
|
||||
区域供暖累计 `GJ` 和生活热水累计 `m³`,并完成历史、计费、API、前端、HA 与部署闭环。
|
||||
|
||||
### 已锁定范围
|
||||
|
||||
- Source、Modbus Device、Meter 分离;channel 先发现、再由用户确认绑定,source 切换与物理换表
|
||||
使用两条独立半开时间线。
|
||||
- DSMR JSON 和 WarmteLink Decimal scalar 分表存储,但都显式关联 source/binding;既有 DSMR
|
||||
历史和电费安全回填,正常电费数字保持不变,跨 source/binding 周期降级。
|
||||
- WarmteLink `unverifiable` frame 需要连续双帧确认后接纳,质量标签不漂白;约 10 秒更新 latest、
|
||||
每分钟保存 history,不保存 raw telegram 或 raw equipment identifier。
|
||||
- 串口 worker 使用 `115200 7N1`、只读、短 DB session、可热更新和 `1…60s` 退避重连;以可选
|
||||
compose overlay、稳定 by-id、serial GID 和非 root 容器部署。
|
||||
- 合同增加 electricity/thermal scope;一份 district-heating 合同覆盖 heating GJ 与 hot-water m³,
|
||||
生成按 commodity 的 15 分钟成本和 contract-level 每日固定费。仓库不硬编码真实 tariff。
|
||||
- UI 分成 Sources / Modbus Devices / Meters,并让 Contracts / Prices / Costs 按 scope 切换;新增
|
||||
source、meter 与 thermal cost HA entities,开关默认关闭,旧 DSMR latest API 保持兼容。
|
||||
|
||||
### 原子实施链
|
||||
|
||||
- **M8-T01~T06**:统一 source/channel/binding schema、DSMR 历史/runtime/电费迁移和管理 API。
|
||||
- **M8-T07~T11**:共享 P1 parser、WarmteLink 标量存储、质量接纳、serial worker、发现与历史 API。
|
||||
- **M8-T12~T16**:合同 scope、district-heating profile、thermal cost 账本/引擎/API。
|
||||
- **M8-T17~T20**:HA、Sources/Meters UI、scope-aware 计费 UI、compose/文档/全链收尾。
|
||||
|
||||
完成判据不仅是单元闸门全绿,还包括历史副本迁移对账、OpenAPI/codegen、全部前端闸门、真实
|
||||
`docker build`、非 root 串口部署和真机端到端 walkthrough。任何任务都不得删除旧数据库、历史
|
||||
读数、旧 config 行或 volume;push/tag 仍需用户单独授权。
|
||||
|
||||
> 完整架构、HTTP 契约、质量/计费规则、依赖图与 M8-T01~M8-T20 任务卡:
|
||||
> [`docs/design/m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md)
|
||||
|
||||
---
|
||||
|
||||
## M3 — 开放与移动端(远期试水)
|
||||
|
||||
### 目标
|
||||
|
||||
Vendored
+17
-5
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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``'
|
||||
|
||||
@@ -7,6 +7,7 @@ paho-mqtt>=2.0,<3.0
|
||||
pymodbus>=3.6,<4.0
|
||||
pydantic-settings>=2.6,<3.0
|
||||
pyotp>=2.9,<3.0
|
||||
pyserial>=3.5,<4.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
pyyaml>=6.0,<7.0
|
||||
sqlalchemy>=2.0,<3.0
|
||||
|
||||
@@ -65,6 +65,8 @@ pymodbus==3.13.1
|
||||
# via -r requirements.in
|
||||
pyotp==2.10.0
|
||||
# via -r requirements.in
|
||||
pyserial==3.5
|
||||
# via -r requirements.in
|
||||
python-dotenv==1.2.2
|
||||
# via
|
||||
# pydantic-settings
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Read-only parser and command-line probe for DSMR and WarmteLink P1 telegrams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import StrEnum
|
||||
import errno
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import BinaryIO, Callable, TextIO
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
_OBIS_LINE = re.compile(r"^(?P<code>\d+-\d+:\d+\.\d+\.\d+)(?P<values>(?:\([^)]*\))*)$")
|
||||
_NUMBER_WITH_UNIT = re.compile(r"^(?P<number>[+-]?\d+(?:\.\d+)?)(?:\*(?P<unit>.+))?$")
|
||||
_CHANNEL_OBIS = re.compile(r"^0-(?P<channel>[1-9]\d*):(24|96)\.")
|
||||
|
||||
|
||||
class IntegrityStatus(StrEnum):
|
||||
"""Whether a frame has a verifiable standard DSMR checksum."""
|
||||
|
||||
VALID = "valid"
|
||||
INVALID = "invalid"
|
||||
UNVERIFIABLE = "unverifiable"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObisField:
|
||||
"""One OBIS line, including values not understood by this proof of concept."""
|
||||
|
||||
code: str
|
||||
raw_values: tuple[str, ...]
|
||||
value: Decimal | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P1Channel:
|
||||
"""Fields associated with one M-Bus channel, discovered from its OBIS code."""
|
||||
|
||||
number: int
|
||||
device_type: str | None
|
||||
equipment_id: str | None
|
||||
readings: tuple[ObisField, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P1Telegram:
|
||||
"""A parsed telegram while retaining its raw framing and all OBIS fields."""
|
||||
|
||||
raw: bytes
|
||||
header: bytes
|
||||
footer: bytes
|
||||
integrity: IntegrityStatus
|
||||
integrity_reason: str
|
||||
timestamp: str | None
|
||||
fields: tuple[ObisField, ...]
|
||||
channels: tuple[P1Channel, ...]
|
||||
|
||||
|
||||
def dsmr_crc16(data: bytes) -> int:
|
||||
"""Return the DSMR CRC-16 over *data* (normally from ``/`` through ``!``)."""
|
||||
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return crc & 0xFFFF
|
||||
|
||||
|
||||
class TelegramFramer:
|
||||
"""Incrementally extract newline-terminated telegrams from byte chunks.
|
||||
|
||||
A standard telegram starts with ``/``. WarmteLink's observed telegrams do
|
||||
not, so a non-standard frame is retained from the current buffer start until
|
||||
its ``!`` footer line instead of fabricating a standard header.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
def feed(self, chunk: bytes) -> list[bytes]:
|
||||
"""Append *chunk* and return every complete frame now available."""
|
||||
|
||||
self._buffer.extend(chunk)
|
||||
frames: list[bytes] = []
|
||||
|
||||
while (bang := self._buffer.find(b"!")) >= 0:
|
||||
newline = self._buffer.find(b"\n", bang)
|
||||
if newline < 0:
|
||||
break
|
||||
|
||||
standard_start = self._buffer.find(b"/")
|
||||
start = standard_start if 0 <= standard_start < bang else 0
|
||||
frames.append(bytes(self._buffer[start : newline + 1]))
|
||||
del self._buffer[: newline + 1]
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def parse_telegram(frame: bytes) -> P1Telegram:
|
||||
"""Parse a complete frame without guessing missing DSMR framing bytes."""
|
||||
|
||||
bang = frame.find(b"!")
|
||||
if bang < 0:
|
||||
raise ValueError("telegram has no footer marker '!'")
|
||||
|
||||
body = frame[:bang]
|
||||
footer = frame[bang + 1 :].rstrip(b"\r\n")
|
||||
header = body.splitlines()[0] if body else b""
|
||||
integrity, reason = _integrity(frame, bang, footer)
|
||||
fields = _parse_obis_fields(body)
|
||||
timestamp = _field_value(fields, "0-0:1.0.0")
|
||||
channels = _parse_channels(fields)
|
||||
|
||||
return P1Telegram(
|
||||
raw=frame,
|
||||
header=header,
|
||||
footer=footer,
|
||||
integrity=integrity,
|
||||
integrity_reason=reason,
|
||||
timestamp=timestamp,
|
||||
fields=tuple(fields),
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
|
||||
def _integrity(frame: bytes, bang: int, footer: bytes) -> tuple[IntegrityStatus, str]:
|
||||
if not frame.startswith(b"/"):
|
||||
return IntegrityStatus.UNVERIFIABLE, "missing standard DSMR '/' header"
|
||||
if len(footer) != 4 or not all(chr(byte) in "0123456789abcdefABCDEF" for byte in footer):
|
||||
return IntegrityStatus.UNVERIFIABLE, "footer is not a four-digit hexadecimal CRC"
|
||||
|
||||
expected = int(footer, 16)
|
||||
actual = dsmr_crc16(frame[: bang + 1])
|
||||
if actual == expected:
|
||||
return IntegrityStatus.VALID, "CRC16 verified from '/' through '!'"
|
||||
return IntegrityStatus.INVALID, f"CRC16 mismatch: expected {expected:04X}, calculated {actual:04X}"
|
||||
|
||||
|
||||
def _parse_obis_fields(body: bytes) -> list[ObisField]:
|
||||
fields: list[ObisField] = []
|
||||
for line in body.decode("ascii", errors="replace").splitlines()[1:]:
|
||||
match = _OBIS_LINE.fullmatch(line)
|
||||
if not match:
|
||||
continue
|
||||
raw_values = tuple(re.findall(r"\(([^)]*)\)", match.group("values")))
|
||||
value, unit = _numeric_value(raw_values)
|
||||
fields.append(ObisField(match.group("code"), raw_values, value, unit))
|
||||
return fields
|
||||
|
||||
|
||||
def _numeric_value(raw_values: tuple[str, ...]) -> tuple[Decimal | None, str | None]:
|
||||
if not raw_values:
|
||||
return None, None
|
||||
match = _NUMBER_WITH_UNIT.fullmatch(raw_values[-1])
|
||||
if not match:
|
||||
return None, None
|
||||
try:
|
||||
return Decimal(match.group("number")), match.group("unit")
|
||||
except InvalidOperation:
|
||||
return None, None
|
||||
|
||||
|
||||
def _field_value(fields: list[ObisField], code: str) -> str | None:
|
||||
field = next((item for item in fields if item.code == code), None)
|
||||
return field.raw_values[-1] if field and field.raw_values else None
|
||||
|
||||
|
||||
def _parse_channels(fields: list[ObisField]) -> tuple[P1Channel, ...]:
|
||||
by_channel: dict[int, list[ObisField]] = {}
|
||||
for field in fields:
|
||||
match = _CHANNEL_OBIS.match(field.code)
|
||||
if match:
|
||||
by_channel.setdefault(int(match.group("channel")), []).append(field)
|
||||
|
||||
return tuple(
|
||||
P1Channel(
|
||||
number=number,
|
||||
device_type=_field_value(channel_fields, f"0-{number}:24.1.0"),
|
||||
equipment_id=_field_value(channel_fields, f"0-{number}:96.1.0"),
|
||||
readings=tuple(
|
||||
field
|
||||
for field in channel_fields
|
||||
if field.code == f"0-{number}:24.2.1" and field.value is not None
|
||||
),
|
||||
)
|
||||
for number, channel_fields in sorted(by_channel.items())
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the CLI parser for an explicitly selected, read-only serial device."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read-only WarmteLink P1 serial probe; it never writes to the device.",
|
||||
epilog="Defaults are the locally measured WarmteLink 115200 7N1 framing, not DSMR defaults.",
|
||||
)
|
||||
parser.add_argument("--device", required=True, help="Explicit serial path, preferably /dev/serial/by-id/..."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baudrate",
|
||||
type=int,
|
||||
default=115200,
|
||||
help="Baud rate (default: 115200, the locally measured WarmteLink value).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bytesize",
|
||||
type=int,
|
||||
choices=(5, 6, 7, 8),
|
||||
default=7,
|
||||
help="Data bits (default: 7, locally measured; not the DSMR standard default).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parity",
|
||||
choices=("N", "E", "O", "M", "S"),
|
||||
type=lambda value: value.upper(),
|
||||
default="N",
|
||||
help="Parity (default: N, locally measured; not the DSMR standard default).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stopbits",
|
||||
type=float,
|
||||
choices=(1, 1.5, 2),
|
||||
default=1,
|
||||
help="Stop bits (default: 1, locally measured; not the DSMR standard default).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration",
|
||||
type=_positive_duration,
|
||||
default=600.0,
|
||||
help="Maximum capture time in seconds (default: 600).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-changes",
|
||||
action="store_true",
|
||||
help="After the first frame, print only OBIS fields whose values changed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--raw-output",
|
||||
type=argparse.FileType("wb"),
|
||||
help="Optional path for the exact raw bytes read from the serial device.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _positive_duration(value: str) -> float:
|
||||
try:
|
||||
duration = float(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError("duration must be a positive number") from exc
|
||||
if duration <= 0:
|
||||
raise argparse.ArgumentTypeError("duration must be greater than zero")
|
||||
return duration
|
||||
|
||||
|
||||
def _serial_error_message(exc: BaseException) -> str:
|
||||
"""Return a practical, non-root diagnostic for a serial open/read failure."""
|
||||
|
||||
error_number = getattr(exc, "errno", None)
|
||||
message = str(exc)
|
||||
if error_number == errno.EACCES or "permission denied" in message.lower():
|
||||
return f"serial permission denied: {message}. Add your user to the dialout group; do not run it as root."
|
||||
if error_number == errno.EBUSY or "resource busy" in message.lower():
|
||||
return f"serial device is busy: {message}. Close the program currently using this device and retry."
|
||||
if error_number in {errno.ENODEV, errno.ENOENT, errno.EIO}:
|
||||
return f"serial device disconnected or unavailable: {message}. Check the cable and --device path."
|
||||
return f"serial I/O failed: {message}. Check the cable, device path, and serial framing settings."
|
||||
|
||||
|
||||
def _format_field(field: ObisField) -> str:
|
||||
raw_values = ", ".join(field.raw_values) or "<no values>"
|
||||
value = f" parsed={field.value} {field.unit or ''}" if field.value is not None else ""
|
||||
return f" {field.code}: {raw_values}{value}".rstrip()
|
||||
|
||||
|
||||
def _print_telegram(
|
||||
telegram: P1Telegram,
|
||||
frame_number: int,
|
||||
cadence: float | None,
|
||||
previous_fields: dict[str, tuple[str, ...]],
|
||||
show_changes: bool,
|
||||
output: TextIO,
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
cadence_text = "first frame" if cadence is None else f"cadence={cadence:.1f}s"
|
||||
print(
|
||||
f"frame {frame_number}: {telegram.integrity.value}; bytes={len(telegram.raw)}; {cadence_text}",
|
||||
file=output,
|
||||
)
|
||||
print(f" integrity: {telegram.integrity_reason}", file=output)
|
||||
current_fields = {field.code: field.raw_values for field in telegram.fields}
|
||||
fields = telegram.fields
|
||||
if show_changes and previous_fields:
|
||||
fields = tuple(field for field in fields if previous_fields.get(field.code) != field.raw_values)
|
||||
print(f" changed fields: {len(fields)}", file=output)
|
||||
for field in fields:
|
||||
print(_format_field(field), file=output)
|
||||
for channel in telegram.channels:
|
||||
print(
|
||||
f" channel {channel.number}: device_type={channel.device_type or '<unknown>'}; "
|
||||
f"readings={len(channel.readings)}",
|
||||
file=output,
|
||||
)
|
||||
return current_fields
|
||||
|
||||
|
||||
SerialFactory = Callable[..., serial.Serial]
|
||||
|
||||
|
||||
def run_probe(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
serial_factory: SerialFactory = serial.Serial,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
output: TextIO = sys.stdout,
|
||||
error_output: TextIO = sys.stderr,
|
||||
) -> int:
|
||||
"""Capture and report frames until duration elapses or the user interrupts.
|
||||
|
||||
The only operation on ``serial_port`` is ``read``. It is always closed,
|
||||
including after an interrupt, timeout, or a read error.
|
||||
"""
|
||||
|
||||
raw_output: BinaryIO | None = args.raw_output
|
||||
serial_port: serial.Serial | None = None
|
||||
try:
|
||||
serial_port = serial_factory(
|
||||
port=args.device,
|
||||
baudrate=args.baudrate,
|
||||
bytesize=args.bytesize,
|
||||
parity=args.parity,
|
||||
stopbits=args.stopbits,
|
||||
timeout=1,
|
||||
)
|
||||
framer = TelegramFramer()
|
||||
deadline = clock() + args.duration
|
||||
frame_number = 0
|
||||
last_frame_at: float | None = None
|
||||
previous_fields: dict[str, tuple[str, ...]] = {}
|
||||
while clock() < deadline:
|
||||
chunk = serial_port.read(4096)
|
||||
if not chunk:
|
||||
continue
|
||||
if raw_output is not None:
|
||||
raw_output.write(chunk)
|
||||
raw_output.flush()
|
||||
for frame in framer.feed(chunk):
|
||||
now = clock()
|
||||
telegram = parse_telegram(frame)
|
||||
frame_number += 1
|
||||
cadence = None if last_frame_at is None else now - last_frame_at
|
||||
previous_fields = _print_telegram(
|
||||
telegram,
|
||||
frame_number,
|
||||
cadence,
|
||||
previous_fields,
|
||||
args.show_changes,
|
||||
output,
|
||||
)
|
||||
last_frame_at = now
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print("capture interrupted; serial device closed", file=output)
|
||||
return 0
|
||||
except (serial.SerialException, OSError) as exc:
|
||||
print(_serial_error_message(exc), file=error_output)
|
||||
return 1
|
||||
finally:
|
||||
if serial_port is not None:
|
||||
serial_port.close()
|
||||
if raw_output is not None:
|
||||
raw_output.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the command-line probe."""
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
return run_probe(args)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||
raise SystemExit(main())
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/ISk5\2MT382-1000
|
||||
|
||||
1-3:0.2.8(50)
|
||||
0-0:1.0.0(240822120000S)
|
||||
0-0:96.1.1(TEST-GATEWAY)
|
||||
0-1:24.1.0(006)
|
||||
0-1:96.1.0(TEST-DHW)
|
||||
0-1:24.2.1(240822120000S)(5.900*m3)
|
||||
0-2:24.1.0(012)
|
||||
0-2:96.1.0(TEST-HEAT)
|
||||
0-2:24.2.1(240822120000S)(0.017*GJ)
|
||||
!D18A
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
)TU)2NWA-MYRSKY
|
||||
0-2:24.2.1(240822120000S)(0.017*GJ)
|
||||
0-1:96.1.0(KAM-REDACTED)
|
||||
0-0:1.0.0(240822120000S)
|
||||
0-2:24.1.0(012)
|
||||
0-1:24.2.1(240822120000S)(5.900*m3)
|
||||
0-0:96.1.1(WARMTE-REDACTED)
|
||||
0-1:24.1.0(006)
|
||||
0-2:96.1.0(KAM-REDACTED)
|
||||
1-3:0.2.8(50)
|
||||
!x7z?
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
from decimal import Decimal
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
|
||||
from scripts.p1_probe import (
|
||||
IntegrityStatus,
|
||||
TelegramFramer,
|
||||
build_parser,
|
||||
parse_telegram,
|
||||
run_probe,
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _fixture(name: str) -> bytes:
|
||||
return (FIXTURES / name).read_bytes()
|
||||
|
||||
|
||||
def test_standard_dsmr_fixture_has_a_valid_frame_and_crc():
|
||||
frame = _fixture("dsmr_p1_valid.txt")
|
||||
framer = TelegramFramer()
|
||||
|
||||
assert framer.feed(frame[:23]) == []
|
||||
assert framer.feed(frame[23:]) == [frame]
|
||||
|
||||
telegram = parse_telegram(frame)
|
||||
assert telegram.integrity is IntegrityStatus.VALID
|
||||
assert telegram.header == b"/ISk5\\2MT382-1000"
|
||||
assert telegram.timestamp == "240822120000S"
|
||||
|
||||
|
||||
def test_warmtelink_fixture_is_unverifiable_but_parses_channels_by_obis_code():
|
||||
telegram = parse_telegram(_fixture("warmtelink_p1_7n1.txt"))
|
||||
|
||||
assert telegram.integrity is IntegrityStatus.UNVERIFIABLE
|
||||
assert "missing standard" in telegram.integrity_reason
|
||||
assert [channel.number for channel in telegram.channels] == [1, 2]
|
||||
values = {
|
||||
channel.number: [(field.value, field.unit) for field in channel.readings]
|
||||
for channel in telegram.channels
|
||||
}
|
||||
assert values[1] == [(Decimal("5.900"), "m3")]
|
||||
assert values[2] == [(Decimal("0.017"), "GJ")]
|
||||
|
||||
|
||||
def test_arbitrary_chunk_boundaries_and_field_order_do_not_change_parsing():
|
||||
frame = _fixture("warmtelink_p1_7n1.txt")
|
||||
framer = TelegramFramer()
|
||||
frames = []
|
||||
for byte in frame:
|
||||
frames.extend(framer.feed(bytes([byte])))
|
||||
|
||||
assert frames == [frame]
|
||||
assert parse_telegram(frames[0]).channels == parse_telegram(frame).channels
|
||||
|
||||
|
||||
def test_unknown_fields_are_preserved_verbatim():
|
||||
frame = b")HEADER\n9-9:9.9.9(opaque)(still-opaque)\n!nope\n"
|
||||
|
||||
telegram = parse_telegram(frame)
|
||||
|
||||
assert len(telegram.fields) == 1
|
||||
assert telegram.fields[0].code == "9-9:9.9.9"
|
||||
assert telegram.fields[0].raw_values == ("opaque", "still-opaque")
|
||||
assert telegram.fields[0].value is None
|
||||
|
||||
|
||||
def test_missing_header_and_non_hex_footer_are_unverifiable():
|
||||
telegram = parse_telegram(b")HEADER\n0-0:1.0.0(240822120000S)\n!zzzz\n")
|
||||
|
||||
assert telegram.integrity is IntegrityStatus.UNVERIFIABLE
|
||||
assert telegram.footer == b"zzzz"
|
||||
|
||||
|
||||
def test_crc_mismatch_is_invalid_when_standard_framing_is_present():
|
||||
frame = _fixture("dsmr_p1_valid.txt")
|
||||
invalid = frame[:-5] + b"0000\n"
|
||||
|
||||
telegram = parse_telegram(invalid)
|
||||
|
||||
assert telegram.integrity is IntegrityStatus.INVALID
|
||||
|
||||
|
||||
class FakeSerial:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self.chunks = iter(chunks)
|
||||
self.closed = False
|
||||
|
||||
def read(self, _size: int) -> bytes:
|
||||
return next(self.chunks, b"")
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _clock(values: list[float]):
|
||||
ticks = iter(values)
|
||||
return lambda: next(ticks, values[-1])
|
||||
|
||||
|
||||
def test_cli_uses_measured_7n1_defaults_and_allows_overrides():
|
||||
parser = build_parser()
|
||||
|
||||
defaults = parser.parse_args(["--device", "/dev/serial/by-id/example"])
|
||||
overridden = parser.parse_args(
|
||||
[
|
||||
"--device",
|
||||
"/dev/test",
|
||||
"--baudrate",
|
||||
"9600",
|
||||
"--bytesize",
|
||||
"8",
|
||||
"--parity",
|
||||
"E",
|
||||
"--stopbits",
|
||||
"2",
|
||||
"--duration",
|
||||
"1",
|
||||
]
|
||||
)
|
||||
|
||||
assert (defaults.baudrate, defaults.bytesize, defaults.parity, defaults.stopbits) == (115200, 7, "N", 1)
|
||||
assert (overridden.baudrate, overridden.bytesize, overridden.parity, overridden.stopbits) == (
|
||||
9600,
|
||||
8,
|
||||
"E",
|
||||
2,
|
||||
)
|
||||
assert "locally measured" in parser.format_help()
|
||||
|
||||
|
||||
def test_probe_reads_fake_chunks_writes_exact_raw_bytes_and_closes_device(tmp_path):
|
||||
frame = _fixture("warmtelink_p1_7n1.txt")
|
||||
fake = FakeSerial([frame[:17], frame[17:]])
|
||||
raw_path = tmp_path / "capture.bin"
|
||||
args = build_parser().parse_args(
|
||||
["--device", "/dev/serial/by-id/fake", "--duration", "3", "--raw-output", str(raw_path)]
|
||||
)
|
||||
created: dict[str, object] = {}
|
||||
|
||||
def serial_factory(**kwargs):
|
||||
created.update(kwargs)
|
||||
return fake
|
||||
|
||||
output = io.StringIO()
|
||||
result = run_probe(
|
||||
args,
|
||||
serial_factory=serial_factory,
|
||||
clock=_clock([0, 0.1, 0.2, 0.3, 4]),
|
||||
output=output,
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert fake.closed
|
||||
assert raw_path.read_bytes() == frame
|
||||
assert created == {
|
||||
"port": "/dev/serial/by-id/fake",
|
||||
"baudrate": 115200,
|
||||
"bytesize": 7,
|
||||
"parity": "N",
|
||||
"stopbits": 1,
|
||||
"timeout": 1,
|
||||
}
|
||||
assert "unverifiable" in output.getvalue()
|
||||
assert "0-1:24.2.1" in output.getvalue()
|
||||
|
||||
|
||||
def test_show_changes_filters_unchanged_fields_and_reports_cadence():
|
||||
frame = _fixture("warmtelink_p1_7n1.txt")
|
||||
changed = frame.replace(b"(5.900*m3)", b"(5.901*m3)")
|
||||
fake = FakeSerial([frame + changed])
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "2", "--show-changes"])
|
||||
output = io.StringIO()
|
||||
|
||||
result = run_probe(
|
||||
args,
|
||||
serial_factory=lambda **_kwargs: fake,
|
||||
clock=_clock([0, 0.1, 0.2, 1.2, 3]),
|
||||
output=output,
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert "cadence=1.0s" in output.getvalue()
|
||||
assert "changed fields: 1" in output.getvalue()
|
||||
assert fake.closed
|
||||
|
||||
|
||||
def test_probe_diagnoses_permission_errors_without_suggesting_root():
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "1"])
|
||||
errors = io.StringIO()
|
||||
|
||||
def serial_factory(**_kwargs):
|
||||
raise serial.SerialException("[Errno 13] Permission denied: '/dev/fake'")
|
||||
|
||||
assert run_probe(args, serial_factory=serial_factory, error_output=errors) == 1
|
||||
assert "dialout" in errors.getvalue()
|
||||
assert "run it as root" in errors.getvalue()
|
||||
|
||||
|
||||
def test_probe_diagnoses_busy_or_disconnected_device_and_closes_after_read_error():
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "1"])
|
||||
errors = io.StringIO()
|
||||
|
||||
def busy_factory(**_kwargs):
|
||||
raise serial.SerialException("[Errno 16] Device or resource busy: '/dev/fake'")
|
||||
|
||||
assert run_probe(args, serial_factory=busy_factory, error_output=errors) == 1
|
||||
assert "Close the program" in errors.getvalue()
|
||||
|
||||
class DisconnectingSerial(FakeSerial):
|
||||
def read(self, _size: int) -> bytes:
|
||||
raise serial.SerialException("[Errno 5] device disconnected")
|
||||
|
||||
fake = DisconnectingSerial([])
|
||||
errors = io.StringIO()
|
||||
assert run_probe(args, serial_factory=lambda **_kwargs: fake, error_output=errors) == 1
|
||||
assert fake.closed
|
||||
assert "Check the cable" in errors.getvalue()
|
||||
|
||||
|
||||
def test_probe_closes_device_when_interrupted():
|
||||
class InterruptingSerial(FakeSerial):
|
||||
def read(self, _size: int) -> bytes:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
fake = InterruptingSerial([])
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "1"])
|
||||
|
||||
assert run_probe(args, serial_factory=lambda **_kwargs: fake) == 0
|
||||
assert fake.closed
|
||||
Reference in New Issue
Block a user