Compare commits
13
Commits
v1.3.0
..
16b050d821
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b050d821 | ||
|
|
d3d914b117 | ||
|
|
fc4af857e3 | ||
|
|
0958d9a2e9 | ||
|
|
b405aea88b | ||
|
|
bfc7aa3031 | ||
|
|
2f63b9630c | ||
|
|
d07a083e03 | ||
|
|
b65f700d56 | ||
|
|
f4cea3874b | ||
|
|
134f0abb5f | ||
|
|
3e04b15656 | ||
|
|
f2e8f6a8e7 |
@@ -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
|
|
||||||
```
|
|
||||||
@@ -169,7 +169,7 @@ def get_prices(
|
|||||||
|
|
||||||
Response ``points`` carries per-slot:
|
Response ``points`` carries per-slot:
|
||||||
- ``buy = total`` (Tibber all-inclusive price)
|
- ``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)
|
- ``level`` (Tibber price level, may be null)
|
||||||
|
|
||||||
``tariff`` is null.
|
``tariff`` is null.
|
||||||
@@ -222,7 +222,8 @@ def get_prices(
|
|||||||
)
|
)
|
||||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||||
|
|
||||||
# Derive sell price per-point using version values (energy_tax + sell_adjust).
|
# Derive sell price per-point using version values
|
||||||
|
# (energy_tax + sell_fee + sell_adjust).
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
def _d(v: Any) -> Decimal:
|
def _d(v: Any) -> Decimal:
|
||||||
@@ -230,12 +231,13 @@ def get_prices(
|
|||||||
|
|
||||||
energy = version.values.get("energy", {}) if version.values else {}
|
energy = version.values.get("energy", {}) if version.values else {}
|
||||||
energy_tax = _d(energy.get("energy_tax", 0))
|
energy_tax = _d(energy.get("energy_tax", 0))
|
||||||
|
sell_fee = _d(energy.get("sell_fee", 0))
|
||||||
sell_adjust = _d(energy.get("sell_adjust", 0))
|
sell_adjust = _d(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
points = []
|
points = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
total = _d(row.total)
|
total = _d(row.total)
|
||||||
sell = float(total - energy_tax - sell_adjust)
|
sell = float(total - energy_tax - sell_fee - sell_adjust)
|
||||||
points.append(
|
points.append(
|
||||||
PricePointSchema(
|
PricePointSchema(
|
||||||
starts_at=_as_utc(row.starts_at),
|
starts_at=_as_utc(row.starts_at),
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ def test_read(
|
|||||||
device.port,
|
device.port,
|
||||||
device.unit_id,
|
device.unit_id,
|
||||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
function_code=profile.function_code,
|
||||||
)
|
)
|
||||||
payload: dict[str, Any] = decode_profile(profile, registers)
|
payload: dict[str, Any] = decode_profile(profile, registers)
|
||||||
return ModbusTestReadResponse(ok=True, payload=payload)
|
return ModbusTestReadResponse(ok=True, payload=payload)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Modbus TCP driver — thin wrapper around pymodbus.
|
"""Modbus TCP driver — thin wrapper around pymodbus.
|
||||||
|
|
||||||
This module provides a single public function ``read_blocks`` that performs
|
This module provides a single public function ``read_blocks`` that performs
|
||||||
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
|
one or more block reads against a Modbus TCP gateway — using either FC04
|
||||||
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
|
(Read Input Registers) or FC03 (Read Holding Registers), selected per call
|
||||||
|
via the ``function_code`` argument — and returns a flat
|
||||||
|
``dict[register_address -> 16-bit_value]`` map. The function code comes from
|
||||||
|
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
|
||||||
|
|
||||||
Design decisions
|
Design decisions
|
||||||
----------------
|
----------------
|
||||||
@@ -80,9 +83,10 @@ def read_blocks(
|
|||||||
unit_id: int,
|
unit_id: int,
|
||||||
blocks: Sequence[Block],
|
blocks: Sequence[Block],
|
||||||
*,
|
*,
|
||||||
|
function_code: int = 4,
|
||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
) -> dict[int, int]:
|
) -> dict[int, int]:
|
||||||
"""Read one or more contiguous register blocks via FC04 (input registers).
|
"""Read one or more contiguous register blocks via FC03 or FC04.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -96,6 +100,11 @@ def read_blocks(
|
|||||||
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
||||||
contiguous register ranges to read. ``count`` is the number of
|
contiguous register ranges to read. ``count`` is the number of
|
||||||
16-bit registers (not bytes).
|
16-bit registers (not bytes).
|
||||||
|
function_code:
|
||||||
|
Modbus read function code: ``4`` for FC04 (Read Input Registers,
|
||||||
|
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
|
||||||
|
DDSU666 and other devices that expose measurements as holding
|
||||||
|
registers). Comes from the device profile's ``function_code`` field.
|
||||||
timeout:
|
timeout:
|
||||||
TCP connect/read timeout in seconds (default 3 s).
|
TCP connect/read timeout in seconds (default 3 s).
|
||||||
|
|
||||||
@@ -107,12 +116,21 @@ def read_blocks(
|
|||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
|
ModbusDriverError
|
||||||
|
If ``function_code`` is neither 3 nor 4 (validated before any
|
||||||
|
connection is attempted).
|
||||||
ModbusConnectionError
|
ModbusConnectionError
|
||||||
If the TCP connection to the gateway fails.
|
If the TCP connection to the gateway fails.
|
||||||
ModbusResponseError
|
ModbusResponseError
|
||||||
If the gateway returns a Modbus exception frame or an unexpected
|
If the gateway returns a Modbus exception frame or an unexpected
|
||||||
number of registers.
|
number of registers.
|
||||||
"""
|
"""
|
||||||
|
if function_code not in (3, 4):
|
||||||
|
raise ModbusDriverError(
|
||||||
|
f"Unsupported read function code FC{function_code:02d} "
|
||||||
|
f"(only FC03 holding-register and FC04 input-register reads are supported)"
|
||||||
|
)
|
||||||
|
|
||||||
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
connected = client.connect()
|
connected = client.connect()
|
||||||
@@ -125,7 +143,7 @@ def read_blocks(
|
|||||||
for block in blocks:
|
for block in blocks:
|
||||||
start: int = block["start"]
|
start: int = block["start"]
|
||||||
count: int = block["count"]
|
count: int = block["count"]
|
||||||
_read_block(client, unit_id, start, count, registers)
|
_read_block(client, unit_id, start, count, registers, function_code=function_code)
|
||||||
|
|
||||||
return registers
|
return registers
|
||||||
|
|
||||||
@@ -145,9 +163,18 @@ def _read_block(
|
|||||||
start: int,
|
start: int,
|
||||||
count: int,
|
count: int,
|
||||||
result: dict[int, int],
|
result: dict[int, int],
|
||||||
|
*,
|
||||||
|
function_code: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Read one block and merge into *result*. Raises on any error."""
|
"""Read one block and merge into *result*. Raises on any error.
|
||||||
|
|
||||||
|
``function_code`` is assumed already validated to be 3 or 4 by the caller
|
||||||
|
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
if function_code == 3:
|
||||||
|
response = client.read_holding_registers(start, count=count, device_id=unit_id)
|
||||||
|
else: # function_code == 4 (input registers)
|
||||||
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||||
except ConnectionException as exc:
|
except ConnectionException as exc:
|
||||||
raise ModbusConnectionError(
|
raise ModbusConnectionError(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: ddsu666
|
||||||
|
description: CHINT DDSU666 single-phase smart meter
|
||||||
|
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
|
||||||
|
word_order: big # high register first — ASSUMED; verify with a known voltage reading
|
||||||
|
byte_order: big # high byte first within each register (confirmed by manual CRC example)
|
||||||
|
|
||||||
|
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
|
||||||
|
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
|
||||||
|
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
|
||||||
|
# device uses the opposite word order and this profile (and the decoder) need adjusting.
|
||||||
|
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
|
||||||
|
|
||||||
|
blocks:
|
||||||
|
# Instantaneous quantities 0x2000–0x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
|
||||||
|
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
|
||||||
|
- { start: 0x2000, count: 0x0010 }
|
||||||
|
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
|
||||||
|
# than one span, to avoid touching the undocumented/reserved 0x4002–0x4009 gap.
|
||||||
|
- { start: 0x4000, count: 0x0002 }
|
||||||
|
- { start: 0x400A, count: 0x0002 }
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
|
||||||
|
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
|
||||||
|
|
||||||
|
- key: voltage
|
||||||
|
address: 0x2000 # U — Voltage (V)
|
||||||
|
type: float32
|
||||||
|
unit: "V"
|
||||||
|
device_class: voltage
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: current
|
||||||
|
address: 0x2002 # I — Current (A)
|
||||||
|
type: float32
|
||||||
|
unit: "A"
|
||||||
|
device_class: current
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: active_power
|
||||||
|
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
|
||||||
|
type: float32
|
||||||
|
unit: "kW"
|
||||||
|
device_class: power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: reactive_power
|
||||||
|
address: 0x2006 # Q — Reactive power (kvar)
|
||||||
|
type: float32
|
||||||
|
unit: "kvar"
|
||||||
|
device_class: reactive_power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: power_factor
|
||||||
|
address: 0x200A # PF — Power factor (dimensionless)
|
||||||
|
type: float32
|
||||||
|
unit: ""
|
||||||
|
device_class: power_factor
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: frequency
|
||||||
|
address: 0x200E # Freq — Frequency (Hz)
|
||||||
|
type: float32
|
||||||
|
unit: "Hz"
|
||||||
|
device_class: frequency
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: import_energy
|
||||||
|
address: 0x4000 # Ep — positive/forward active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: export_energy
|
||||||
|
address: 0x400A # -Ep — reverse active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
@@ -123,6 +123,7 @@ class ManualProfile(BaseModel):
|
|||||||
class TibberEnergySpec(BaseModel):
|
class TibberEnergySpec(BaseModel):
|
||||||
source: str # must be "tibber_api"
|
source: str # must be "tibber_api"
|
||||||
energy_tax: FieldSpec # subtracted from total to derive sell price
|
energy_tax: FieldSpec # subtracted from total to derive sell price
|
||||||
|
sell_fee: FieldSpec # verkoopvergoeding (feed-in fee); always subtracted from sell; default 0.0248
|
||||||
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
||||||
|
|
||||||
|
|
||||||
@@ -268,10 +269,13 @@ def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dic
|
|||||||
|
|
||||||
|
|
||||||
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
|
"""Return a copy of *values* with sell_fee, sell_adjust and management_fee defaults applied."""
|
||||||
filled = dict(values)
|
filled = dict(values)
|
||||||
|
|
||||||
energy = dict(filled.get("energy", {}))
|
energy = dict(filled.get("energy", {}))
|
||||||
|
# Apply default for sell_fee (default=0.0248) if absent.
|
||||||
|
if "sell_fee" not in energy and profile.energy.sell_fee.default is not None:
|
||||||
|
energy["sell_fee"] = profile.energy.sell_fee.default
|
||||||
# Apply default for sell_adjust (default=0) if absent.
|
# Apply default for sell_adjust (default=0) if absent.
|
||||||
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
||||||
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
||||||
@@ -347,6 +351,7 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
|
|||||||
|
|
||||||
# Required energy fields.
|
# Required energy fields.
|
||||||
_require_numeric("energy", "energy_tax", energy)
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "sell_fee", energy)
|
||||||
_require_numeric("energy", "sell_adjust", energy)
|
_require_numeric("energy", "sell_adjust", energy)
|
||||||
# Required standing fields.
|
# Required standing fields.
|
||||||
_require_numeric("standing", "management_fee", standing)
|
_require_numeric("standing", "management_fee", standing)
|
||||||
@@ -361,9 +366,10 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"""Validate a contract-values dict against the named profile structure.
|
"""Validate a contract-values dict against the named profile structure.
|
||||||
|
|
||||||
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
||||||
``sell_adjust``, tibber ``management_fee``) are silently filled in when
|
``sell_fee``, ``sell_adjust``, tibber ``management_fee``) are silently
|
||||||
absent from *values*. Fields with no default that are absent, or fields
|
filled in when absent from *values*. Fields with no default that are
|
||||||
whose value is not a number, cause a ``ProfileValidationError``.
|
absent, or fields whose value is not a number, cause a
|
||||||
|
``ProfileValidationError``.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ kind: tibber
|
|||||||
label: Tibber 动态电价(15 分钟)
|
label: Tibber 动态电价(15 分钟)
|
||||||
|
|
||||||
energy:
|
energy:
|
||||||
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_fee − sell_adjust
|
||||||
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
||||||
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
|
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding (feed-in fee, incl. VAT); always subtracted from sell
|
||||||
|
sell_adjust: { unit: EUR/kWh, default: 0 } # manual sell-price adjustment; net-metering: set = −energy_tax to refund the tax
|
||||||
|
|
||||||
standing: # fixed charges; UI fills per month, engine prorates to days
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
management_fee: { unit: EUR/month, default: 5.99 }
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
|||||||
@@ -209,12 +209,23 @@ def _tibber_strategy(
|
|||||||
query on ``starts_at``.
|
query on ``starts_at``.
|
||||||
|
|
||||||
Formula (§3.4):
|
Formula (§3.4):
|
||||||
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
- ``buy = total`` (Tibber's all-inclusive price; already includes energy
|
||||||
- ``sell = total − energy_tax − sell_adjust``
|
tax, VAT and the buy-side ``inkoopvergoeding``)
|
||||||
|
- ``sell = total − energy_tax − sell_fee − sell_adjust``
|
||||||
- ``import_cost = (Δd1 + Δd2) × buy``
|
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||||
- ``export_revenue = (Δr1 + Δr2) × sell``
|
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||||
- ``net_cost = import_cost − export_revenue``
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
``sell_fee`` models Tibber's per-kWh **verkoopvergoeding** (feed-in fee,
|
||||||
|
€0.0248/kWh incl. VAT since 2026-01-01). It is always deducted from the
|
||||||
|
feed-in payout: even under the net-metering (saldering) scheme, Tibber pays
|
||||||
|
``total − verkoopvergoeding`` per returned kWh (Tibber NL: "€0,28 − €0,0248
|
||||||
|
= €0,2552"). ``total`` already contains the equal buy-side
|
||||||
|
``inkoopvergoeding``, so the two fees do **not** cancel — the feed-in price
|
||||||
|
sits ``sell_fee`` below the buy price. ``sell_adjust`` is a separate manual
|
||||||
|
correction: under net metering it carries back the refunded energy tax
|
||||||
|
(``sell_adjust = −energy_tax``), leaving ``sell = total − sell_fee``.
|
||||||
|
|
||||||
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||||
API price applies to the full delivered/returned volume.
|
API price applies to the full delivered/returned volume.
|
||||||
|
|
||||||
@@ -248,11 +259,12 @@ def _tibber_strategy(
|
|||||||
|
|
||||||
energy = values.get("energy", {})
|
energy = values.get("energy", {})
|
||||||
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
sell_fee = _to_decimal(energy.get("sell_fee", 0))
|
||||||
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
total = _to_decimal(price_row.total)
|
total = _to_decimal(price_row.total)
|
||||||
buy = total
|
buy = total
|
||||||
sell = total - energy_tax - sell_adjust
|
sell = total - energy_tax - sell_fee - sell_adjust
|
||||||
|
|
||||||
total_delivered = deltas.d1 + deltas.d2
|
total_delivered = deltas.d1 + deltas.d2
|
||||||
total_returned = deltas.r1 + deltas.r2
|
total_returned = deltas.r1 + deltas.r2
|
||||||
@@ -269,6 +281,7 @@ def _tibber_strategy(
|
|||||||
"buy": str(buy),
|
"buy": str(buy),
|
||||||
"sell": str(sell),
|
"sell": str(sell),
|
||||||
"energy_tax": str(energy_tax),
|
"energy_tax": str(energy_tax),
|
||||||
|
"sell_fee": str(sell_fee),
|
||||||
"sell_adjust": str(sell_adjust),
|
"sell_adjust": str(sell_adjust),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,20 +30,39 @@ logger = logging.getLogger(__name__)
|
|||||||
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
||||||
_DEFAULT_TIMEOUT = 15.0
|
_DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
# GraphQL query to fetch a range of 15-minute price nodes.
|
# GraphQL query to fetch the forward-looking today + tomorrow price curve at
|
||||||
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
# 15-minute resolution.
|
||||||
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
|
#
|
||||||
# but the Tibber API typically starts from the current slot and returns at
|
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
|
||||||
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
# * ``today`` — always the full current local day (96 quarter-hourly slots,
|
||||||
# "today + tomorrow").
|
# 00:00 → 23:45 local), regardless of the current time.
|
||||||
|
# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
|
||||||
|
# day-ahead prices (around 13:00–15:00 local); empty before that.
|
||||||
|
#
|
||||||
|
# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
|
||||||
|
# connection whose range ends at "now" (it never returns future slots), so it
|
||||||
|
# cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every
|
||||||
|
# 15-minute slot's price is present in the DB *before* the slot closes — which is
|
||||||
|
# what makes per-slot billing accurate (each period finds its own exact slot
|
||||||
|
# instead of falling back to a stale earlier price) and keeps the live current-
|
||||||
|
# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's
|
||||||
|
# prices are picked up within an hour of publication without a restart.
|
||||||
_PRICE_RANGE_QUERY = """
|
_PRICE_RANGE_QUERY = """
|
||||||
{
|
{
|
||||||
viewer {
|
viewer {
|
||||||
homes {
|
homes {
|
||||||
id
|
id
|
||||||
currentSubscription {
|
currentSubscription {
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
priceInfo(resolution: QUARTER_HOURLY) {
|
||||||
nodes {
|
today {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
energy
|
energy
|
||||||
@@ -230,11 +249,15 @@ def fetch_price_range(
|
|||||||
*,
|
*,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
) -> list[PricePoint]:
|
) -> list[PricePoint]:
|
||||||
"""Fetch a range of 15-minute price nodes from the Tibber API.
|
"""Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber.
|
||||||
|
|
||||||
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
|
Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
|
||||||
and parses every returned node into a ``PricePoint``. The number of nodes
|
and parses every node from both lists (today first, then tomorrow) into a
|
||||||
is not assumed — all returned nodes are parsed regardless of count.
|
``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
|
||||||
|
full current local day and ``tomorrow`` is populated once Tibber publishes the
|
||||||
|
day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange``
|
||||||
|
which only reaches "now". ``tomorrow`` may be empty (before publication); the
|
||||||
|
number of nodes is not assumed and all returned nodes are parsed.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -268,10 +291,15 @@ def fetch_price_range(
|
|||||||
home = _pick_home(homes, home_id)
|
home = _pick_home(homes, home_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
|
price_info = home["currentSubscription"]["priceInfo"]
|
||||||
|
today = price_info["today"]
|
||||||
|
tomorrow = price_info["tomorrow"]
|
||||||
except (KeyError, TypeError) as exc:
|
except (KeyError, TypeError) as exc:
|
||||||
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
|
raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc
|
||||||
|
|
||||||
|
# tomorrow is null/empty until Tibber publishes the day-ahead prices; treat
|
||||||
|
# a missing list as empty so we still return today's slots.
|
||||||
|
nodes = list(today or []) + list(tomorrow or [])
|
||||||
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -121,15 +121,29 @@ class CostsResponse(BaseModel):
|
|||||||
class SummaryResponse(BaseModel):
|
class SummaryResponse(BaseModel):
|
||||||
"""Response for GET /api/energy/costs/summary.
|
"""Response for GET /api/energy/costs/summary.
|
||||||
|
|
||||||
All monetary values are in ``currency``.
|
Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
|
||||||
|
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
|
||||||
|
only the ``_kwh``-suffixed fields carry kWh.
|
||||||
|
|
||||||
``total_payable = metered_net + fixed_costs − credits``
|
``total_payable = metered_net + fixed_costs − credits``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
currency: str
|
currency: str
|
||||||
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
|
metered_import: float = Field(
|
||||||
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
|
description="Σ import_cost for non-degraded periods (money, in `currency`)."
|
||||||
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
|
)
|
||||||
|
metered_export: float = Field(
|
||||||
|
description="Σ export_revenue for non-degraded periods (money, in `currency`)."
|
||||||
|
)
|
||||||
|
metered_net: float = Field(
|
||||||
|
description="Σ net_cost for non-degraded periods (money, in `currency`)."
|
||||||
|
)
|
||||||
|
metered_import_kwh: float = Field(
|
||||||
|
description="Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
|
||||||
|
)
|
||||||
|
metered_export_kwh: float = Field(
|
||||||
|
description="Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
|
||||||
|
)
|
||||||
fixed_costs: float = Field(
|
fixed_costs: float = Field(
|
||||||
description="Standing charges (network_fee + management_fee) apportioned over the interval."
|
description="Standing charges (network_fee + management_fee) apportioned over the interval."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ M6 design document, extended in M7-T03 to be meter-aware:
|
|||||||
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
|
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
|
||||||
apportioned at EUR/year ÷ 365 per day).
|
apportioned at EUR/year ÷ 365 per day).
|
||||||
|
|
||||||
|
The summary reports **both** money and energy: ``metered_import`` /
|
||||||
|
``metered_export`` are monetary totals (Σ import_cost / Σ export_revenue),
|
||||||
|
while ``metered_import_kwh`` / ``metered_export_kwh`` are the corresponding
|
||||||
|
metered energy totals in kWh. The ``_kwh`` suffix is the only thing that
|
||||||
|
distinguishes them — always check it before labelling a value in a UI.
|
||||||
|
|
||||||
Design notes
|
Design notes
|
||||||
------------
|
------------
|
||||||
- **Decimal arithmetic throughout**: all monetary computations use
|
- **Decimal arithmetic throughout**: all monetary computations use
|
||||||
@@ -732,9 +738,11 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
dict with keys:
|
dict with keys:
|
||||||
|
|
||||||
currency str ISO 4217 currency (from contract, or "EUR" fallback)
|
currency str ISO 4217 currency (from contract, or "EUR" fallback)
|
||||||
metered_import float Σ import_cost from non-degraded periods
|
metered_import float Σ import_cost from non-degraded periods (money)
|
||||||
metered_export float Σ export_revenue from non-degraded periods
|
metered_export float Σ export_revenue from non-degraded periods (money)
|
||||||
metered_net float Σ net_cost from non-degraded periods
|
metered_net float Σ net_cost from non-degraded periods (money)
|
||||||
|
metered_import_kwh float Σ (d1_kwh + d2_kwh) from non-degraded periods (energy)
|
||||||
|
metered_export_kwh float Σ (r1_kwh + r2_kwh) from non-degraded periods (energy)
|
||||||
fixed_costs float standing charges for elapsed whole local days
|
fixed_costs float standing charges for elapsed whole local days
|
||||||
credits float energy-tax credit for elapsed whole local days
|
credits float energy-tax credit for elapsed whole local days
|
||||||
total_payable float metered_net + fixed_costs − credits
|
total_payable float metered_net + fixed_costs − credits
|
||||||
@@ -763,6 +771,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
|
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
|
||||||
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
|
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
|
||||||
|
|
||||||
|
# Σ metered energy (kWh), summed across both tariff registers. Reuses the
|
||||||
|
# already-fetched ``good_rows`` so no extra query is issued.
|
||||||
|
sum_import_kwh = sum(
|
||||||
|
(_to_decimal(r.d1_kwh) + _to_decimal(r.d2_kwh) for r in good_rows), Decimal("0")
|
||||||
|
)
|
||||||
|
sum_export_kwh = sum(
|
||||||
|
(_to_decimal(r.r1_kwh) + _to_decimal(r.r2_kwh) for r in good_rows), Decimal("0")
|
||||||
|
)
|
||||||
|
|
||||||
# --- Interval length in days (window, not elapsed — kept for API compat) ---
|
# --- Interval length in days (window, not elapsed — kept for API compat) ---
|
||||||
total_seconds = (end_utc - start_utc).total_seconds()
|
total_seconds = (end_utc - start_utc).total_seconds()
|
||||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||||
@@ -885,6 +902,8 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
"metered_import": float(sum_import),
|
"metered_import": float(sum_import),
|
||||||
"metered_export": float(sum_export),
|
"metered_export": float(sum_export),
|
||||||
"metered_net": float(sum_net),
|
"metered_net": float(sum_net),
|
||||||
|
"metered_import_kwh": float(sum_import_kwh),
|
||||||
|
"metered_export_kwh": float(sum_export_kwh),
|
||||||
"fixed_costs": float(fixed_dec),
|
"fixed_costs": float(fixed_dec),
|
||||||
"credits": float(credits_dec),
|
"credits": float(credits_dec),
|
||||||
"total_payable": float(total_payable),
|
"total_payable": float(total_payable),
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
|
|||||||
device.port,
|
device.port,
|
||||||
device.unit_id,
|
device.unit_id,
|
||||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
function_code=profile.function_code,
|
||||||
)
|
)
|
||||||
payload = profiles.decode(profile, registers)
|
payload = profiles.decode(profile, registers)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
||||||
- [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺
|
- [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺
|
||||||
- [`m7-meter-epochs-archival.md`](./m7-meter-epochs-archival.md) — 电表生命周期 / 换表归档(Meter epochs)
|
- [`m7-meter-epochs-archival.md`](./m7-meter-epochs-archival.md) — 电表生命周期 / 换表归档(Meter epochs)
|
||||||
|
- [`pre-m8-warmtelink-p1-poc.md`](./pre-m8-warmtelink-p1-poc.md) — WarmteLink P1 真机概念验证(等待线到货)
|
||||||
|
- [`m8-warmtelink-energy.md`](./m8-warmtelink-energy.md) — WarmteLink P1 与多数据源 Meter(Planning 占位)
|
||||||
|
|
||||||
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
|
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ credits:
|
|||||||
kind: tibber
|
kind: tibber
|
||||||
label: Tibber 动态电价(15 分钟)
|
label: Tibber 动态电价(15 分钟)
|
||||||
energy:
|
energy:
|
||||||
source: tibber_api # buy = total; sell = total − energy_tax − sell_adjust
|
source: tibber_api # buy = total; sell = total − energy_tax − sell_fee − sell_adjust
|
||||||
energy_tax: { unit: EUR/kWh }
|
energy_tax: { unit: EUR/kWh }
|
||||||
|
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted
|
||||||
sell_adjust: { unit: EUR/kWh, default: 0 }
|
sell_adjust: { unit: EUR/kWh, default: 0 }
|
||||||
standing:
|
standing:
|
||||||
management_fee: { unit: EUR/month, default: 5.99 }
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
@@ -164,7 +165,7 @@ credits:
|
|||||||
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
||||||
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
||||||
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
||||||
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_adjust`;`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding,默认 0.0248,见下修正说明);`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
||||||
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
||||||
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ credits:
|
|||||||
|
|
||||||
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
||||||
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
||||||
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_adjust`;manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax;含 inkoopvergoeding);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding 默认 0.0248);manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
||||||
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
||||||
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
||||||
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
||||||
@@ -475,7 +476,7 @@ Phase D(API + 前端)
|
|||||||
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
||||||
|
|
||||||
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
||||||
2. **卖价残差(tibber)**:`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。
|
2. **卖价残差(tibber)**:~~`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)~~ → **已修正(2026-07,见 references §3.1)**:`total` 含 inkoopvergoeding,净计量回送 = `total − verkoopvergoeding`,两费**不抵消**。公式改为 `sell = total − energy_tax − sell_fee − sell_adjust`,新增 `sell_fee`(默认 0.0248,始终扣除);`sell_adjust` 净计量期设 `−energy_tax`。真实账单确认后若有残差再调 `sell_fee`。
|
||||||
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
||||||
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
||||||
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# M8 — WarmteLink P1 与多数据源 Meter(Planning 占位)
|
||||||
|
|
||||||
|
> **状态:Planning 占位;尚未拆解任务卡,尚未锁定架构。** M8 必须等待 [Pre-M8 真机概念验证](./pre-m8-warmtelink-p1-poc.md)完成后再进入正式设计。
|
||||||
|
|
||||||
|
## 1. 候选目标
|
||||||
|
|
||||||
|
把 Vattenfall WarmteLink 的 P1 数据接入现有 Energy 模块,至少支持:
|
||||||
|
|
||||||
|
- 区域供暖累计热量(GJ)。
|
||||||
|
- 真机 telegram 确认存在时的生活热水累计量(预计为 m³,最终以实测为准)。
|
||||||
|
- 历史读数、当前状态以及按需暴露给 Home Assistant。
|
||||||
|
- 与现有 Meter epoch/换表归档语义兼容。
|
||||||
|
|
||||||
|
## 2. 当前已知边界
|
||||||
|
|
||||||
|
- 现有 DSMR 模块独立订阅 `dsmr/json`,把 DSMR Reader 已解析的 JSON 降采样写入 `dsmr_reading`。
|
||||||
|
- 现有 `Meter` 表示物理计量表的安装 epoch,本身不订阅 MQTT,也不负责解析 telegram。
|
||||||
|
- 当前 electricity Meter 与 `dsmr_reading` 之间没有显式 source FK/binding;电费计算通过代码约定直接查询 DSMR 电力寄存器。
|
||||||
|
- `Meter.commodity` 后端已为 `heating` 等品类预留,但“增加 commodity”本身不会自动获得相应数据源或解析能力。
|
||||||
|
- 当前 Devices UI/模型是 Modbus 专用,不能直接假设 WarmteLink 应复用 `modbus_device`。
|
||||||
|
|
||||||
|
## 3. 下一轮 Planning 必须讨论的问题
|
||||||
|
|
||||||
|
以下问题当前全部保持开放,不在本占位文档中拍板:
|
||||||
|
|
||||||
|
1. Meter 是否显式绑定可配置的数据源,以及绑定的生命周期和基数。
|
||||||
|
2. 如何表示现有 DSMR MQTT source 与新的 WarmteLink P1 serial source。
|
||||||
|
3. “Device”与“Data Source”是否为同一概念;前端 Devices 是否需要改名或分组。
|
||||||
|
4. 一个 P1 source 暴露多个 measurement channel 时,如何映射到一个或多个 Meter。
|
||||||
|
5. 直接 P1 读数是否使用独立存储,还是将现有 `dsmr_reading` 泛化;如何保证多 source 去重和隔离。
|
||||||
|
6. heating GJ 与可选 hot-water m³ 的 commodity、单位、累计/换表语义。
|
||||||
|
7. M8 是否只做采集与展示;区域供暖合同、价格和成本计算是否留到后续里程碑。
|
||||||
|
8. 串口 worker 的重连、停止、配置热更新、Docker device mapping 与权限边界。
|
||||||
|
|
||||||
|
## 4. Planning 入口条件
|
||||||
|
|
||||||
|
正式编写 M8 目标架构、数据模型和原子任务卡前,至少需要:
|
||||||
|
|
||||||
|
- Pre-M8 通过并留下脱敏字段清单。
|
||||||
|
- 确认实际存在几个累计量及其单位、equipment id/channel 和更新时间。
|
||||||
|
- 确认原始 telegram 的稳定性与 parser 适配方式。
|
||||||
|
- 重新走查现有 DSMR ingest、Meter epoch、Modbus device、expose/HA 和 Energy 前端边界。
|
||||||
|
- 与用户讨论并锁定 Meter ↔ source 的配置体验后,再决定 migration/API/UI 方案。
|
||||||
|
|
||||||
|
## 5. 当前明确不做
|
||||||
|
|
||||||
|
- 本占位不创建 implementation task,不授权 schema/API/frontend 变更。
|
||||||
|
- 不假设生活热水 m³ 一定可读,也不承诺可拆分“空间供暖 GJ”和“生活热水 GJ”。
|
||||||
|
- 不提前把 WarmteLink 塞进 `modbus_device` 或现有 `dsmr_reading`。
|
||||||
|
- 不在缺少真机证据时设计通用 telemetry framework。
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Pre-M8 — WarmteLink P1 真机概念验证
|
||||||
|
|
||||||
|
> **状态:等待 USB→P1 线到货后执行。** 本文只定义验证边界与证据要求;当前仓库尚未实现下文所示的 probe 命令。
|
||||||
|
|
||||||
|
## 1. 目的
|
||||||
|
|
||||||
|
在进入 M8 正式设计和实现前,先用新家的 Vattenfall WarmteLink 做一次只读真机验证,回答以下问题:
|
||||||
|
|
||||||
|
1. 当前 USB→P1 线、主机串口权限和 WarmteLink P1 端口能否稳定输出完整 telegram。
|
||||||
|
2. telegram 的 framing、CRC、时间戳、OBIS/M-Bus channel 和单位能否被 parser 正确识别。
|
||||||
|
3. 实际能够读取哪些累计量:区域供暖热量(GJ)、生活热水体积(m³)或其它字段。
|
||||||
|
4. 读数的精度、更新频率和累计语义,是否与热力表/水表面板上的数字一致。
|
||||||
|
|
||||||
|
Pre-M8 是 M8 的证据门:在真机字段和语义确认前,不决定数据库结构、Meter 数据源绑定、后台采集服务或前端布局。
|
||||||
|
|
||||||
|
## 2. 执行边界
|
||||||
|
|
||||||
|
本阶段只建立下面这条最短链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WarmteLink P1 → USB serial → 完整 telegram → CRC 校验 → 字段解析 → 终端输出
|
||||||
|
```
|
||||||
|
|
||||||
|
明确不做:
|
||||||
|
|
||||||
|
- 不写入 `app.db`,不新增 Alembic migration。
|
||||||
|
- 不新增 FastAPI API、后台常驻 worker、配置页面或 Energy 前端。
|
||||||
|
- 不发布 MQTT / Home Assistant Discovery。
|
||||||
|
- 不修改现有 DSMR Reader MQTT、电费计算或 Meter 逻辑。
|
||||||
|
- 不在本阶段决定 WarmteLink 应落在哪个正式 Device/Source 模型中。
|
||||||
|
|
||||||
|
## 3. 预期操作方式
|
||||||
|
|
||||||
|
线到货后,在 workspace 的 virtual environment 中实现并运行一个只读 probe。命令形态暂定为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m scripts.p1_probe \
|
||||||
|
--device /dev/serial/by-id/<usb-p1-device> \
|
||||||
|
--duration 600 \
|
||||||
|
--show-changes \
|
||||||
|
--raw-output /tmp/warmtelink-p1-telegram.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
最终参数名可在实现 probe 时调整,但应保留这些能力:
|
||||||
|
|
||||||
|
- 使用稳定的 `/dev/serial/by-id/...` 路径,而不是依赖可能变化的 `/dev/ttyUSB0`。
|
||||||
|
- 连续读取多帧,而不是只看一帧偶然样本。
|
||||||
|
- 同时显示完整帧/CRC 结果、原始 OBIS 字段和解析后的值/单位。
|
||||||
|
- 枚举 telegram 中出现的所有 M-Bus channel、device type、equipment id、capture timestamp、value 和 unit,不依赖固定字段顺序。
|
||||||
|
- 可只显示发生变化的字段,便于观察更新频率。
|
||||||
|
- 原始 telegram 默认只写到 `/tmp`;未经脱敏不提交到 Git。
|
||||||
|
|
||||||
|
### 3.1 分两步 bring-up:Bash 冒烟验证 → Python probe
|
||||||
|
|
||||||
|
Home Assistant Community 的一份 WarmteLink 实例提供了一个适合作为硬件
|
||||||
|
bring-up 起点的[最小 Bash 读取方法](https://community.home-assistant.io/t/solved-dsmr-add-warmtelink-as-data-source/485255/2):
|
||||||
|
先把串口设为 115200 baud,逐行读取设备,并从带 `GJ` 的行中取出累计值。该帖展示的
|
||||||
|
telegram 样例还给出了以下**候选事实**:
|
||||||
|
|
||||||
|
- 设备头为 `/NWA-WARMTELINK`,版本字段为 `1-3:0.2.8(50)`。
|
||||||
|
- M-Bus channel 1 的 device type 样例为 `004`。
|
||||||
|
- 累计热量样例位于 `0-1:24.2.1(<capture timestamp>)(<value>*GJ)`。
|
||||||
|
- telegram 以 `!` 加四位 CRC 结束。
|
||||||
|
|
||||||
|
这些是其他用户在 2022 年记录的单机样本,只用于提出假设,不能替代本机 firmware、线材和
|
||||||
|
实际 telegram 的验证。当前 Home Assistant 的
|
||||||
|
[DSMR 文档](https://www.home-assistant.io/integrations/dsmr/)确认其 DSMR 集成支持 DSMR v5 与
|
||||||
|
M-Bus subdevice;该集成底层使用
|
||||||
|
[`dsmr_parser`](https://github.com/ndokter/dsmr_parser)。实现 probe 时可把它作为候选解析基线
|
||||||
|
进行对照,但是否引入为本项目正式依赖留到 M8 Planning 决定。
|
||||||
|
|
||||||
|
线到货后的执行顺序调整为:
|
||||||
|
|
||||||
|
1. **Bash 冒烟验证**:用稳定的 `/dev/serial/by-id/...` 路径配置串口并短时读取;先保留完整
|
||||||
|
原始字节流,再确认是否能看到 `/NWA-WARMTELINK`、帧尾和带 `GJ` 的行。论坛脚本中的
|
||||||
|
`GJ` 文本提取只能用作快速可见性检查,不能算解析或验收通过。
|
||||||
|
2. **Python probe**:在已确认物理链路工作的前提下,实现上面的 `scripts.p1_probe`,完成
|
||||||
|
完整 framing、CRC、全部字段枚举、结构化解析、连续多帧变化观察和人工面板对照。
|
||||||
|
|
||||||
|
不复制论坛脚本的 MQTT 发布步骤:Pre-M8 仍只输出到终端和 `/tmp`,MQTT / Home Assistant
|
||||||
|
集成属于 M8 设计范围。
|
||||||
|
|
||||||
|
## 4. 人工对照
|
||||||
|
|
||||||
|
probe 运行期间,人工从热力表和相关水表面板记录同一时间附近的显示值,并与终端结果对照:
|
||||||
|
|
||||||
|
| 检查项 | 需要记录 |
|
||||||
|
| --- | --- |
|
||||||
|
| 区域供暖 | 面板累计值、P1 值、单位、两者时间差 |
|
||||||
|
| 生活热水 | 面板累计值、P1 是否存在对应字段、单位、两者时间差 |
|
||||||
|
| 更新时间 | 连续 telegram 中数值变化的间隔 |
|
||||||
|
| 累计语义 | 数值是否单调累计,是否出现每日归零或其它重置 |
|
||||||
|
|
||||||
|
允许 P1 capture time 与按表时间之间存在合理延迟;不能只凭数值接近就认定字段含义,必须同时核对单位、channel/device type 和时间戳。
|
||||||
|
|
||||||
|
## 5. 通过条件
|
||||||
|
|
||||||
|
Pre-M8 完成需留下以下证据:
|
||||||
|
|
||||||
|
- [ ] 连续收到可识别为 WarmteLink 的完整 telegram。
|
||||||
|
- [ ] CRC 校验通过;若失败,已区分串口/线材问题与 parser 问题。
|
||||||
|
- [ ] parser 不依赖字段固定顺序,并列出全部实际 channel/OBIS 字段。
|
||||||
|
- [ ] 找到 GJ 累计值并与热力表面板对照,误差可由显示精度或 capture 延迟解释。
|
||||||
|
- [ ] 明确实际 telegram 是否包含独立的生活热水 m³ 累计量;若包含,已与水表面板对照。
|
||||||
|
- [ ] 记录数值精度、telegram 频率、字段更新频率和累计/重置行为。
|
||||||
|
- [ ] 形成一份脱敏结果摘要,足以支持下一轮 M8 Planning。
|
||||||
|
|
||||||
|
如果只能确认 GJ、没有独立生活热水 m³,这也是有效结论,不视为 Pre-M8 失败。
|
||||||
|
|
||||||
|
## 6. 失败分类
|
||||||
|
|
||||||
|
- 完全无数据:优先检查 USB 识别、串口权限、P1 request line、线材方向/供电。
|
||||||
|
- 输出乱码或不成帧:优先检查串口参数、信号反相和线材兼容性。
|
||||||
|
- 原始帧完整但解析失败:保存脱敏样本,调整 parser/字段映射。
|
||||||
|
- 解析成功但面板对不上:检查 capture timestamp、累计语义、单位和 WarmteLink firmware 差异。
|
||||||
|
|
||||||
|
## 7. 向 M8 的交付物
|
||||||
|
|
||||||
|
Pre-M8 只向 M8 交付事实,不交付正式架构:
|
||||||
|
|
||||||
|
- 已脱敏的 telegram 结构与字段清单。
|
||||||
|
- GJ / 可选 m³ 的实际 channel、OBIS、单位、精度和更新时间。
|
||||||
|
- 串口参数、稳定设备路径与部署权限要求。
|
||||||
|
- parser 适配结论以及需要保留的异常样本。
|
||||||
|
- 对“一个来源包含几个可用计量通道”的实测结论。
|
||||||
Binary file not shown.
@@ -0,0 +1,198 @@
|
|||||||
|
# DDSU666 Modbus 协议(从官方 PDF 提取)
|
||||||
|
|
||||||
|
> 来源:`docs/references/DDSU666 Single phase Smart Meter.pdf`
|
||||||
|
> (CHINT / 正泰仪表 **DDSU666 Single phase Smart Meter — Operation Manual**,文档号 `ZTY0.464.1224`,版本 **V2**,2020 年 8 月;厂商 Zhejiang Chint Instrument & Meter Co., Ltd.)
|
||||||
|
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
|
||||||
|
> 同类文档见 SDM120 的 `SDM120-Modbus-Protocol.md`;两表差异较大,见下方 §6「与 SDM120 的关键差异」。
|
||||||
|
|
||||||
|
## 设备速览(来自手册 Table 1 / Table 5)
|
||||||
|
|
||||||
|
| 项 | DDSU666(直接接入) | DDSU666-CT(经互感器) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 精度等级 | Active Class B | Active Class C |
|
||||||
|
| 参考电压 | 230 V | 230 V |
|
||||||
|
| 电流规格 | 0.25–5(80) A | 0.015–1.5(6) A |
|
||||||
|
| 表常数 | 800 imp/kWh | 6400 imp/kWh |
|
||||||
|
| 接入方式 | 直接接入 | 经电流互感器 |
|
||||||
|
|
||||||
|
- 单相电子式电能表,DIN35mm 导轨安装;测量电压、电流、有功/无功功率、频率、功率因数、正/反向有功电能。
|
||||||
|
- 电能测量范围 `0~999999.99 kWh`(LCD 只显示 6 位,自动移动小数点)。
|
||||||
|
- 通信:RS485,**Modbus-RTU**(也支持 DL/T 645-2007,可切换,见 §5 `0005H ChangeProtocol`)。
|
||||||
|
- 手册 Table 1 标注 Frequency Reference = 60Hz,但 LCD 示例又写 `F=50.00Hz`(手册自身不一致);**实际频率以寄存器 `200EH` 读数为准**,不要把 50/60 写死。
|
||||||
|
|
||||||
|
## 0. 本项目的接入方式(重要)
|
||||||
|
|
||||||
|
DDSU666 物理层是 **Modbus RTU(RS-485 串口)**,半双工。和 SDM120 一样,本项目通过一个 **Modbus-TCP 网关**接入:
|
||||||
|
|
||||||
|
- 后端用 **Modbus TCP**(`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
|
||||||
|
- TCP 帧用 MBAP header、**无 CRC**(CRC 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `CRC (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
|
||||||
|
- **Slave Address / Unit ID = 电表的通信地址 Addr**(范围 1–247;面板按键只能设 1–99;见 §5 `0006H`),在 TCP 请求里作为 unit id 传入。
|
||||||
|
- 若以后直连串口(RTU),才需要管波特率 / 数据格式 / CRC:**默认串口格式是 8 数据位、无校验、2 停止位(8N2)**,与 SDM120 的 8N1 不同——直连时务必对齐。
|
||||||
|
- 电表必须处于 **Modbus 协议模式**(而非 DL/T 645)才能用本协议;可经面板长按切换,或写 `0005H = 2`(见 §5)。
|
||||||
|
|
||||||
|
## 1. 协议帧格式(Appendix A)
|
||||||
|
|
||||||
|
异步传输,按字节为单位。一帧 10 位字符 = **1 起始位(0) + 8 数据位(无校验) + 2 停止位(1)**(其它格式可定制)。
|
||||||
|
|
||||||
|
### 信息帧结构(Table A.1)
|
||||||
|
|
||||||
|
| 字段 | 长度 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Start(起始) | >3.5 字符静默 | 帧间至少 3.5 字符空闲时间作为分隔 |
|
||||||
|
| Address code(地址码) | 1 字节 | 目标从机地址 1–247;每个从机在总线上地址唯一 |
|
||||||
|
| Function code(功能码) | 1 字节 | 仅支持 **03H / 10H**(见 §2) |
|
||||||
|
| Data(数据域) | n 字节 | 随功能码不同而不同(起始地址、寄存器数、寄存器数据等) |
|
||||||
|
| CRC check code(CRC 校验) | 2 字节 | 16-bit CRC(**低字节在前、高字节在后**;多项式 `A001`) |
|
||||||
|
| End(结束) | >3.5 字符静默 | 帧间静默 |
|
||||||
|
|
||||||
|
> TCP 网关模式下 Start/End 静默与 CRC 由网关处理,本项目不关心。
|
||||||
|
|
||||||
|
### 功能码 03H 示例(读寄存器,Table A.3/A.4)
|
||||||
|
|
||||||
|
读从机 `01H`、起始地址 `0CH`、2 个寄存器:
|
||||||
|
|
||||||
|
- 主机发送:`01 03 00 0C 00 02 04 08`(最后 `04 08` 是 CRC,低字节 `04` 在前)。
|
||||||
|
- 从机返回(设 `0CH/0DH` 内容为 `0000H`、`1388H`):`01 03 04 00 00 13 88 F7 65`
|
||||||
|
- `04` = 字节数;`00 00` = `0CH` 数据;`13 88` = `0DH` 数据;`F7 65` = CRC(低字节 `F7` 在前)。
|
||||||
|
|
||||||
|
> **注意**:单个 16-bit 寄存器内是「高字节在前、低字节在后」(Table A.4 里 `0DH` 数据返回 `13 88` = `0x1388`)。这一点对解码浮点的字节序很关键,见 §3。
|
||||||
|
|
||||||
|
### 功能码 10H 示例(写多个寄存器,Table A.5/A.6)
|
||||||
|
|
||||||
|
向从机 `01H`、起始地址 `00H` 连续写 3 个寄存器 `0002H,1388H,000AH`:
|
||||||
|
|
||||||
|
- 主机发送:`01 10 00 00 00 03 06 00 02 13 88 00 0A 9B E9`
|
||||||
|
- `06` = 写入字节数(3 寄存器 × 2 字节);随后是 3 个寄存器的数据;末尾 `9B E9` CRC。
|
||||||
|
- 从机返回:`01 10 00 00 00 03 80 08`(回显起始地址 + 寄存器数 + CRC `80 08`)。
|
||||||
|
|
||||||
|
### 异常响应(Table A.7/A.8)
|
||||||
|
|
||||||
|
- 异常时返回的 Function Code = **原功能码 + 128**(即最高位置 1,`03H→83H`、`10H→90H`)。
|
||||||
|
- 数据为单字节 Error Code:
|
||||||
|
|
||||||
|
| Error Code | 含义 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `01H` | Illegal function code | 收到的功能码本表不支持 |
|
||||||
|
| `02H` | Illegal register address | 寄存器地址超出有效范围 |
|
||||||
|
| `03H` | Illegal data value | 数据值超出对应地址的取值范围 |
|
||||||
|
|
||||||
|
## 2. 功能码
|
||||||
|
|
||||||
|
DDSU666 **只支持两个功能码**(Table A.2):
|
||||||
|
|
||||||
|
| 功能码 | 作用 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **03H** | Read register(读寄存器) | 读一个或多个寄存器——**测量值、电量、配置全部走它** |
|
||||||
|
| **10H** | Write multiple registers(写多个寄存器) | 向 n 个连续寄存器写 n 个 16-bit 数据(改配置 / 清电量) |
|
||||||
|
|
||||||
|
> ⚠️ **DDSU666 没有「输入寄存器 / FC04」概念**——所有数据(包括电压电流功率)都用 **FC 03H** 读保持寄存器。这是它和 SDM120(测量值走 FC04)最大的踩坑差异,见 §6。
|
||||||
|
|
||||||
|
## 3. 数据编码
|
||||||
|
|
||||||
|
DDSU666 有两类数据:
|
||||||
|
|
||||||
|
1. **配置 / 参数寄存器(§5,`0000H`–`0010H`)**:每个 1 个寄存器、`16-bit with symbols`(**16 位有符号整数**)。
|
||||||
|
2. **测量 / 电量寄存器(§4,`2000H`+ / `4000H`+)**:每个参数 = **32-bit IEEE-754 单精度浮点**(手册写 “single precision floating decimal”),占 **2 个相邻寄存器**(Length = 2 Word)。
|
||||||
|
|
||||||
|
**浮点字节序 / 字序**
|
||||||
|
|
||||||
|
- **字节序(byte order)= 大端:寄存器内高字节在前** —— 由 Table A.4 的 `0x1388` 返回为 `13 88` 确认。
|
||||||
|
- **字序(word order,两个寄存器谁是高 16 位)**:手册**没有给出浮点解码的实例**,未明确标注。按标准 Modbus 浮点惯例应为**大端字序(高寄存器在前,`ABCD`)**,与本项目 SDM120 驱动一致(`registers_to_float` 用 `>f`,高寄存器在前)。
|
||||||
|
- ⚠️ **需上机实测确认**:读 `2000H`(电压)应解出 ~230V 这样的合理值;若解出乱数,多半是字序相反,改成「低寄存器在前」再试。CHINT 同系列(DTSU/DDSU666)现场固件偶有字序差异,**首次接入务必用一个已知量(电压)校验**,不要凭手册想当然。
|
||||||
|
|
||||||
|
> Python 解码(大端、高寄存器在前):`struct.unpack('>f', struct.pack('>HH', hi_reg, lo_reg))[0]`。
|
||||||
|
> pymodbus:`BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。
|
||||||
|
|
||||||
|
## 4. 测量 / 电量寄存器表(FC 03H 读)
|
||||||
|
|
||||||
|
全部为只读、`Float`(32-bit),每项占 **2 个寄存器**。地址为 Modbus 协议原始地址(即帧里的 Start Register Address Hi/Lo),手册用十六进制。
|
||||||
|
|
||||||
|
### 4.1 瞬时量(“Electric quantity of the secondary side”,`2000H` 段)
|
||||||
|
|
||||||
|
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `2000H` | 电压 Voltage | U | V | |
|
||||||
|
| `2002H` | 电流 Current | I | A | |
|
||||||
|
| `2004H` | 有功功率 Active power | P | **kW** | 手册标注 “the unit is KW”——**不是 W** |
|
||||||
|
| `2006H` | 无功功率 Reactive power | Q | **kvar** | |
|
||||||
|
| `2008H` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 |
|
||||||
|
| `200AH` | 功率因数 Power factor | PF | — | 无量纲 |
|
||||||
|
| `200CH` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 |
|
||||||
|
| `200EH` | 频率 Frequency | Freq | Hz | |
|
||||||
|
|
||||||
|
### 4.2 电量(“Electrical data of the secondary side”,`4000H` 段)
|
||||||
|
|
||||||
|
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `4000H` | 正向(导入)有功电能 Active in electricity | Ep | kWh | 正向 / forward active energy |
|
||||||
|
| `400AH` | 反向(导出)有功电能 Reverse in electricity | -Ep | kWh | 反向 / reverse active energy |
|
||||||
|
|
||||||
|
> 手册里 `4000H` 与 `400AH` 之间(`4002H`–`4009H`)未列出,视为保留/未文档化。
|
||||||
|
|
||||||
|
**读取分块建议**
|
||||||
|
|
||||||
|
- 瞬时量:`2000H`–`200FH` 共 **16 个寄存器连续**,一次块读即可覆盖 U…Freq(含两段 RESERVED,解码时跳过)。
|
||||||
|
- 电量:`4000H`(2 寄存器)与 `400AH`(2 寄存器)相距较远,分两小块读,或读 `4000H`–`400BH`(12 寄存器)一次取出后挑用——以网关 / 电表是否允许跨保留地址块读为准,谨慎起见分开读更稳。
|
||||||
|
|
||||||
|
### 常用核心子集(日常监控够用)
|
||||||
|
|
||||||
|
电压 `2000H`、电流 `2002H`、有功功率 `2004H`(kW)、功率因数 `200AH`、频率 `200EH`、正向有功电能 `4000H`、反向有功电能 `400AH`。
|
||||||
|
|
||||||
|
## 5. 配置 / 参数寄存器表(FC 03H 读 / FC 10H 写)(Table 9)
|
||||||
|
|
||||||
|
每个 1 个寄存器、**16-bit 有符号整数**。`R/W` 列来自手册。
|
||||||
|
|
||||||
|
| 地址(hex) | 代号 | 含义 | R/W | 取值 / 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0000H` | UCode | 编程密码 Programming password code | R/W | 写配置前的密码字 |
|
||||||
|
| `0001H` | REV. | 保留;**实际读出的是版本号** | R | |
|
||||||
|
| `0002H` | ClrE | 电能清零 CLr.E | R/W | **写 `1` 清除总电量**(不可逆,慎用) |
|
||||||
|
| `0003H`–`0004H` | RESERVED | 保留 | — | |
|
||||||
|
| `0005H` | ChangeProtocol | 协议切换 | R/W | **`2` = Modbus-RTU**,`1` = DL/T 645-2007 |
|
||||||
|
| `0006H` | Addr | 通信地址 | R/W | 1–247(面板按键仅 1–99) |
|
||||||
|
| `0007H`–`000AH` | RESERVED | 保留 | — | |
|
||||||
|
| `000BH` | Meter type | 表型 Meter type | R | 只读设备类型标识 |
|
||||||
|
| `000CH` | BAud | 通信波特率 | R/W | **`1`=2400bps,`2`=4800bps,`3`=9600bps**(手册寄存器仅列这三档;通信章另提到也支持 1200bps) |
|
||||||
|
| `000DH`–`0010H` | RESERVED | 保留 | — | |
|
||||||
|
|
||||||
|
> ⚠️ 写 `0002H`(清电量)、`0006H`(改地址)、`000CH`(改波特率)、`0005H`(切协议)都会改变电表状态或通信参数,配错可能**清空累计电量**或**导致通信中断**。本项目默认**只读采集**,不在自动化链路里写电表配置寄存器。
|
||||||
|
> 写配置通常需先经 `0000H UCode` 密码字校验;具体密码值手册正文未给出,需向厂商确认或经面板操作。
|
||||||
|
|
||||||
|
## 6. 与 SDM120 的关键差异(迁移 / 复用驱动时必看)
|
||||||
|
|
||||||
|
本项目已有 SDM120 profile(`app/integrations/modbus/profiles/sdm120.yaml`)。DDSU666 **不能照搬**,主要差异:
|
||||||
|
|
||||||
|
| 维度 | SDM120 (Eastron) | DDSU666 (CHINT) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 读测量值功能码 | **FC 04**(输入寄存器 3X) | **FC 03**(保持寄存器,无 FC04) |
|
||||||
|
| 测量值起始地址 | `0x0000` 起(30001) | 瞬时量 `0x2000` 起;电量 `0x4000`/`0x400A` |
|
||||||
|
| 有功功率单位 | **W**(瓦) | **kW(千瓦)** —— 入库前注意换算 / 单位标注 |
|
||||||
|
| 无功功率单位 | VAr | kvar |
|
||||||
|
| 配置寄存器格式 | Float(FC03/16) | **16-bit 有符号整数**(FC03/10) |
|
||||||
|
| 写功能码 | 16 / 0x10 | 10H(同 0x10) |
|
||||||
|
| 串口默认格式 | 8N1(1 停止位) | **8N2(2 停止位)** |
|
||||||
|
| 多协议 | 仅 Modbus | Modbus **与 DL/T 645-2007 可切换**(需确保在 Modbus 模式) |
|
||||||
|
| 浮点字序 | 大端、高寄存器在前(手册有实例佐证) | 字节序大端已确认;**字序手册无实例,需上机实测** |
|
||||||
|
|
||||||
|
## 7. 给本项目采集驱动的要点小结
|
||||||
|
|
||||||
|
1. 走 **Modbus TCP 网关**:`ModbusTcpClient(host, port)`,`slave=<Addr>`;电表须在 **Modbus 协议模式**。
|
||||||
|
2. 所有读取(测量 + 电量 + 配置)都用 **FC 03H**——**没有 FC04**。
|
||||||
|
3. 测量值在 `0x2000` 段、电量在 `0x4000`/`0x400A`,均为 **float32**;解码大端字节序,**字序默认高寄存器在前但务必用电压实测校验**。
|
||||||
|
4. **有功功率单位是 kW、无功是 kvar**——与 SDM120 的 W/VAr 不同,新建 profile / 入库映射时单位别抄错。
|
||||||
|
5. 配置寄存器(`0x0000`–`0x0010`)是 **16-bit 有符号整数**,不是 float。
|
||||||
|
6. 默认**只读**;`0002H` 写 1 会**清空累计电量**、`0006H/000CH/0005H` 会改通信参数,自动化链路里一律不写。
|
||||||
|
7. 新建 profile 时这是 `ddsu666` 这一个 register profile 的定义;建议 `function_code: 3`、`word_order: big`(先按大端字序,接入后用电压读数验证)、瞬时量与电量分块读。
|
||||||
|
|
||||||
|
## 8. 实测记录(真机验证,2026-06-30)
|
||||||
|
|
||||||
|
首次接入一台 **DDSU666 直接接入版(5(80)A)** 实测,确认以下几点:
|
||||||
|
|
||||||
|
- **字序大端,确认无误**:电压 / 电流 / 频率 / 电能用「大端、高寄存器在前」解码全部得到合理值(如 233.9 V / 0.055 A / 49.99 Hz),与 §3 的假设一致。`ddsu666.yaml` 的 `word_order: big` / `byte_order: big` **无需修改**,§3 里「字序需上机实测」一项可视为已关闭。
|
||||||
|
- **FC03 读通**:所有量走 FC03,profile `ddsu666` 在采集链路(CLI `read` / 设备 `/test` / 后台轮询)中工作正常。
|
||||||
|
- **低电流下「瞬时功率读 0、但电能照常累加」**:测试负载仅为一台 PoE 交换机(≈230 V / 0.05 A,真实有功仅几瓦),**远低于本表测量量程下限 Imin≈0.25 A**。此工况下:
|
||||||
|
- 瞬时 `active_power`(`0x2004`)与 `power_factor`(`0x200A`)寄存器返回**全零**(原始 hex `0x0000 0x0000`);电表 LCD 上功率在 0~3.3 W、PF 在 0~0.6 之间抖动。
|
||||||
|
- 抖动成因 = **低电流测量噪声 + 开关电源(SMPS,无 PFC)畸变电流**(电流为电压峰值附近的窄脉冲、谐波重 → 畸变功率因数天然偏低)。**不是**「采样率与开关频率拍频」:计量芯片 SH79F7019 采样在 kHz 量级,远低于开关电源 50–200 kHz 的开关频率,两者不在一个频段。
|
||||||
|
- 但累计电能 `import_energy`(`0x4000`)**正常累加**(实测 0 → 0.01 kWh)——电表内部积分器在防潜动起始电流(`0.004·Ib`≈0.02 A)之上照常计量。
|
||||||
|
- **结论**:低于量程下限时**瞬时功率 / PF 不可信,但电能计量不丢**;电流进入量程(正常负载)后瞬时量即稳定可信。这是 5(80)A 大量程表对极小负载的固有特性,**非缺陷、非解码问题**。
|
||||||
|
- **排错提示**:若日后看到 DDSU666「功率一直 0」,先确认负载电流是否在 Imin 以上——多半是负载太轻而非链路故障;可用 `scripts/modbus_cli probe --fc 3 --address 0x2000 --count 16` 看 `0x2004/0x2005` 原始寄存器是否真为全零佐证。
|
||||||
@@ -113,16 +113,26 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
|||||||
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
||||||
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
||||||
|
|
||||||
→ **买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。
|
→ **买卖服务费金额相等(均 €0.0248/kWh),但两者对住户都是成本、不互相抵消**:
|
||||||
|
- 买侧 inkoopvergoeding 已经**包含在 Tibber API 的 `total` 里**(见下 §3.1 的实证拆解),买电按 `total` 计价即已含它。
|
||||||
|
- 卖侧 verkoopvergoeding 则是从回送价里**额外扣掉**的一笔——所以回送价 = `total − 0.0248`,比买价低 0.0248/kWh。
|
||||||
|
- ⚠️ **早期版本误判为"一进一出抵消 → 回送=total"**,这是错的:`total` 里那笔 inkoopvergoeding 不会退回来充抵 verkoopvergoeding。代码里用 `energy.sell_fee`(默认 0.0248)建模这笔卖侧费用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
||||||
|
|
||||||
### 3.1 回送价(净计量期内,文档原文)
|
### 3.1 回送价(净计量期内,文档原文 + 实证)
|
||||||
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
||||||
|
|
||||||
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**(spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。
|
> **Worked example(Tibber NL 原文)**:"Stel dat tussen 14:00 en 14:15 de totale stroomprijs €0,28 per kWh incl. is, dan krijg je €0,28 − €0,0248 verkoopvergoeding = **€0,2552** per teruggeleverde kWh terug."
|
||||||
|
|
||||||
|
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`,而官方例子直接写成 **`回送价 = totale stroomprijs − verkoopvergoeding = total − 0.0248`**。能源税**退回**(留在 total 里没动),只有 verkoopvergoeding 这 0.0248 被扣。
|
||||||
|
|
||||||
|
**✅ 实证(本项目生产库,2026-07-20 三个刻钟)**:按 21% VAT 拆 `total`:`total = 现货×1.21 + energiebelasting(0.11085) + inkoopvergoeding(0.0248)`,三段解出的 inkoop 都精确等于 **0.0248**。→ **我们存的 `tibber_price.total` 就是官方 "totale stroomprijs"(含 inkoopvergoeding 的买价)**,因此:
|
||||||
|
- 买价 `buy = total`(已含 inkoopvergoeding,正确)。
|
||||||
|
- 净计量回送价 `sell = total − verkoopvergoeding = total − 0.0248`。
|
||||||
|
- ⚠️ 所以 saldering 下"回送 1 度"仍比"用 1 度"少 0.0248——**不是完全 1:1**。代码用 `sell_fee` 建模这笔扣减,`sell_adjust` 只负责在净计量期把能源税补回(`sell_adjust = −energy_tax`)。
|
||||||
|
|
||||||
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
||||||
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
||||||
@@ -146,9 +156,11 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
|||||||
|
|
||||||
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
||||||
|
|
||||||
- **Tibber 动态合同**(post-2027 口径):
|
- **Tibber 动态合同**:
|
||||||
- 买价 `buy = price.total`
|
- 买价 `buy = price.total`(含 energy_tax + VAT + inkoopvergoeding)
|
||||||
- 卖价 `sell = price.total − energy_tax_per_kwh − sell_adjust`(`sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里)
|
- 卖价 `sell = price.total − energy_tax − sell_fee − sell_adjust`
|
||||||
|
- `sell_fee`:verkoopvergoeding(卖侧上网费,默认 **0.0248**,含 VAT),**始终扣除**——即使净计量期也扣(见 §3.1)。
|
||||||
|
- `sell_adjust`:手动修正项(默认 0)。**净计量期**设为 `−energy_tax`(把能源税补回),得 `sell = total − sell_fee`;**2027 取消净计量后**设为 0,得 `sell = total − energy_tax − sell_fee`(无能源税、纯市场价再扣上网费)。
|
||||||
- **固定合同(manual,双费率)**:
|
- **固定合同(manual,双费率)**:
|
||||||
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
||||||
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
||||||
@@ -242,7 +254,7 @@ extra_device_timestamp, extra_device_delivered # 燃气表(m³,每
|
|||||||
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
||||||
|
|
||||||
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
||||||
2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0)。
|
2. ~~**卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉~~ → **已核实(2026-07)**:`total` 含 inkoopvergoeding(0.0248),净计量回送价 = `total − verkoopvergoeding(0.0248)`,两费**不抵消**;代码以 `sell_fee`(默认 0.0248)建模。仍待真实账单核对 `sell_fee` / VAT 口径的最终残差。
|
||||||
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
||||||
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
||||||
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
||||||
|
|||||||
+53
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
||||||
|
|
||||||
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
> 每个里程碑的设计与**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)、Pre-M8 [`pre-m8-warmtelink-p1-poc.md`](./design/pre-m8-warmtelink-p1-poc.md)、M8 [`m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md)。Pre-M8/M8 当前仍是验证与 Planning 占位,尚无可派发的实现任务卡。
|
||||||
|
|
||||||
## 当前基线(v1.0.3)
|
## 当前基线(v1.0.3)
|
||||||
|
|
||||||
@@ -40,6 +40,8 @@
|
|||||||
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
|
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
|
||||||
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
|
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
|
||||||
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI |
|
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI |
|
||||||
|
| **Pre-M8** ⏳ | WarmteLink P1 真机概念验证 | USB→P1 到货后用 workspace venv 只读采集实际 telegram,校验 CRC/解析,并与热力表和水表面板对照 |
|
||||||
|
| **M8** 📝 | WarmteLink P1 与多数据源 Meter | 等 Pre-M8 后规划直接 P1 采集、区域供暖读数及 Meter 与数据源的可配置关系;当前仅占位 |
|
||||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||||
|
|
||||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
||||||
@@ -257,6 +259,30 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pre-M8 — WarmteLink P1 真机概念验证(⏳ 等待硬件)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
USB→P1 线到货后,先在 workspace virtual environment 中运行只读 probe,直接采集 Vattenfall WarmteLink 的实际 telegram。验证完整帧、CRC、OBIS/M-Bus channel、单位、精度和更新频率,并把解析出的 GJ 与热力表面板、可选 m³ 与水表面板进行人工对照。
|
||||||
|
|
||||||
|
本阶段不落库、不接 API/前端/HA,也不决定正式 Device/Source/Meter 关系。它只向 M8 提供脱敏的真机事实,避免在未知 firmware/字段语义上提前设计。
|
||||||
|
|
||||||
|
> 验证计划与通过条件:[`docs/design/pre-m8-warmtelink-p1-poc.md`](./design/pre-m8-warmtelink-p1-poc.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M8 — WarmteLink P1 与多数据源 Meter(📝 Planning 占位)
|
||||||
|
|
||||||
|
### 候选目标
|
||||||
|
|
||||||
|
在 Pre-M8 事实基础上,把 WarmteLink P1 的区域供暖 GJ、以及真机确认存在时的生活热水 m³ 接入 Energy 模块,并讨论 Meter 如何与实际数据源建立可配置关系。
|
||||||
|
|
||||||
|
当前不锁定数据库、API、后台 worker 或 UI 结构;特别是 DSMR MQTT source、P1 serial source、Device/Data Source 的定义和多 channel 映射,都留到下一轮 Planning 讨论后再拆原子任务。
|
||||||
|
|
||||||
|
> Planning 占位与待决问题:[`docs/design/m8-warmtelink-energy.md`](./design/m8-warmtelink-energy.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## M3 — 开放与移动端(远期试水)
|
## M3 — 开放与移动端(远期试水)
|
||||||
|
|
||||||
### 目标
|
### 目标
|
||||||
@@ -288,11 +314,36 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
|
|||||||
|
|
||||||
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
||||||
|
|
||||||
|
**本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权**(2026-06-27 与用户确认):
|
||||||
|
|
||||||
|
- `POST /location/record`(`app/api/routes/location.py:18`)——位置记录上报。**目前无任何鉴权**。当前数据经 Home Assistant 转发进来,上 token 后 **HA 侧需携带该 token**;也可由其他客户端直接上报。
|
||||||
|
- `POST /poo/record`(`app/api/routes/poo.py:21`)+ `GET /poo/latest`(`poo.py:57`)——小狗排便记录上报 / 最新查询。**目前无任何鉴权**。
|
||||||
|
- 这些是设备 / 脚本(非浏览器)端点,session cookie 不适用,正是 long-lived token 的用武之地。(浏览器 CRUD `/api/data/*` 已由 session 保护,不在此列。)
|
||||||
|
|
||||||
**范围(粗略,待细化)**:
|
**范围(粗略,待细化)**:
|
||||||
|
|
||||||
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
||||||
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
|
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 token 鉴权依赖。
|
||||||
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
||||||
|
- 与下面第 3 条「Session 滑动续期」同属 Authentication 主题(一个是设备/脚本的长期凭据,一个是浏览器短会话体验),实现时鉴权层可一并梳理。
|
||||||
|
|
||||||
|
### 3. Session 滑动自动续期(Authentication)
|
||||||
|
|
||||||
|
**动机**(2026-06-27 与用户确认):当前 session 是**绝对过期**——登录即定死、活动不续期,满 TTL 必须重新登录,体验割裂。希望改成**滑动续期(sliding / rolling)**:只要用户还在活动就自动延长,提供"在用就不掉线"的体验。
|
||||||
|
|
||||||
|
**现状(实现起点,便于快速拾起)**:
|
||||||
|
|
||||||
|
- TTL 默认 **12 小时**:`auth_session_ttl_hours`(`app/config.py:38`;配置页 `app/services/config_page.py:45` 可运行时改)。
|
||||||
|
- 登录时**一次性写死**:`create_session` 设 `expires_at = now + ttl`(`app/services/auth.py:94`)+ cookie `max_age = ttl`(`app/api/routes/api/session.py:153`)。
|
||||||
|
- 每请求**只读校验、从不延长**:`get_authenticated_session`(`app/services/auth.py:103`)只判断 `expires_at <= now`,过期时仅顺手标 `revoked`。`set_cookie` 只在登录路由调用一次,**无 per-request 中间件**。→ 所以是绝对过期,不是滑动。
|
||||||
|
|
||||||
|
**设计要点(待写设计文档时展开)**:
|
||||||
|
|
||||||
|
- 校验通过时 bump `expires_at = now + ttl` 并**重发 cookie**(滑动窗口)。
|
||||||
|
- **写节流**:不要每个请求都写 DB——仅当剩余寿命已过半(或距上次续期 > N 分钟)才续期,避免高频写放大。
|
||||||
|
- **绝对寿命硬顶**:除滑动 TTL 外再设 `created_at + max_lifetime` 上限,防止"永不过期"的会话(安全考量)。
|
||||||
|
- 新增配置项:滑动 TTL、绝对寿命上限、续期节流阈值。
|
||||||
|
- 注意:改动只对**新逻辑生效**,已存在 session 的 `expires_at` 行为按新校验路径走即可;上线前过校验闸门。
|
||||||
|
|
||||||
## Future Ideas(暂不排期,想到先记下)
|
## Future Ideas(暂不排期,想到先记下)
|
||||||
|
|
||||||
|
|||||||
Vendored
+17
-5
@@ -261,7 +261,7 @@ export interface paths {
|
|||||||
*
|
*
|
||||||
* Response ``points`` carries per-slot:
|
* Response ``points`` carries per-slot:
|
||||||
* - ``buy = total`` (Tibber all-inclusive price)
|
* - ``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)
|
* - ``level`` (Tibber price level, may be null)
|
||||||
*
|
*
|
||||||
* ``tariff`` is null.
|
* ``tariff`` is null.
|
||||||
@@ -2220,7 +2220,9 @@ export interface components {
|
|||||||
* SummaryResponse
|
* SummaryResponse
|
||||||
* @description Response for GET /api/energy/costs/summary.
|
* @description Response for GET /api/energy/costs/summary.
|
||||||
*
|
*
|
||||||
* All monetary values are in ``currency``.
|
* Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
|
||||||
|
* in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
|
||||||
|
* only the ``_kwh``-suffixed fields carry kWh.
|
||||||
*
|
*
|
||||||
* ``total_payable = metered_net + fixed_costs − credits``
|
* ``total_payable = metered_net + fixed_costs − credits``
|
||||||
*/
|
*/
|
||||||
@@ -2229,19 +2231,29 @@ export interface components {
|
|||||||
currency: string;
|
currency: string;
|
||||||
/**
|
/**
|
||||||
* Metered Import
|
* Metered Import
|
||||||
* @description Σ import_cost for non-degraded periods.
|
* @description Σ import_cost for non-degraded periods (money, in `currency`).
|
||||||
*/
|
*/
|
||||||
metered_import: number;
|
metered_import: number;
|
||||||
/**
|
/**
|
||||||
* Metered Export
|
* Metered Export
|
||||||
* @description Σ export_revenue for non-degraded periods.
|
* @description Σ export_revenue for non-degraded periods (money, in `currency`).
|
||||||
*/
|
*/
|
||||||
metered_export: number;
|
metered_export: number;
|
||||||
/**
|
/**
|
||||||
* Metered Net
|
* Metered Net
|
||||||
* @description Σ net_cost for non-degraded periods.
|
* @description Σ net_cost for non-degraded periods (money, in `currency`).
|
||||||
*/
|
*/
|
||||||
metered_net: number;
|
metered_net: number;
|
||||||
|
/**
|
||||||
|
* Metered Import Kwh
|
||||||
|
* @description Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh).
|
||||||
|
*/
|
||||||
|
metered_import_kwh: number;
|
||||||
|
/**
|
||||||
|
* Metered Export Kwh
|
||||||
|
* @description Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh).
|
||||||
|
*/
|
||||||
|
metered_export_kwh: number;
|
||||||
/**
|
/**
|
||||||
* Fixed Costs
|
* Fixed Costs
|
||||||
* @description Standing charges (network_fee + management_fee) apportioned over the interval.
|
* @description Standing charges (network_fee + management_fee) apportioned over the interval.
|
||||||
|
|||||||
@@ -61,9 +61,13 @@ const COST_PERIOD = {
|
|||||||
|
|
||||||
const SUMMARY = {
|
const SUMMARY = {
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
|
// Money totals and kWh totals are deliberately distinct so the assertions
|
||||||
|
// below prove the cards read the *_kwh fields, not the monetary ones.
|
||||||
metered_import: 10.5,
|
metered_import: 10.5,
|
||||||
metered_export: 2.3,
|
metered_export: 2.3,
|
||||||
metered_net: 8.2,
|
metered_net: 8.2,
|
||||||
|
metered_import_kwh: 33.3,
|
||||||
|
metered_export_kwh: 44.4,
|
||||||
fixed_costs: 5.0,
|
fixed_costs: 5.0,
|
||||||
credits: 50.0,
|
credits: 50.0,
|
||||||
total_payable: 12.5,
|
total_payable: 12.5,
|
||||||
@@ -145,9 +149,14 @@ describe('CostView', () => {
|
|||||||
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
|
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500')
|
// Main figure is energy (kWh), taken from the *_kwh fields.
|
||||||
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300')
|
expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300')
|
||||||
|
expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400')
|
||||||
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
|
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
|
||||||
|
|
||||||
|
// Sub-line carries the monetary equivalent, so money is still visible.
|
||||||
|
expect(screen.getByTestId('summary-import-sub')).toHaveTextContent('10.50 EUR')
|
||||||
|
expect(screen.getByTestId('summary-export-sub')).toHaveTextContent('2.30 EUR')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows recompute confirmation modal on button click', async () => {
|
it('shows recompute confirmation modal on button click', async () => {
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ function getThisMonthRange(): { start: string; end: string } {
|
|||||||
interface SummaryCardProps {
|
interface SummaryCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
|
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
|
||||||
|
sub?: string
|
||||||
testId?: string
|
testId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryCard({ label, value, testId }: SummaryCardProps) {
|
function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="sm" data-testid={testId}>
|
<Paper withBorder p="sm" data-testid={testId}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
@@ -90,6 +92,11 @@ function SummaryCard({ label, value, testId }: SummaryCardProps) {
|
|||||||
<Text fw={600} size="lg">
|
<Text fw={600} size="lg">
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
|
{sub !== undefined && (
|
||||||
|
<Text size="xs" c="dimmed" data-testid={testId ? `${testId}-sub` : undefined}>
|
||||||
|
{sub}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
@@ -207,12 +214,14 @@ export function CostView() {
|
|||||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
|
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Import (kWh)"
|
label="Import (kWh)"
|
||||||
value={summaryQuery.data.metered_import.toFixed(3)}
|
value={summaryQuery.data.metered_import_kwh.toFixed(3)}
|
||||||
|
sub={`${summaryQuery.data.metered_import.toFixed(2)} ${currency}`}
|
||||||
testId="summary-import"
|
testId="summary-import"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Export (kWh)"
|
label="Export (kWh)"
|
||||||
value={summaryQuery.data.metered_export.toFixed(3)}
|
value={summaryQuery.data.metered_export_kwh.toFixed(3)}
|
||||||
|
sub={`${summaryQuery.data.metered_export.toFixed(2)} ${currency}`}
|
||||||
testId="summary-export"
|
testId="summary-export"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
* 2. Empty state (no active contract / no kind).
|
* 2. Empty state (no active contract / no kind).
|
||||||
* 3. Renders tibber chart when tibber kind data is available.
|
* 3. Renders tibber chart when tibber kind data is available.
|
||||||
* 4. Shows tariff table for manual kind.
|
* 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
|
|||||||
// Import component
|
// 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
|
// Tests
|
||||||
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
|
|||||||
|
|
||||||
describe('TibberPrices', () => {
|
describe('TibberPrices', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
restoreChartSize()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders loading state initially', () => {
|
it('renders loading state initially', () => {
|
||||||
mockGet.mockImplementation(() => new Promise(() => {}))
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
|
|||||||
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
||||||
expect(screen.getByTestId('tariff-sell-dal')).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.
|
* TibberPrices — price curve visualization.
|
||||||
*
|
*
|
||||||
* - Fetches today + tomorrow price range using useEnergyPrices.
|
* - 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).
|
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
|
||||||
* - Handles: no active contract, empty data, loading, error.
|
* - Handles: no active contract, empty data, loading, error.
|
||||||
*
|
*
|
||||||
* Recharts imports are isolated to this file only.
|
* Recharts imports are isolated to this file only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -29,10 +31,20 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
ReferenceDot,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useEnergyPrices } from './hooks'
|
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
|
// Time range helpers
|
||||||
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
|
|||||||
return d.toISOString()
|
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
|
// Tibber chart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface TibberChartProps {
|
interface TibberChartProps {
|
||||||
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
|
points: PricePoint[]
|
||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TibberChart({ points, currency }: TibberChartProps) {
|
function TibberChart({ points, currency }: TibberChartProps) {
|
||||||
const data = points.map((p) => ({
|
const data = useMemo(() => buildChartRows(points), [points])
|
||||||
time: formatLocalTime(p.starts_at),
|
const nowMs = useNowMs()
|
||||||
buy: p.buy,
|
const activeIndex = findActiveSlotIndex(data, nowMs)
|
||||||
sell: p.sell,
|
const activeRow = activeIndex == null ? null : data[activeIndex]
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" data-testid="tibber-chart">
|
<Stack gap="xs" data-testid="tibber-chart">
|
||||||
|
<Group gap="xs" justify="space-between" align="baseline">
|
||||||
<Title order={6} c="dimmed">
|
<Title order={6} c="dimmed">
|
||||||
Price curve ({currency})
|
Price curve ({currency})
|
||||||
</Title>
|
</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}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="ts"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
|
tickFormatter={(v: string) => formatLocalTime(v)}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
|||||||
formatter={(val: any) =>
|
formatter={(val: any) =>
|
||||||
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
||||||
}
|
}
|
||||||
|
labelFormatter={(label) =>
|
||||||
|
typeof label === 'string'
|
||||||
|
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
|
||||||
|
: label
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="buy"
|
dataKey="buy"
|
||||||
stroke="#2196f3"
|
stroke={BUY_COLOR}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Buy"
|
name="Buy"
|
||||||
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="sell"
|
dataKey="sell"
|
||||||
stroke="#4caf50"
|
stroke={SELL_COLOR}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Sell"
|
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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const SUMMARY = {
|
|||||||
metered_import: 10.5,
|
metered_import: 10.5,
|
||||||
metered_export: 2.3,
|
metered_export: 2.3,
|
||||||
metered_net: 8.2,
|
metered_net: 8.2,
|
||||||
|
metered_import_kwh: 33.3,
|
||||||
|
metered_export_kwh: 44.4,
|
||||||
fixed_costs: 5.0,
|
fixed_costs: 5.0,
|
||||||
credits: 50.0,
|
credits: 50.0,
|
||||||
total_payable: 12.5,
|
total_payable: 12.5,
|
||||||
|
|||||||
+17
-5
@@ -701,7 +701,7 @@
|
|||||||
"api-energy"
|
"api-energy"
|
||||||
],
|
],
|
||||||
"summary": "Get Prices",
|
"summary": "Get Prices",
|
||||||
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
||||||
"operationId": "get_prices_api_energy_prices_get",
|
"operationId": "get_prices_api_energy_prices_get",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -4727,17 +4727,27 @@
|
|||||||
"metered_import": {
|
"metered_import": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"title": "Metered Import",
|
"title": "Metered Import",
|
||||||
"description": "Σ import_cost for non-degraded periods."
|
"description": "Σ import_cost for non-degraded periods (money, in `currency`)."
|
||||||
},
|
},
|
||||||
"metered_export": {
|
"metered_export": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"title": "Metered Export",
|
"title": "Metered Export",
|
||||||
"description": "Σ export_revenue for non-degraded periods."
|
"description": "Σ export_revenue for non-degraded periods (money, in `currency`)."
|
||||||
},
|
},
|
||||||
"metered_net": {
|
"metered_net": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"title": "Metered Net",
|
"title": "Metered Net",
|
||||||
"description": "Σ net_cost for non-degraded periods."
|
"description": "Σ net_cost for non-degraded periods (money, in `currency`)."
|
||||||
|
},
|
||||||
|
"metered_import_kwh": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Metered Import Kwh",
|
||||||
|
"description": "Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
|
||||||
|
},
|
||||||
|
"metered_export_kwh": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Metered Export Kwh",
|
||||||
|
"description": "Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
|
||||||
},
|
},
|
||||||
"fixed_costs": {
|
"fixed_costs": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -4776,6 +4786,8 @@
|
|||||||
"metered_import",
|
"metered_import",
|
||||||
"metered_export",
|
"metered_export",
|
||||||
"metered_net",
|
"metered_net",
|
||||||
|
"metered_import_kwh",
|
||||||
|
"metered_export_kwh",
|
||||||
"fixed_costs",
|
"fixed_costs",
|
||||||
"credits",
|
"credits",
|
||||||
"total_payable",
|
"total_payable",
|
||||||
@@ -4784,7 +4796,7 @@
|
|||||||
"days"
|
"days"
|
||||||
],
|
],
|
||||||
"title": "SummaryResponse",
|
"title": "SummaryResponse",
|
||||||
"description": "Response for GET /api/energy/costs/summary.\n\nAll monetary values are in ``currency``.\n\n``total_payable = metered_net + fixed_costs − credits``"
|
"description": "Response for GET /api/energy/costs/summary.\n\nMonetary values are in ``currency``; the ``*_kwh`` fields are energy totals\nin kWh. ``metered_import``/``metered_export`` are **money**, not energy —\nonly the ``_kwh``-suffixed fields carry kWh.\n\n``total_payable = metered_net + fixed_costs − credits``"
|
||||||
},
|
},
|
||||||
"TibberTestPriceSchema": {
|
"TibberTestPriceSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+23
-7
@@ -527,9 +527,9 @@ paths:
|
|||||||
\ (most recent first\n within the window, then reversed to ascending order\
|
\ (most recent first\n within the window, then reversed to ascending order\
|
||||||
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
||||||
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
||||||
\ price)\n - ``sell = total − energy_tax − sell_adjust`` (from active\
|
\ price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from\
|
||||||
\ version values)\n - ``level`` (Tibber price level,\
|
\ active version values)\n - ``level`` (Tibber price\
|
||||||
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
\ level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
||||||
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
||||||
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
||||||
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
||||||
@@ -3659,15 +3659,25 @@ components:
|
|||||||
metered_import:
|
metered_import:
|
||||||
type: number
|
type: number
|
||||||
title: Metered Import
|
title: Metered Import
|
||||||
description: Σ import_cost for non-degraded periods.
|
description: Σ import_cost for non-degraded periods (money, in `currency`).
|
||||||
metered_export:
|
metered_export:
|
||||||
type: number
|
type: number
|
||||||
title: Metered Export
|
title: Metered Export
|
||||||
description: Σ export_revenue for non-degraded periods.
|
description: Σ export_revenue for non-degraded periods (money, in `currency`).
|
||||||
metered_net:
|
metered_net:
|
||||||
type: number
|
type: number
|
||||||
title: Metered Net
|
title: Metered Net
|
||||||
description: Σ net_cost for non-degraded periods.
|
description: Σ net_cost for non-degraded periods (money, in `currency`).
|
||||||
|
metered_import_kwh:
|
||||||
|
type: number
|
||||||
|
title: Metered Import Kwh
|
||||||
|
description: Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported,
|
||||||
|
kWh).
|
||||||
|
metered_export_kwh:
|
||||||
|
type: number
|
||||||
|
title: Metered Export Kwh
|
||||||
|
description: Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported,
|
||||||
|
kWh).
|
||||||
fixed_costs:
|
fixed_costs:
|
||||||
type: number
|
type: number
|
||||||
title: Fixed Costs
|
title: Fixed Costs
|
||||||
@@ -3699,6 +3709,8 @@ components:
|
|||||||
- metered_import
|
- metered_import
|
||||||
- metered_export
|
- metered_export
|
||||||
- metered_net
|
- metered_net
|
||||||
|
- metered_import_kwh
|
||||||
|
- metered_export_kwh
|
||||||
- fixed_costs
|
- fixed_costs
|
||||||
- credits
|
- credits
|
||||||
- total_payable
|
- total_payable
|
||||||
@@ -3709,7 +3721,11 @@ components:
|
|||||||
description: 'Response for GET /api/energy/costs/summary.
|
description: 'Response for GET /api/energy/costs/summary.
|
||||||
|
|
||||||
|
|
||||||
All monetary values are in ``currency``.
|
Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
|
||||||
|
|
||||||
|
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
|
||||||
|
|
||||||
|
only the ``_kwh``-suffixed fields carry kWh.
|
||||||
|
|
||||||
|
|
||||||
``total_payable = metered_net + fixed_costs − credits``'
|
``total_payable = metered_net + fixed_costs − credits``'
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
print(f"Profile : {profile.name} — {profile.description}")
|
print(f"Profile : {profile.name} — {profile.description}")
|
||||||
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||||
|
print(f"Function : FC{profile.function_code:02d}")
|
||||||
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None:
|
|||||||
from app.integrations.modbus.driver import read_blocks
|
from app.integrations.modbus.driver import read_blocks
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registers = read_blocks(host, port, unit_id, blocks)
|
registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code)
|
||||||
except ModbusConnectionError as exc:
|
except ModbusConnectionError as exc:
|
||||||
print(f"Connection error: {exc}", file=sys.stderr)
|
print(f"Connection error: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -363,13 +363,65 @@ def test_prices_tibber_contract_returns_points(energy_client):
|
|||||||
starts_at_list = [p["starts_at"] for p in body["points"]]
|
starts_at_list = [p["starts_at"] for p in body["points"]]
|
||||||
assert starts_at_list == sorted(starts_at_list)
|
assert starts_at_list == sorted(starts_at_list)
|
||||||
|
|
||||||
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0
|
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust
|
||||||
|
# (this version has no sell_fee/sell_adjust → both default to 0 at read time).
|
||||||
for p in body["points"]:
|
for p in body["points"]:
|
||||||
assert abs(p["buy"] - 0.245) < 1e-6
|
assert abs(p["buy"] - 0.245) < 1e-6
|
||||||
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
||||||
assert p["level"] == "NORMAL"
|
assert p["level"] == "NORMAL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prices_tibber_sell_reflects_sell_fee(energy_client):
|
||||||
|
"""/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config."""
|
||||||
|
client, engine, _app = energy_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Net-metering version: sell_adjust = −energy_tax (refund tax), sell_fee = 0.0248.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Tibber NetMeter",
|
||||||
|
kind="tibber",
|
||||||
|
active=True,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=now - timedelta(days=30),
|
||||||
|
effective_to=None,
|
||||||
|
values={
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_fee": 0.0248,
|
||||||
|
"sell_adjust": -0.1108,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 25.0},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
_make_tibber_prices(engine, count=3)
|
||||||
|
|
||||||
|
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||||
|
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
||||||
|
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "tibber"
|
||||||
|
assert len(body["points"]) == 3
|
||||||
|
# sell = 0.245 − 0.1108 − 0.0248 − (−0.1108) = 0.245 − 0.0248 = 0.2202
|
||||||
|
for p in body["points"]:
|
||||||
|
assert abs(p["buy"] - 0.245) < 1e-6
|
||||||
|
assert abs(p["sell"] - 0.2202) < 1e-4
|
||||||
|
|
||||||
|
|
||||||
def test_prices_tibber_limit_caps_results(energy_client):
|
def test_prices_tibber_limit_caps_results(energy_client):
|
||||||
client, engine, _app = energy_client
|
client, engine, _app = energy_client
|
||||||
_login(client)
|
_login(client)
|
||||||
|
|||||||
@@ -990,6 +990,38 @@ class TestSummarize:
|
|||||||
# Σnet ≈ 2 × 0.4051 = 0.8102
|
# Σnet ≈ 2 × 0.4051 = 0.8102
|
||||||
assert abs(result["metered_net"] - 0.8102) < 1e-6
|
assert abs(result["metered_net"] - 0.8102) < 1e-6
|
||||||
|
|
||||||
|
def test_metered_kwh_sums(self, energy_db: Session) -> None:
|
||||||
|
"""The *_kwh totals sum both tariff registers and are distinct from the money totals."""
|
||||||
|
self._setup_two_periods(energy_db)
|
||||||
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
|
|
||||||
|
# Per period: d1=0.5, d2=1.2 → import 1.7 kWh; r1=0.0, r2=0.1 → export 0.1 kWh.
|
||||||
|
assert abs(result["metered_import_kwh"] - 3.4) < 1e-6, (
|
||||||
|
f"expected Σ(d1+d2) = 2 × 1.7 = 3.4 kWh, got {result['metered_import_kwh']}"
|
||||||
|
)
|
||||||
|
assert abs(result["metered_export_kwh"] - 0.2) < 1e-6, (
|
||||||
|
f"expected Σ(r1+r2) = 2 × 0.1 = 0.2 kWh, got {result['metered_export_kwh']}"
|
||||||
|
)
|
||||||
|
# Regression guard for the mislabelled-unit bug: energy and money totals
|
||||||
|
# must never be conflated (import 3.4 kWh vs 0.8202 EUR of import cost).
|
||||||
|
assert result["metered_import_kwh"] != result["metered_import"]
|
||||||
|
assert result["metered_export_kwh"] != result["metered_export"]
|
||||||
|
|
||||||
|
def test_metered_kwh_excludes_degraded_periods(self, energy_db: Session) -> None:
|
||||||
|
"""Degraded periods contribute no kWh, mirroring the money totals."""
|
||||||
|
self._setup_two_periods(energy_db)
|
||||||
|
# Degrade the first period; its kWh must drop out of the totals.
|
||||||
|
row = energy_db.execute(
|
||||||
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
||||||
|
).scalar_one()
|
||||||
|
row.degraded = True
|
||||||
|
energy_db.commit()
|
||||||
|
|
||||||
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
|
assert result["degraded_count"] == 1
|
||||||
|
assert abs(result["metered_import_kwh"] - 1.7) < 1e-6
|
||||||
|
assert abs(result["metered_export_kwh"] - 0.1) < 1e-6
|
||||||
|
|
||||||
def test_period_count(self, energy_db: Session) -> None:
|
def test_period_count(self, energy_db: Session) -> None:
|
||||||
self._setup_two_periods(energy_db)
|
self._setup_two_periods(energy_db)
|
||||||
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
@@ -1263,6 +1295,9 @@ class TestSummarizePrincipleC:
|
|||||||
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
||||||
"""A fully future window (all local dates > today) counts 0 days.
|
"""A fully future window (all local dates > today) counts 0 days.
|
||||||
|
|
||||||
|
Pins ``local_now`` to June 25 2026 noon AMS so the 7/1→8/1 window is
|
||||||
|
genuinely in the future regardless of the actual wall-clock date
|
||||||
|
(mirrors the sibling window tests, which all pin ``local_now``).
|
||||||
Matches table row: 7/1→8/1 (all future) → 0 days.
|
Matches table row: 7/1→8/1 (all future) → 0 days.
|
||||||
"""
|
"""
|
||||||
eff_utc = _ams_midnight(2026, 6, 1)
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
@@ -1270,7 +1305,9 @@ class TestSummarizePrincipleC:
|
|||||||
|
|
||||||
start = _ams_midnight(2026, 7, 1)
|
start = _ams_midnight(2026, 7, 1)
|
||||||
end = _ams_midnight(2026, 8, 1)
|
end = _ams_midnight(2026, 8, 1)
|
||||||
result = self._run_summarize_ams(energy_db, start, end)
|
# Pin local_now to June 25 2026 noon AMS so 7/1→8/1 stays fully future.
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||||
|
|
||||||
assert result["fixed_costs"] == 0.0, (
|
assert result["fixed_costs"] == 0.0, (
|
||||||
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
||||||
@@ -2703,6 +2740,7 @@ class TestSummarizeSettlementOffset:
|
|||||||
|
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
"currency", "metered_import", "metered_export", "metered_net",
|
"currency", "metered_import", "metered_export", "metered_net",
|
||||||
|
"metered_import_kwh", "metered_export_kwh",
|
||||||
"fixed_costs", "credits", "total_payable", "period_count",
|
"fixed_costs", "credits", "total_payable", "period_count",
|
||||||
"degraded_count", "days",
|
"degraded_count", "days",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,57 @@ class TestReadBlocks:
|
|||||||
|
|
||||||
mock_client.close.assert_called_once()
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_default_function_code_uses_fc04_input_registers(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""With no function_code given, read_blocks uses FC04 (read_input_registers)."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
|
||||||
|
|
||||||
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1)
|
||||||
|
mock_client.read_holding_registers.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_function_code_3_uses_fc03_holding_registers(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_holding_registers.return_value = _make_ok_response(
|
||||||
|
[0x4366, 0x3334, 0x3F80, 0x0000]
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks = [{"start": 0x2000, "count": 4}]
|
||||||
|
result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3)
|
||||||
|
|
||||||
|
assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000}
|
||||||
|
mock_client.read_holding_registers.assert_called_once_with(
|
||||||
|
0x2000, count=4, device_id=1
|
||||||
|
)
|
||||||
|
mock_client.read_input_registers.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_invalid_function_code_raises_before_connecting(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""An unsupported function code is rejected without opening a connection."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
with pytest.raises(ModbusDriverError, match="Unsupported read function code"):
|
||||||
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16)
|
||||||
|
|
||||||
|
# No client should have been constructed or connected for a bad FC.
|
||||||
|
mock_client_cls.assert_not_called()
|
||||||
|
mock_client.connect.assert_not_called()
|
||||||
|
|
||||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
||||||
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ class TestLoadProfileTibber:
|
|||||||
profile = load_profile("tibber")
|
profile = load_profile("tibber")
|
||||||
assert profile.energy.sell_adjust.default == 0
|
assert profile.energy.sell_adjust.default == 0
|
||||||
|
|
||||||
|
def test_sell_fee_has_default_verkoopvergoeding(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.energy.sell_fee.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.sell_fee.default == 0.0248
|
||||||
|
|
||||||
def test_management_fee_has_default(self) -> None:
|
def test_management_fee_has_default(self) -> None:
|
||||||
profile = load_profile("tibber")
|
profile = load_profile("tibber")
|
||||||
assert profile.standing.management_fee.default is not None
|
assert profile.standing.management_fee.default is not None
|
||||||
@@ -350,6 +355,19 @@ class TestValidateValuesTibber:
|
|||||||
filled = validate_values("tibber", values)
|
filled = validate_values("tibber", values)
|
||||||
assert filled["energy"]["sell_adjust"] == 0
|
assert filled["energy"]["sell_adjust"] == 0
|
||||||
|
|
||||||
|
def test_sell_fee_default_applied_when_absent(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_adjust": 0.0,
|
||||||
|
# sell_fee absent — has default 0.0248 (verkoopvergoeding)
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
filled = validate_values("tibber", values)
|
||||||
|
assert filled["energy"]["sell_fee"] == 0.0248
|
||||||
|
|
||||||
def test_management_fee_default_applied_when_absent(self) -> None:
|
def test_management_fee_default_applied_when_absent(self) -> None:
|
||||||
values = {
|
values = {
|
||||||
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Acceptance criteria covered
|
|||||||
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
||||||
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
||||||
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
||||||
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_adjust.
|
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_fee−sell_adjust.
|
||||||
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
||||||
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
||||||
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
||||||
@@ -371,6 +371,68 @@ class TestTibberStrategy:
|
|||||||
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
||||||
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
||||||
|
|
||||||
|
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
|
||||||
|
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
|
||||||
|
|
||||||
|
Under net metering the energy tax is refunded (sell_adjust = −energy_tax),
|
||||||
|
so sell should equal total − sell_fee. Verifies the fee is a first-class,
|
||||||
|
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
|
||||||
|
that is already baked into total.
|
||||||
|
"""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Net-metering config: sell_adjust = −energy_tax refunds the tax;
|
||||||
|
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.11085,
|
||||||
|
"sell_fee": 0.0248,
|
||||||
|
"sell_adjust": -0.11085,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
|
||||||
|
# sell = 0.3073 − 0.11085 − 0.0248 − (−0.11085) = 0.3073 − 0.0248 = 0.2825
|
||||||
|
expected_sell = (
|
||||||
|
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
|
||||||
|
)
|
||||||
|
assert expected_sell == Decimal("0.2825")
|
||||||
|
assert result["export_revenue"] == Decimal("1") * expected_sell
|
||||||
|
assert result["pricing"]["sell_fee"] == "0.0248"
|
||||||
|
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
|
||||||
|
|
||||||
|
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
|
||||||
|
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.10, "sell_adjust": 0.0}, # no sell_fee key
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
# sell = 0.25 − 0.10 − 0 − 0 = 0.15
|
||||||
|
assert result["export_revenue"] == Decimal("0.15")
|
||||||
|
assert result["pricing"]["sell_fee"] == "0"
|
||||||
|
|
||||||
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
||||||
"""Correct row: starts_at ≤ t0, most recent wins."""
|
"""Correct row: starts_at ≤ t0, most recent wins."""
|
||||||
t0 = _ts(10, 0)
|
t0 = _ts(10, 0)
|
||||||
|
|||||||
+80
-25
@@ -69,22 +69,28 @@ _THREE_NODES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRICE_RANGE_RESPONSE = {
|
def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
|
||||||
|
"""Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
|
||||||
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"homes": [
|
"homes": [
|
||||||
{
|
{
|
||||||
"id": "home-id-1",
|
"id": "home-id-1",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": _THREE_NODES,
|
"today": today,
|
||||||
|
"tomorrow": tomorrow if tomorrow is not None else [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
|
||||||
|
|
||||||
_CURRENT_PRICE_RESPONSE = {
|
_CURRENT_PRICE_RESPONSE = {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
|
|||||||
|
|
||||||
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
||||||
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
||||||
# Build a response with a single node only.
|
# Build a response with a single today node and no tomorrow yet.
|
||||||
one_node_response = {
|
one_node_response = _price_info_response([_THREE_NODES[0]])
|
||||||
"data": {
|
|
||||||
"viewer": {
|
|
||||||
"homes": [
|
|
||||||
{
|
|
||||||
"id": "home-id-1",
|
|
||||||
"currentSubscription": {
|
|
||||||
"priceInfoRange": {
|
|
||||||
"nodes": [_THREE_NODES[0]],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transport = _make_transport(200, one_node_response)
|
transport = _make_transport(200, one_node_response)
|
||||||
|
|
||||||
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
@@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
|||||||
assert len(points) == 1
|
assert len(points) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch):
|
||||||
|
"""today and tomorrow node lists are both parsed (today first, then tomorrow)."""
|
||||||
|
tomorrow_nodes = [
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-24T00:00:00.000+02:00",
|
||||||
|
"total": 0.40,
|
||||||
|
"energy": 0.32,
|
||||||
|
"tax": 0.08,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "EXPENSIVE",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
response = _price_info_response(_THREE_NODES, tomorrow_nodes)
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
# 3 today + 1 tomorrow, in order.
|
||||||
|
assert len(points) == 4
|
||||||
|
# First node is today's first; last node is tomorrow's.
|
||||||
|
assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
|
||||||
|
# 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z
|
||||||
|
assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC)
|
||||||
|
assert points[-1].total == pytest.approx(0.40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch):
|
||||||
|
"""A null tomorrow (before day-ahead publication) yields today's nodes only."""
|
||||||
|
response = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfo": {
|
||||||
|
"today": _THREE_NODES,
|
||||||
|
"tomorrow": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert len(points) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_price_range_home_id_selection(monkeypatch):
|
def test_fetch_price_range_home_id_selection(monkeypatch):
|
||||||
"""When home_id is specified, the matching home is selected."""
|
"""When home_id is specified, the matching home is selected."""
|
||||||
two_homes_response = {
|
two_homes_response = {
|
||||||
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
|
|||||||
{
|
{
|
||||||
"id": "home-id-first",
|
"id": "home-id-first",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[0]],
|
"today": [_THREE_NODES[0]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "home-id-second",
|
"id": "home-id-second",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
|
"today": [_THREE_NODES[1], _THREE_NODES[2]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user