Compare commits
115
Commits
v1.0.1
..
2544514f52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2544514f52 | ||
|
|
96e85fb48e | ||
|
|
b71009620a | ||
|
|
8b91146c29 | ||
|
|
682e06d256 | ||
|
|
d7f0731d1c | ||
|
|
9bc46f8d2d | ||
|
|
6f8cb05eab | ||
|
|
effe434637 | ||
|
|
9d3e28a529 | ||
|
|
7e61b0a938 | ||
|
|
e8521351d7 | ||
|
|
8d4f496ff4 | ||
|
|
78f3cc4776 | ||
|
|
96e88861d4 | ||
|
|
57f2459f60 | ||
|
|
c61fc2d4ba | ||
|
|
d35ac7d752 | ||
|
|
ef48032adb | ||
|
|
7e266a21eb | ||
|
|
4284bd40a7 | ||
|
|
bedea196c3 | ||
|
|
df54f5518b | ||
|
|
18fe18281b | ||
|
|
355c8eb8b8 | ||
|
|
1ffdaebbbd | ||
|
|
c64693b8e5 | ||
|
|
c89d083942 | ||
|
|
6090b98ab0 | ||
|
|
489cd7df3d | ||
|
|
35cdc7f22a | ||
|
|
75d685f04d | ||
|
|
935e68846c | ||
|
|
4767a7af60 | ||
|
|
45d87f36f7 | ||
|
|
35b95f5c88 | ||
|
|
9d05f5ea62 | ||
|
|
360cddc05b | ||
|
|
14b846549e | ||
|
|
68165f0e01 | ||
|
|
2dc469274a | ||
|
|
702a1cec00 | ||
|
|
49d15d8ffe | ||
|
|
c89cea4953 | ||
|
|
d9c82e39fb | ||
|
|
eac7860b51 | ||
|
|
da7d0f9d62 | ||
|
|
e49a6ad963 | ||
|
|
d70b985eb0 | ||
|
|
e3c47b2762 | ||
|
|
dbf17f3592 | ||
|
|
ae75e4582d | ||
|
|
ee1264b66b | ||
|
|
51da8c5716 | ||
|
|
81bd32f613 | ||
|
|
ee3031013e | ||
|
|
5026967cee | ||
|
|
5481fbc99c | ||
|
|
e480a84a44 | ||
|
|
64d3882c16 | ||
|
|
6b67b8e0f8 | ||
|
|
0cb94d85ec | ||
|
|
9da88db221 | ||
|
|
1a3aaea933 | ||
|
|
bf7fd71a21 | ||
|
|
962ba26c7c | ||
|
|
da236643f2 | ||
|
|
bd09523e94 | ||
|
|
53f1245d83 | ||
|
|
51f712f602 | ||
|
|
f8b1e5fc71 | ||
|
|
a9830c42d8 | ||
|
|
8aa7316b26 | ||
|
|
32d93bba2a | ||
|
|
0d988a9b28 | ||
|
|
ef7ea6b971 | ||
|
|
6cc6382515 | ||
|
|
ef2bd3c9c5 | ||
|
|
cc2c02a2e2 | ||
|
|
b2e26f0b17 | ||
|
|
8975acc48b | ||
|
|
6cfeb2b865 | ||
|
|
dba9e28540 | ||
|
|
2bc5d6ea9a | ||
|
|
3ec663e138 | ||
|
|
048414c5cb | ||
|
|
9ce3f2a0b8 | ||
|
|
0fba7cfe11 | ||
|
|
d8303eaa3d | ||
|
|
8da1f13e60 | ||
|
|
de77019ce3 | ||
|
|
c2b1b7b751 | ||
|
|
3628ac51e5 | ||
|
|
1756192270 | ||
|
|
66ec9979cc | ||
|
|
c1a5d7a425 | ||
|
|
1e0b235cef | ||
|
|
a337b06c94 | ||
|
|
1cbe6c46d2 | ||
|
|
2f634006d2 | ||
|
|
dc624bb7e5 | ||
|
|
af8c602988 | ||
|
|
0d898e09f2 | ||
|
|
3d3c2bcc57 | ||
|
|
bc8dd062d5 | ||
|
|
427a491380 | ||
|
|
b359bbe3bf | ||
|
|
636bb2b80b | ||
|
|
eda49489e0 | ||
|
|
779e160b95 | ||
|
|
3ea3498e58 | ||
|
|
5a420bd37b | ||
|
|
a24e402d47 | ||
|
|
8565534b73 | ||
|
|
4acdd2dc60 |
@@ -8,3 +8,6 @@ data
|
||||
openapi
|
||||
src
|
||||
|
||||
# Frontend host build artifacts — built inside the node stage, not needed from context
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
+35
-2
@@ -4,8 +4,6 @@ APP_NAME=Home Automation Backend (Python)
|
||||
APP_ENV=production
|
||||
APP_HOSTNAME=home-automation.example.com
|
||||
APP_DATABASE_URL=sqlite:////app/data/app.db
|
||||
LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db
|
||||
POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db
|
||||
AUTH_BOOTSTRAP_USERNAME=admin
|
||||
AUTH_BOOTSTRAP_PASSWORD=change-me
|
||||
|
||||
@@ -30,3 +28,38 @@ TICKTICK_CLIENT_ID=
|
||||
TICKTICK_CLIENT_SECRET=
|
||||
TICKTICK_TOKEN=
|
||||
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
|
||||
|
||||
# Optional: Modbus polling (global kill-switch; default false — opt-in).
|
||||
# MODBUS_POLLING_ENABLED=false
|
||||
|
||||
# Optional: MQTT broker connection.
|
||||
# Leave MQTT_ENABLED=false (or unset) when MQTT is not needed.
|
||||
MQTT_ENABLED=false
|
||||
MQTT_BROKER_HOST=
|
||||
MQTT_BROKER_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_TLS_ENABLED=false
|
||||
|
||||
# Optional: Home Assistant MQTT Discovery.
|
||||
# Requires MQTT_ENABLED=true and a running MQTT broker.
|
||||
# HA_DISCOVERY_PREFIX is the config topic prefix (must be "homeassistant" for HA auto-discovery).
|
||||
# HA_STATE_TOPIC_PREFIX is the prefix for state/availability topics (separate from discovery).
|
||||
HA_DISCOVERY_ENABLED=false
|
||||
HA_DISCOVERY_PREFIX=homeassistant
|
||||
HA_STATE_TOPIC_PREFIX=home_automation
|
||||
|
||||
# Optional: DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
|
||||
# Set DSMR_INGEST_ENABLED=true to subscribe to the DSMR MQTT topic and
|
||||
# store 10-second downsampled telegram frames in dsmr_reading.
|
||||
# DSMR_INGEST_ENABLED=false
|
||||
# DSMR_MQTT_TOPIC=dsmr/json
|
||||
# DSMR_SAMPLE_INTERVAL_S=10
|
||||
# DSMR tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak).
|
||||
# Empty string disables tariff-aware pricing (buy/sell_price_now always show normal rate).
|
||||
# DSMR_TARIFF_TOPIC=dsmr/meter-stats/electricity_tariff
|
||||
|
||||
# Optional: Tibber dynamic pricing credentials (M6).
|
||||
# Only used when an active energy contract with kind=tibber is configured.
|
||||
# TIBBER_API_TOKEN=
|
||||
# TIBBER_HOME_ID=
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Check codegen is in sync
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm run codegen
|
||||
git diff --exit-code src/api/schema.d.ts
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Type-check
|
||||
working-directory: frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
working-directory: frontend
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
@@ -5,3 +5,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
review-notes/
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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
|
||||
```
|
||||
+20
-4
@@ -1,3 +1,20 @@
|
||||
# Stage 1: build the React SPA.
|
||||
# Pin to the native build host ($BUILDPLATFORM) so the Node/V8 build never runs
|
||||
# under QEMU during multi-arch builds — emulated Node crashes V8's baseline JIT
|
||||
# (SIGTRAP / exit 133 on `npm ci`). The dist/ output is static JS/CSS, i.e.
|
||||
# architecture-independent, so building it once and COPYing it into each
|
||||
# target-arch runtime stage is both correct and avoids the emulator entirely.
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: python runtime (no node)
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -11,15 +28,14 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY alembic_app ./alembic_app
|
||||
COPY alembic_app.ini ./
|
||||
COPY alembic_location ./alembic_location
|
||||
COPY alembic_location.ini ./
|
||||
COPY alembic_poo ./alembic_poo
|
||||
COPY alembic_poo.ini ./
|
||||
COPY scripts ./scripts
|
||||
COPY docker ./docker
|
||||
COPY README.md ./
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy the built SPA dist from the frontend-build stage
|
||||
COPY --from=frontend-build /frontend/dist ./frontend/dist
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
|
||||
@@ -4,14 +4,23 @@
|
||||
|
||||
当前系统已经包含:
|
||||
|
||||
- FastAPI Web 应用与服务端模板页面
|
||||
- SQLite + SQLAlchemy + Alembic 的三库结构
|
||||
- username/password + server-side session 鉴权
|
||||
- FastAPI Web 应用(React SPA 前端 + JSON API)
|
||||
- SQLite + SQLAlchemy + Alembic 的单库结构
|
||||
- username/password + server-side session 鉴权(含登录加固,见下文)
|
||||
- runtime config 页面与 app DB 持久化
|
||||
- public IPv4 monitor、历史持久化与定时检查
|
||||
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
|
||||
- location recorder
|
||||
- poo recorder
|
||||
- Home Assistant inbound / outbound integration
|
||||
- Home Assistant inbound / outbound integration(REST 通道)
|
||||
- TickTick OAuth 与 action task 集成
|
||||
- **Modbus 设备采集**:通过 YAML profile(首个:SDM120 电表)按设备周期轮询 Modbus-TCP 网关,解码工程量并落通用读数表(`modbus_device` + `modbus_reading`)
|
||||
- **MQTT + Home Assistant Discovery**:以可勾选方式把 Modbus 设备/工程量注册为 HA device/entity(含 binary_sensor online),state 周期发布;配置变更可重连重发
|
||||
- **前端侧边栏 + Energy 视图**:侧边导航替换顶栏;Energy 页管理 Modbus 设备、展示最新读数与 Recharts 走势图;Config 页 Accordion 分区展开;Expose 设置勾选 HA 可暴露实体
|
||||
- **DSMR 实时电表接入**:订阅 DSMR Reader 的 `dsmr/json`(每秒一帧)、整帧 JSON blob 按 10 秒降采样落库(`dsmr_reading`)
|
||||
- **通用电价合同层**:YAML profile 定合同结构(manual 固定/双费率 / tibber 动态电价);`EnergyContract`+`EnergyContractVersion` 存 UI 可填的数值,改价加新版本旧版本保留;price strategy 按 kind 出价
|
||||
- **实时买卖电费计算**:每 15 分钟按寄存器差值(`_1`=dal/低、`_2`=normal/高)× 买/卖价算计量电费,快照不可变;日/月/年汇总加固定费减 heffingskorting
|
||||
- **反哺 Home Assistant Energy**:当前买/卖价 + 累计买电支出/卖电收入(`total_increasing`)发成 HA 实体,可直接挂 HA Energy 仪表盘
|
||||
- pytest 测试与 OpenAPI 导出脚本
|
||||
- Docker / Compose 部署入口
|
||||
|
||||
@@ -21,43 +30,44 @@
|
||||
|
||||
## 当前配置现实
|
||||
|
||||
当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库:
|
||||
当前系统使用单一 SQLite 数据库文件(`app.db`),所有数据表都在其中:
|
||||
|
||||
- `app` 级共享数据使用自己的 DB 文件
|
||||
- `location` 模块使用自己的 DB 文件
|
||||
- `poo` 模块使用自己的 DB 文件
|
||||
- auth(单个 admin 用户、server-side session)
|
||||
- runtime config 持久化(`app_config` 表)
|
||||
- public IPv4 当前状态与变化历史
|
||||
- location 记录(`location` 表)
|
||||
- poo 记录(`poo_records` 表)
|
||||
- Modbus 设备定义(`modbus_device` 表)
|
||||
- Modbus 通用读数(`modbus_reading` 表,JSON payload)
|
||||
- HA 实体暴露开关(`exposed_entity_toggle` 表)
|
||||
- DSMR 电表实时读数(`dsmr_reading` 表,整帧 JSON blob,10s 降采样)
|
||||
- 电价合同(`energy_contract` 表)与版本(`energy_contract_version` 表,values JSON)
|
||||
- Tibber 15 分钟电价缓存(`tibber_price` 表,不可变)
|
||||
- 每 15 分钟计量电费(`energy_cost_period` 表,快照价,不可变)
|
||||
|
||||
当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点:
|
||||
配置层只保留一个数据库环境变量:
|
||||
|
||||
- `APP_DATABASE_URL`
|
||||
- `LOCATION_DATABASE_URL`
|
||||
- `POO_DATABASE_URL`
|
||||
|
||||
目前 auth、`location` 和 `poo` 都已经接到各自独立的数据库文件。
|
||||
`app.db` 不会在应用启动时自动创建,需要先运行:
|
||||
|
||||
其中 `app` 级共享 DB 当前主要用于:
|
||||
```bash
|
||||
python -m scripts.run_migrations
|
||||
```
|
||||
|
||||
- 单个 admin 用户
|
||||
- server-side session
|
||||
- runtime config 持久化
|
||||
|
||||
这部分现在也使用 Alembic 管理:
|
||||
|
||||
- `app db` 不会在应用启动时自动创建
|
||||
- 需要先运行 `python scripts/app_db_adopt.py`
|
||||
- 这个脚本会创建新 DB 并建好 schema
|
||||
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含全部表,包括 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading`、`energy_contract`、`energy_contract_version`、`tibber_price`、`energy_cost_period`)。
|
||||
|
||||
## 当前目录
|
||||
|
||||
主要目录如下:
|
||||
|
||||
- `app/`: FastAPI 应用代码
|
||||
- `alembic_app/`: App DB 的 Alembic migration 环境
|
||||
- `alembic_location/`: Location DB 的 Alembic migration 环境
|
||||
- `alembic_poo/`: Poo DB 的 Alembic migration 环境
|
||||
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
|
||||
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
|
||||
- `alembic_app/`: App DB 的 Alembic migration 环境(管理所有表,含 M5 新增的 `modbus_device`、`modbus_reading`、`exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading`、`energy_contract`、`energy_contract_version`、`tibber_price`、`energy_cost_period`)
|
||||
- `tests/`: pytest 测试
|
||||
- `docs/`: 当前系统说明文档
|
||||
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
||||
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
|
||||
|
||||
## 依赖管理
|
||||
|
||||
@@ -118,31 +128,81 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
启动后可访问:
|
||||
|
||||
- 应用首页:`http://localhost:8000/`
|
||||
- 应用首页(React SPA):`http://localhost:8000/`
|
||||
- 健康检查:`http://localhost:8000/status`
|
||||
- Swagger UI:`http://localhost:8000/docs`
|
||||
- ReDoc:`http://localhost:8000/redoc`
|
||||
|
||||
## 前端 v2(React SPA)
|
||||
|
||||
M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **Vite + React + TypeScript + Mantine**(组件库)
|
||||
- **TanStack Query**(数据请求/缓存)
|
||||
- **Leaflet / react-leaflet**(地图与热力图)
|
||||
- **Recharts**(Energy 视图走势图,M5 引入)
|
||||
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
|
||||
|
||||
### 本地开发(前端)
|
||||
|
||||
前端开发服务器会把 `/api`、`/location`、`/poo`、`/public-ip`、`/homeassistant`、`/ticktick`、`/status` 等路径代理到后端 FastAPI(`:8000`)。
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 启动 Vite dev server(默认 :5173),代理后端
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build # 产出 frontend/dist
|
||||
```
|
||||
|
||||
FastAPI 启动时若 `frontend/dist/index.html` 存在,则自动挂载该目录,并对非 `/api` 路径做 SPA fallback(返回 `index.html`)。该路径可通过环境变量 `SPA_DIST_DIR` 覆盖(默认值为 `frontend/dist`,与多阶段 Dockerfile 中 `COPY` 到 `/app/frontend/dist` 一致)。
|
||||
|
||||
### 类型化 API Client
|
||||
|
||||
前端 API client 由后端 OpenAPI schema 自动生成:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run codegen # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts
|
||||
```
|
||||
|
||||
生成物(`src/api/schema.d.ts`)已提交入库,CI 会校验它与 `openapi/openapi.json` 保持同步。
|
||||
|
||||
### 前端校验闸门
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript 类型检查
|
||||
npm run test # Vitest 单元测试
|
||||
npm run build # 构建,确认产出 dist
|
||||
```
|
||||
|
||||
## 数据库与 Alembic
|
||||
|
||||
当前默认使用 SQLite,并区分三个数据库文件:
|
||||
当前使用单一 SQLite 数据库文件:
|
||||
|
||||
- App DB:`sqlite:///./data/app.db`
|
||||
- Location DB:`sqlite:///./data/locationRecorder.db`
|
||||
- Poo DB:`sqlite:///./data/pooRecorder.db`
|
||||
- 数据目录:`./data/`
|
||||
|
||||
初始化 migration 环境后,可继续添加模型并生成迁移:
|
||||
所有模型(auth / config / public_ip / location / poo / modbus / expose)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
||||
|
||||
当前 `app`、`location` 和 `poo` 都已经有各自独立的 Alembic 链路。
|
||||
|
||||
- App Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||
- Location Alembic 环境:`alembic_location.ini` + `alembic_location/`
|
||||
- Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/`
|
||||
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||
- 统一 migration job:`python -m scripts.run_migrations`
|
||||
- App DB 初始化:`python scripts/app_db_adopt.py`
|
||||
- Location DB 接管 / 初始化:`python scripts/location_db_adopt.py`
|
||||
- Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py`
|
||||
- App DB 接管 / 初始化:`python scripts/app_db_adopt.py`
|
||||
|
||||
历史 location / poo 数据(旧版本遗留的独立 DB 文件)已通过以下脚本一次性迁移至 `app.db`(幂等,不删除旧文件):
|
||||
|
||||
```bash
|
||||
python -m scripts.migrate_legacy_data
|
||||
```
|
||||
|
||||
## 基础鉴权
|
||||
|
||||
@@ -150,9 +210,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
- 认证模型:`username/password`
|
||||
- 会话模型:server-side session + cookie
|
||||
- 当前主要受保护页面:`/config`
|
||||
- 当前公开页面:`/login`
|
||||
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
|
||||
- 当前受保护入口:React SPA(`/` 等客户端路由)调用 `/api/*` JSON 端点
|
||||
- 当前公开页面:`/login`(SPA 登录页)
|
||||
- 当前公开 API:裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
|
||||
|
||||
安全实现的当前边界:
|
||||
|
||||
@@ -160,7 +220,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
- session cookie 使用 `HttpOnly`
|
||||
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
|
||||
- `SameSite=Lax`
|
||||
- 登录表单和登出表单都有基础 CSRF 防护
|
||||
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
|
||||
|
||||
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
|
||||
|
||||
@@ -174,12 +234,203 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
||||
|
||||
当前前端主要有两条页面路径:
|
||||
React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`):
|
||||
|
||||
- `/login`
|
||||
- `/config`
|
||||
- `/login`:登录页
|
||||
- `/`:首页(地图热力图主视图)
|
||||
- `/config`:配置页(取代原 Jinja `/config`)
|
||||
- `/records`:记录管理列表页
|
||||
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`)。
|
||||
|
||||
## M4 登录加固
|
||||
|
||||
M4 在基础鉴权之上叠加了三层防御,详细说明见 [`docs/auth.md`](./docs/auth.md)。
|
||||
|
||||
### 防爆破 / 指数退避
|
||||
|
||||
登录失败超过 3 次后进入指数退避(`wait = min(900s, 1s × 2^(failures-3))`),期间请求返回 `429 Too Many Requests`(含 `Retry-After` 响应头);成功登录后自动清零。退避按 **client IP** 与 **username** 双键取较大值,不会因此永久锁定账号(只是延迟,不是封号)。
|
||||
|
||||
- 全局开关:`AUTH_LOGIN_THROTTLE_ENABLED`(CONFIG_FIELDS,默认 `true`)
|
||||
- 反代后需要 `AUTH_TRUST_FORWARDED_FOR=true` 才会读 `X-Forwarded-For`(`.env` 部署级配置,默认 `false`)
|
||||
|
||||
### CLI 逃生通道
|
||||
|
||||
拿到服务器 CLI 权限时,可以在**不依赖任何已存凭据**(无需密码、恢复码)的情况下重置密码、解锁退避、关停 TOTP:
|
||||
|
||||
```bash
|
||||
# 重置密码(不加 --password 则交互式输入,不回显)
|
||||
python -m scripts.admin_cli reset-password admin
|
||||
|
||||
# 解锁退避(被 429 挡住时使用)
|
||||
python -m scripts.admin_cli unlock --all # 清所有退避行
|
||||
python -m scripts.admin_cli unlock --ip 1.2.3.4 # 按 IP 清
|
||||
python -m scripts.admin_cli unlock --username admin # 按 username 清
|
||||
|
||||
# TOTP 相关(需要先启用,见下文)
|
||||
python -m scripts.admin_cli disable-totp admin # 关停 TOTP(零凭据,逃生口)
|
||||
python -m scripts.admin_cli reissue-totp admin # 重新发放 TOTP secret(打印新 URI)
|
||||
|
||||
# 查看用户列表
|
||||
python -m scripts.admin_cli list-admin
|
||||
```
|
||||
|
||||
在 Docker 容器内执行时:
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m scripts.admin_cli <command>
|
||||
```
|
||||
|
||||
### 可选 TOTP 二次验证
|
||||
|
||||
admin 可在 React SPA 设置页(`/config`)自选启用 RFC 6238 TOTP:
|
||||
|
||||
1. 设置页点「启用 TOTP」→ 后端生成 `otpauth://` URI,前端渲染二维码(`qrcode.react`)
|
||||
2. 用 Authenticator App(如 Google Authenticator、Authy)扫码
|
||||
3. 输入当前 6 位动态码确认 → TOTP 启用
|
||||
4. 妥善保存一次性展示的 10 个恢复码(格式 `xxxx-xxxx`)
|
||||
|
||||
启用后,登录需要两步:密码 → 6 位动态码(或恢复码,一次性)。不启用则维持纯密码登录,行为不变。
|
||||
|
||||
恢复码丢失时,可用 CLI 逃生:`python -m scripts.admin_cli disable-totp admin`,随后即可纯密码登录。
|
||||
|
||||
TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环境变量配置(`.env` 部署级),默认回退 `app_name`。
|
||||
|
||||
## M5 Modbus 设备采集 / Energy / MQTT + HA Discovery
|
||||
|
||||
M5 给后端接入家庭 IoT 生态,新增通用 Modbus 采集链路(首个领域:能耗)、MQTT + Home Assistant Discovery 发布,以及前端侧边栏与 Energy 视图。
|
||||
|
||||
### 依赖
|
||||
|
||||
后端新增:
|
||||
- `pymodbus`:Modbus-TCP 客户端(轮询电表等 slave 设备)
|
||||
- `paho-mqtt`:MQTT 客户端(HA Discovery 与 state 发布)
|
||||
- `pyyaml`:YAML profile 加载(设备协议声明式描述)
|
||||
|
||||
前端新增:
|
||||
- `recharts`:Energy 视图走势图
|
||||
|
||||
### Modbus 设备采集
|
||||
|
||||
采集链路采用两层分离:**YAML profile**(协议知识,随代码走)+ **`modbus_device` 数据库行**(部署/可配置信息)+ **`modbus_reading` 通用读数表**(JSON payload 遥测)。
|
||||
|
||||
- **profile**(如 `sdm120.yaml`)描述:读哪些寄存器(FC04 块读)、每个量的 key/unit/device_class/ha_component。纯协议知识,不含 unit_id / friendly_name 等部署项。
|
||||
- **`modbus_device` 行**:friendly_name、网关 host/port、Modbus slave `unit_id`(电表 Meter ID,设备面板可改故落 DB)、选用哪个 profile、采样周期、是否启用。
|
||||
- **`modbus_reading` 行**:device_id FK、recorded_at、payload(JSON,如 `{"voltage": 230.2, "current": 1.3, ...}`)。
|
||||
- 多设备可共享同一 profile(如两块 SDM120 共用 `sdm120` profile,各自独立 unit_id 和 friendly_name)。
|
||||
- APScheduler 后台 job 周期轮询所有 `enabled` 设备,更新 `last_poll_at` / `last_poll_ok`。全局开关 `MODBUS_POLLING_ENABLED`(CONFIG_FIELDS)。
|
||||
|
||||
**手工命令行试读**(不依赖 DB,最快验证网关连通性):
|
||||
|
||||
```bash
|
||||
# 按 profile 解码读一次(验证整套解码链路)
|
||||
python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120
|
||||
|
||||
# 手工指定请求内容(first-contact 验证,不依赖 profile)
|
||||
python -m scripts.modbus_cli probe --host <网关IP> --port 502 --unit 1 --fc 4 --address 0x0000 --count 2 --decode float32
|
||||
```
|
||||
|
||||
CLI 工具为受控手工验证而设(设备需接市电),仅暴露读功能码(FC03/04),无写寄存器子命令。
|
||||
|
||||
### MQTT + Home Assistant Discovery
|
||||
|
||||
后端作为 MQTT 发布方,把 Modbus 设备/工程量以 HA Discovery 协议自动注册为 device/entity:
|
||||
|
||||
- 每个 Modbus 设备 = 一个 HA device(`unique_id` 锚定于设备 `uuid`,不随改名变)
|
||||
- 各工程量 = sensor entity(device_class/unit 取自 YAML profile);另有 binary_sensor `online`(取 `last_poll_ok`)
|
||||
- 每次轮询成功后推 state topic;连接成功或勾选变更时重发 retained discovery config
|
||||
- `POST /api/config/mqtt/test`:试连 broker 并发布一条测试消息(可用 MQTT Explorer 验证链路)
|
||||
|
||||
**Config 页配置流程**:
|
||||
1. 在 `/config` 的「MQTT」section 填写 broker host/port/username/password,启用 `MQTT_ENABLED`
|
||||
2. 点「发送测试消息」确认 broker 链路通
|
||||
3. 启用 `HA_DISCOVERY_ENABLED`
|
||||
4. 在「Home Assistant Expose」面板勾选要暴露的实体,点「重新发布 discovery」
|
||||
5. 在 Home Assistant 确认对应 device/entity 出现
|
||||
|
||||
### API 端点(M5 新增)
|
||||
|
||||
| 端点 | 用途 |
|
||||
| --- | --- |
|
||||
| `GET /api/modbus/devices` | 列出 Modbus 设备 |
|
||||
| `POST /api/modbus/devices` | 新建设备 |
|
||||
| `GET /api/modbus/devices/{uuid}` | 单个设备 |
|
||||
| `PATCH /api/modbus/devices/{uuid}` | 修改设备(含 enable/disable)|
|
||||
| `DELETE /api/modbus/devices/{uuid}` | 删除设备;有读数时 409 |
|
||||
| `GET /api/modbus/devices/{uuid}/metrics` | 该设备 profile 的量目录(key/unit/device_class)|
|
||||
| `GET /api/modbus/devices/{uuid}/latest` | 最新一条读数 payload |
|
||||
| `GET /api/modbus/devices/{uuid}/readings` | 时间范围读数(start/end/limit),供走势图 |
|
||||
| `POST /api/modbus/devices/{uuid}/test` | 即时试读(不落库) |
|
||||
| `GET /api/modbus/profiles` | 列出可用 profile 名 + 描述 |
|
||||
| `GET /api/expose` | 可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 |
|
||||
| `PUT /api/expose` | 设置逐 key 暴露开关 |
|
||||
| `POST /api/expose/republish` | 手动重发 discovery |
|
||||
| `POST /api/config/mqtt/test` | 试连 broker 并发布测试消息 |
|
||||
|
||||
### 前端视图(M5 新增)
|
||||
|
||||
- **侧边栏**:把顶栏改为侧边导航(Home / Records / Energy / Config + 主题切换 + 注销),当前路由高亮,移动端可折叠。
|
||||
- **`/energy`(Energy 视图)**:设备 CRUD(新建/编辑/删除,删除有二次确认;有读数时引导改用禁用);最新读数卡片(字段标签/单位取自 profile metrics);时间序列走势图(Recharts,支持电压/电流/功率/电能,带时间范围选择)。
|
||||
- **Config 页 Accordion**:各大 config section 可独立折叠/展开;「Home Assistant Expose」面板按设备分组勾选可暴露实体、显示 MQTT/Discovery 连接状态、「重新发布 discovery」按钮。
|
||||
- SPA 路由新增 `/energy`。
|
||||
|
||||
## M6 DSMR 接入 / 电价合同 / 实时电费计算 / HA Energy 反哺
|
||||
|
||||
M6 在 M5 IoT 基建之上接入 DSMR 实时智能电表数据,建立通用电价合同层,按每 15 分钟算出实际买卖电费并反哺 HA Energy。
|
||||
|
||||
### 依赖
|
||||
|
||||
M6 **不新增任何 Python 依赖**,复用 M5 已有的 `httpx`(Tibber GraphQL)、`paho-mqtt`(DSMR 订阅)、`pyyaml`(pricing profile 加载)、`apscheduler`(抓价 job、计费 job)。
|
||||
|
||||
### DSMR 实时电表接入
|
||||
|
||||
订阅 DSMR Reader 的 `dsmr/json` topic(每秒一帧完整 telegram),整帧存为 JSON blob、按 `dsmr_sample_interval_s`(默认 10 秒)降采样落 `dsmr_reading`(`source_id` 幂等去重)。`dsmr_ingest_enabled`(默认 false,opt-in)。
|
||||
|
||||
### 电价合同层
|
||||
|
||||
- **YAML profile 定结构**(仓库内,不放数值):`manual.yaml`(固定/双费率:buy_normal/dal、sell_normal/dal、energy_tax、ode、固定费、heffingskorting);`tibber.yaml`(动态:source=tibber_api,energy_tax、sell_adjust)
|
||||
- **`EnergyContract` + `EnergyContractVersion`**(UI 填数值):改价 = 加新版本行(带 `effective_from`),旧版本保留(审计链);一次只有一个 active 合同
|
||||
- **price strategy**:`manual` 用双费率常数(`buy = energy_buy_档 + energy_tax`,`sell = sell_档`);`tibber` 用 `tibber_price.total` 作买价(已含税,demo 确认 `total=energy+tax`),`total − energy_tax − sell_adjust` 作卖价(卖价残差 `sell_adjust` 默认 0,待真实账单核定)
|
||||
|
||||
### 每 15 分钟计量电费(不可变)
|
||||
|
||||
APScheduler 1 分钟 tick,取每个闭合 15 分钟窗口的 DSMR 寄存器差值(`delivered_1/2`,`returned_1/2`;`_1`=dal/低,`_2`=normal/高,NL 惯例)× 当时合同版本的 strategy 出价,upsert `energy_cost_period`(快照当时价 + `contract_version_id`)。缺价/缺数据时标 `degraded`。`POST /api/energy/costs/recompute` 显式重算。
|
||||
|
||||
日/月/年汇总 = Σnet + 固定费(network_fee + management_fee 按月→天 × 天数)- heffingskorting(按年→天 × 天数),读时计算、不落表。能源税 `energy_tax` 参考值约 0.1108 EUR/kWh(2026 第一档含 VAT,待真实账单核定;该值由 UI 填入合同版本,YAML profile 仅声明字段 unit,代码无写死默认数值)。
|
||||
|
||||
### Tibber 动态电价
|
||||
|
||||
`app/integrations/tibber/client.py` httpx POST GraphQL(`priceInfoRange(QUARTER_HOURLY, first=96)`),解析 `startsAt`/`total`/`energy`/`tax`/`level`,按 `starts_at` upsert `tibber_price`(幂等)。启动 + 每小时抓取今明两天 15 分钟价、幂等 upsert(hourly trigger,确保每日刷新且可补重试);仅当 active 合同 kind=tibber 且 `tibber_api_token` 存在时运行。`POST /api/energy/tibber/test` 试连三态(success 带当前价 / config-error / failed)。
|
||||
|
||||
### 反哺 Home Assistant Energy
|
||||
|
||||
`_energy_cost_provider` 向 expose 框架注册 4 个实体:`buy_price_now`、`sell_price_now`(€/kWh sensor)、`import_cost_total`、`export_revenue_total`(`total_increasing` monetary,可直接挂 HA Energy 仪表盘)。默认未勾选,在 Expose 面板启用。
|
||||
|
||||
### API 端点(M6 新增)
|
||||
|
||||
| 端点 | 用途 |
|
||||
| --- | --- |
|
||||
| `GET /api/energy/contracts` | 列出合同 + active 标记 |
|
||||
| `POST /api/energy/contracts` | 新建合同(kind + 首版本值,按 profile 校验)|
|
||||
| `GET /api/energy/contracts/{id}` | 单个合同 + 版本历史 |
|
||||
| `PATCH /api/energy/contracts/{id}` | 改名 / 激活 |
|
||||
| `POST /api/energy/contracts/{id}/versions` | 加新版本(改价,带生效日期)|
|
||||
| `GET /api/energy/profiles` | 列出 pricing profile 结构(前端按它渲染表单)|
|
||||
| `GET /api/energy/prices` | 区间价格点(曲线)|
|
||||
| `GET /api/energy/costs` | 区间 `energy_cost_period`(走势/明细)|
|
||||
| `GET /api/energy/costs/summary` | 区间汇总(计量电费 + 固定费 − 抵扣)|
|
||||
| `POST /api/energy/costs/recompute` | 幂等重算 |
|
||||
| `GET /api/energy/dsmr/latest` | 最新 `dsmr_reading` |
|
||||
| `POST /api/energy/tibber/test` | 试连 Tibber + 拉当前价,三态 |
|
||||
|
||||
DSMR/Tibber 标量配置复用现有 `GET/PUT /api/config`(新增 `dsmr_ingest_enabled`、`dsmr_mqtt_topic`、`dsmr_sample_interval_s`、`tibber_api_token`(secret)、`tibber_home_id`)。
|
||||
|
||||
### 前端视图(M6 新增,并入 Energy 视图)
|
||||
|
||||
- **Contracts Tab**:合同列表 + 新建/编辑(表单按 `/api/energy/profiles` 结构渲染,不 hardcode 字段)+ 激活 + 改价加版本 + 版本历史只读。
|
||||
- **Prices Tab**:15 分钟价格曲线(tibber 动态或 manual 档位),复用 Recharts。
|
||||
- **Costs Tab**:费用走势/明细 + 汇总卡片(含固定费/抵扣)。
|
||||
- **Config 页 Tibber 测试**:三态(success/config-error/failed)。
|
||||
|
||||
## Config 持久化
|
||||
|
||||
@@ -194,11 +445,89 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
这意味着:
|
||||
|
||||
- location / poo / app DB 地址仍然属于 bootstrap 范畴
|
||||
- app DB 地址(`APP_DATABASE_URL`)仍然属于 bootstrap 范畴
|
||||
- 运行时可编辑配置主要通过 `app_config` 表持久化
|
||||
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
|
||||
- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
|
||||
|
||||
当前已经接入 config 页面的运行时配置包括:
|
||||
|
||||
- 基础系统配置
|
||||
- auth cookie 相关配置
|
||||
- SMTP 基础配置
|
||||
- TickTick OAuth 配置
|
||||
- Home Assistant 配置
|
||||
- MQTT broker 配置(`MQTT_ENABLED`、`MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD`、`MQTT_TLS_ENABLED`)
|
||||
- Home Assistant Discovery 配置(`HA_DISCOVERY_ENABLED`、`HA_DISCOVERY_PREFIX`)
|
||||
- Modbus 采集配置(`MODBUS_POLLING_ENABLED`)
|
||||
- DSMR 接入配置(`DSMR_INGEST_ENABLED`、`DSMR_MQTT_TOPIC`、`DSMR_SAMPLE_INTERVAL_S`)
|
||||
- Tibber 凭据(`TIBBER_API_TOKEN`(secret)、`TIBBER_HOME_ID`)
|
||||
|
||||
其中 SMTP password 与其他 secret 字段一致:
|
||||
|
||||
- 页面不明文回显
|
||||
- 留空提交时保留旧值
|
||||
- 用于测试发信与自动通知时不会写入响应
|
||||
|
||||
## Public IPv4 Monitor
|
||||
|
||||
当前系统已经提供最小可用的 public IPv4 monitor:
|
||||
|
||||
- 使用单一 provider 检查当前公网 IPv4
|
||||
- 将状态与变化历史持久化到 app DB
|
||||
- 提供受保护的手动检查入口:`GET /public-ip/check`
|
||||
- 启动时注册 APScheduler job,默认每 4 小时检查一次
|
||||
|
||||
当前 app DB 中与此功能相关的新表:
|
||||
|
||||
- `public_ip_state`
|
||||
- `public_ip_history`
|
||||
|
||||
状态语义如下:
|
||||
|
||||
- `first_seen`:首次发现当前公网 IPv4
|
||||
- `unchanged`:与上次状态一致
|
||||
- `changed`:公网 IPv4 发生变化
|
||||
- `error`:provider 请求失败或返回无效值
|
||||
|
||||
## SMTP 与邮件通知
|
||||
|
||||
当前系统已经提供最小可用的 SMTP 能力:
|
||||
|
||||
- SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`)
|
||||
- 可通过 config 页面发送测试邮件(`POST /api/config/smtp/test`)
|
||||
- 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>`
|
||||
|
||||
当前 SMTP 配置项包括:
|
||||
|
||||
- `SMTP_ENABLED`
|
||||
- `SMTP_HOST`
|
||||
- `SMTP_PORT`
|
||||
- `SMTP_USERNAME`
|
||||
- `SMTP_PASSWORD`
|
||||
- `SMTP_FROM_NAME`
|
||||
- `SMTP_FROM_ADDRESS`
|
||||
- `SMTP_TO_ADDRESS`
|
||||
- `SMTP_USE_STARTTLS`
|
||||
|
||||
当前 public IPv4 monitor 已与 SMTP sender 接通,但只处理一个很小的通知场景:
|
||||
|
||||
- 当 public IPv4 check 结果为 `changed` 时,自动发送一封英文纯文本邮件
|
||||
|
||||
以下情况不会发邮件:
|
||||
|
||||
- `first_seen`
|
||||
- `unchanged`
|
||||
- `error`
|
||||
|
||||
当前通知邮件内容固定,不提供模板系统,正文会包含:
|
||||
|
||||
- previous IP
|
||||
- current IP
|
||||
- detected time
|
||||
|
||||
手动测试时,如果需要再次模拟一次 IP 变化,可以临时修改 `public_ip_state.current_ipv4` 为一个保留测试地址,然后再次调用 `GET /public-ip/check`。
|
||||
|
||||
## OpenAPI
|
||||
|
||||
可使用下面的脚本重新导出当前 API 定义:
|
||||
@@ -218,18 +547,20 @@ python scripts/export_openapi.py
|
||||
|
||||
当前 Compose 分成两层:
|
||||
|
||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取
|
||||
- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
|
||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
|
||||
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
|
||||
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
|
||||
|
||||
本地开发启动方式:
|
||||
本地开发启动方式(显式叠加 dev 层):
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。
|
||||
dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
|
||||
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
|
||||
|
||||
如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件:
|
||||
如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml pull
|
||||
@@ -286,9 +617,16 @@ pytest
|
||||
|
||||
当前测试包含:
|
||||
|
||||
- app 基本启动测试
|
||||
- `/status` endpoint 测试
|
||||
- 登录 / session 基础流程测试
|
||||
- app 启动与 `/status` 检查
|
||||
- 登录 / session / 鉴权流程
|
||||
- runtime config 读写
|
||||
- public IPv4 monitor
|
||||
- SMTP 配置与测试发信
|
||||
- location / poo recorder 端点
|
||||
- Home Assistant inbound 集成
|
||||
- TickTick OAuth
|
||||
- 部署与迁移(`run_migrations`)
|
||||
- legacy 数据迁移脚本(`migrate_legacy_data`)
|
||||
|
||||
## OpenAPI 导出
|
||||
|
||||
|
||||
+16
-3
@@ -3,10 +3,23 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
from app.models.config import AppConfigEntry # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser, RecoveryCode # noqa: F401
|
||||
from app.models.auth_throttle import LoginThrottle # noqa: F401
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||
from app.models.location import Location # noqa: F401
|
||||
from app.models.poo import PooRecord # noqa: F401
|
||||
from app.models.modbus import ModbusDevice, ModbusReading # noqa: F401
|
||||
from app.models.expose import ExposedEntityToggle # noqa: F401
|
||||
from app.models.energy import ( # noqa: F401
|
||||
DsmrReading,
|
||||
EnergyContract,
|
||||
EnergyContractVersion,
|
||||
TibberPrice,
|
||||
EnergyCostPeriod,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -18,7 +31,7 @@ configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/app.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.app_database_url)
|
||||
|
||||
target_metadata = AuthBase.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""public ip monitor tables
|
||||
|
||||
Revision ID: 20260429_05_public_ip_monitor
|
||||
Revises: 20260420_04_app_config_table
|
||||
Create Date: 2026-04-29 00:00:01.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260429_05_public_ip_monitor"
|
||||
down_revision: Union[str, None] = "20260420_04_app_config_table"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"public_ip_history",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("ipv4", sa.String(length=45), nullable=False),
|
||||
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("change_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("provider", sa.String(length=64), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_public_ip_history_observed_at",
|
||||
"public_ip_history",
|
||||
["observed_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"public_ip_state",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("current_ipv4", sa.String(length=45), nullable=False),
|
||||
sa.Column("previous_ipv4", sa.String(length=45), nullable=True),
|
||||
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_changed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_check_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("last_check_error", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_provider", sa.String(length=64), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("public_ip_state")
|
||||
op.drop_index("ix_public_ip_history_observed_at", table_name="public_ip_history")
|
||||
op.drop_table("public_ip_history")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""merge location and poo_records tables into app chain
|
||||
|
||||
Revision ID: 20260611_06_merge_location_poo_tables
|
||||
Revises: 20260429_05_public_ip_monitor
|
||||
Create Date: 2026-06-11 00:00:01.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260611_06_merge_location_poo_tables"
|
||||
down_revision: Union[str, None] = "20260429_05_public_ip_monitor"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"location",
|
||||
sa.Column("person", sa.Text(), nullable=False),
|
||||
sa.Column("datetime", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.REAL(), nullable=False),
|
||||
sa.Column("longitude", sa.REAL(), nullable=False),
|
||||
sa.Column("altitude", sa.REAL(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("person", "datetime"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"poo_records",
|
||||
sa.Column("timestamp", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.REAL(), nullable=False),
|
||||
sa.Column("longitude", sa.REAL(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("timestamp"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("poo_records")
|
||||
op.drop_table("location")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add auth_login_throttle table for exponential back-off throttling
|
||||
|
||||
Revision ID: 20260621_07_auth_login_throttle
|
||||
Revises: 20260611_06_merge_location_poo_tables
|
||||
Create Date: 2026-06-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260621_07_auth_login_throttle"
|
||||
down_revision: Union[str, None] = "20260611_06_merge_location_poo_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"auth_login_throttle",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("key", sa.String(length=255), nullable=False),
|
||||
sa.Column("scope", sa.String(length=16), nullable=False),
|
||||
sa.Column("failures", sa.Integer(), nullable=False),
|
||||
sa.Column("first_failed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_failed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("next_allowed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_auth_login_throttle_scope_key",
|
||||
"auth_login_throttle",
|
||||
["scope", "key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_auth_login_throttle_scope_key", table_name="auth_login_throttle")
|
||||
op.drop_table("auth_login_throttle")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""add TOTP fields to auth_users and create auth_recovery_code table
|
||||
|
||||
Revision ID: 20260621_08_totp
|
||||
Revises: 20260621_07_auth_login_throttle
|
||||
Create Date: 2026-06-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260621_08_totp"
|
||||
down_revision: Union[str, None] = "20260621_07_auth_login_throttle"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add totp_secret (nullable) to auth_users — existing rows get NULL, which is correct.
|
||||
op.add_column("auth_users", sa.Column("totp_secret", sa.String(length=64), nullable=True))
|
||||
# Add totp_enabled (NOT NULL) with server_default="0" so existing rows default to false.
|
||||
op.add_column(
|
||||
"auth_users",
|
||||
sa.Column(
|
||||
"totp_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
|
||||
# Create auth_recovery_code table for one-time TOTP recovery codes.
|
||||
op.create_table(
|
||||
"auth_recovery_code",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("code_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_auth_recovery_code_user_id"),
|
||||
"auth_recovery_code",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop auth_recovery_code table first (has FK to auth_users).
|
||||
op.drop_index(op.f("ix_auth_recovery_code_user_id"), table_name="auth_recovery_code")
|
||||
op.drop_table("auth_recovery_code")
|
||||
|
||||
# Drop the two TOTP columns from auth_users.
|
||||
# Use batch_alter_table for SQLite compatibility (alembic's portable column-drop path).
|
||||
with op.batch_alter_table("auth_users") as batch_op:
|
||||
batch_op.drop_column("totp_enabled")
|
||||
batch_op.drop_column("totp_secret")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add modbus_device and modbus_reading tables
|
||||
|
||||
Revision ID: 20260622_09_modbus_tables
|
||||
Revises: 20260621_08_totp
|
||||
Create Date: 2026-06-22 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260622_09_modbus_tables"
|
||||
down_revision: Union[str, None] = "20260621_08_totp"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# modbus_device — deployment/configurable metadata for each polled device.
|
||||
op.create_table(
|
||||
"modbus_device",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("uuid", sa.String(length=36), nullable=False),
|
||||
sa.Column("friendly_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("transport", sa.String(length=16), nullable=False),
|
||||
sa.Column("host", sa.String(length=255), nullable=False),
|
||||
sa.Column("port", sa.Integer(), nullable=False),
|
||||
sa.Column("unit_id", sa.Integer(), nullable=False),
|
||||
sa.Column("profile", sa.String(length=64), nullable=False),
|
||||
sa.Column("poll_interval_s", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("last_poll_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_poll_ok", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("uuid", name="uq_modbus_device_uuid"),
|
||||
)
|
||||
|
||||
# modbus_reading — generic telemetry, one row per device per poll cycle.
|
||||
op.create_table(
|
||||
"modbus_reading",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("device_id", sa.Integer(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["device_id"],
|
||||
["modbus_device.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# Individual index on recorded_at (from the ORM-level index=True).
|
||||
op.create_index(
|
||||
"ix_modbus_reading_recorded_at",
|
||||
"modbus_reading",
|
||||
["recorded_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# Composite index for efficient time-range queries per device.
|
||||
op.create_index(
|
||||
"ix_modbus_reading_device_recorded",
|
||||
"modbus_reading",
|
||||
["device_id", "recorded_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop the reading table first (it has a FK referencing modbus_device).
|
||||
op.drop_index("ix_modbus_reading_device_recorded", table_name="modbus_reading")
|
||||
op.drop_index("ix_modbus_reading_recorded_at", table_name="modbus_reading")
|
||||
op.drop_table("modbus_reading")
|
||||
|
||||
# Drop the device table.
|
||||
op.drop_table("modbus_device")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add exposed_entity_toggle table
|
||||
|
||||
Revision ID: 20260622_10_exposed_entities
|
||||
Revises: 20260622_09_modbus_tables
|
||||
Create Date: 2026-06-22 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260622_10_exposed_entities"
|
||||
down_revision: Union[str, None] = "20260622_09_modbus_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# exposed_entity_toggle — per-entity on/off switch for MQTT / HA Discovery.
|
||||
op.create_table(
|
||||
"exposed_entity_toggle",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("key", sa.String(length=255), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key", name="uq_exposed_entity_toggle_key"),
|
||||
)
|
||||
|
||||
# Index on key for fast single-key lookups (toggle by key).
|
||||
op.create_index(
|
||||
"ix_exposed_entity_toggle_key",
|
||||
"exposed_entity_toggle",
|
||||
["key"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index then table — only removes what this revision created.
|
||||
op.drop_index("ix_exposed_entity_toggle_key", table_name="exposed_entity_toggle")
|
||||
op.drop_table("exposed_entity_toggle")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""add energy pricing and DSMR metering tables
|
||||
|
||||
Revision ID: 20260623_11_energy_tables
|
||||
Revises: 20260622_10_exposed_entities
|
||||
Create Date: 2026-06-23 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260623_11_energy_tables"
|
||||
down_revision: Union[str, None] = "20260622_10_exposed_entities"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# dsmr_reading — raw DSMR telegram blobs (10-second down-sampled).
|
||||
# No device table: single P1 smart meter; a ``source`` column can be added later
|
||||
# if a second meter is introduced.
|
||||
op.create_table(
|
||||
"dsmr_reading",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("source_id", sa.Integer(), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("source_id", name="uq_dsmr_reading_source_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_dsmr_reading_recorded_at",
|
||||
"dsmr_reading",
|
||||
["recorded_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# energy_contract — contract head (manual or tibber, one active at a time).
|
||||
op.create_table(
|
||||
"energy_contract",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# energy_contract_version — versioned pricing values; append-only for auditability.
|
||||
# Must be created after energy_contract because of the FK dependency.
|
||||
op.create_table(
|
||||
"energy_contract_version",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("contract_id", sa.Integer(), nullable=False),
|
||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("values", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contract_id"],
|
||||
["energy_contract.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# tibber_price — cached Tibber 15-minute spot prices (immutable once fetched).
|
||||
op.create_table(
|
||||
"tibber_price",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("resolution", sa.String(length=32), nullable=False),
|
||||
sa.Column("energy", sa.Float(), nullable=False),
|
||||
sa.Column("tax", sa.Float(), nullable=False),
|
||||
sa.Column("total", sa.Float(), nullable=False),
|
||||
sa.Column("level", sa.String(length=32), nullable=True),
|
||||
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("starts_at", name="uq_tibber_price_starts_at"),
|
||||
)
|
||||
|
||||
# energy_cost_period — computed 15-minute billing periods (immutable snapshot).
|
||||
# Must be created after energy_contract_version because of the FK dependency.
|
||||
op.create_table(
|
||||
"energy_cost_period",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("d1_kwh", sa.Float(), nullable=False),
|
||||
sa.Column("d2_kwh", sa.Float(), nullable=False),
|
||||
sa.Column("r1_kwh", sa.Float(), nullable=False),
|
||||
sa.Column("r2_kwh", sa.Float(), nullable=False),
|
||||
sa.Column("import_cost", sa.Float(), nullable=False),
|
||||
sa.Column("export_revenue", sa.Float(), nullable=False),
|
||||
sa.Column("net_cost", sa.Float(), nullable=False),
|
||||
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||
sa.Column("pricing", sa.JSON(), nullable=False),
|
||||
sa.Column("contract_version_id", sa.Integer(), nullable=True),
|
||||
sa.Column("degraded", sa.Boolean(), nullable=False),
|
||||
sa.Column("computed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contract_version_id"],
|
||||
["energy_contract_version.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("period_start", name="uq_energy_cost_period_period_start"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse dependency order: tables with FKs first.
|
||||
|
||||
# energy_cost_period has a FK to energy_contract_version — drop it first.
|
||||
op.drop_table("energy_cost_period")
|
||||
|
||||
# tibber_price has no FK dependencies on our new tables.
|
||||
op.drop_table("tibber_price")
|
||||
|
||||
# energy_contract_version has a FK to energy_contract — drop it before energy_contract.
|
||||
op.drop_table("energy_contract_version")
|
||||
|
||||
# energy_contract has no FK dependencies on our new tables.
|
||||
op.drop_table("energy_contract")
|
||||
|
||||
# dsmr_reading has no FK dependencies.
|
||||
op.drop_index("ix_dsmr_reading_recorded_at", table_name="dsmr_reading")
|
||||
op.drop_table("dsmr_reading")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""decouple dsmr_reading from the telegram id
|
||||
|
||||
The DSMR Reader's own ``id`` field is auto-incrementing but is known to overflow
|
||||
and require a manual reset to zero (a long-standing DSMR firmware quirk). If we
|
||||
keep a UNIQUE constraint on ``source_id`` (the telegram id) and use it for
|
||||
idempotency, an overflow/reset would make legitimately-new telegrams collide with
|
||||
old ids and be silently dropped as "duplicates" — data loss.
|
||||
|
||||
This migration removes that coupling:
|
||||
|
||||
- Drops the UNIQUE constraint on ``source_id`` (the column is kept as a plain,
|
||||
nullable reference value; it is no longer relied upon for uniqueness/dedup).
|
||||
- Makes ``recorded_at`` (the telegram timestamp) UNIQUE instead — a single P1
|
||||
meter emits one telegram per timestamp, so this is a robust, telegram-id-
|
||||
independent idempotency key. The table's own autoincrement ``id`` PK remains
|
||||
the stable internal identity.
|
||||
|
||||
Revision ID: 20260624_12_dsmr_decouple_telegram_id
|
||||
Revises: 20260623_11_energy_tables
|
||||
Create Date: 2026-06-24 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260624_12_dsmr_decouple_telegram_id"
|
||||
down_revision: Union[str, None] = "20260623_11_energy_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite cannot ALTER away a constraint in place, so recreate the table via
|
||||
# Alembic's batch mode. The table is recreated and rows are copied; existing
|
||||
# data (if any) is preserved. recorded_at must be unique for this to succeed
|
||||
# — a single meter never emits two telegrams at the exact same timestamp.
|
||||
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
|
||||
# Old non-unique index on recorded_at is replaced by the unique constraint.
|
||||
batch_op.drop_index("ix_dsmr_reading_recorded_at")
|
||||
# Telegram id is no longer a uniqueness/idempotency key.
|
||||
batch_op.drop_constraint("uq_dsmr_reading_source_id", type_="unique")
|
||||
# Timestamp becomes the telegram-id-independent dedup key.
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_dsmr_reading_recorded_at", ["recorded_at"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_dsmr_reading_recorded_at", type_="unique")
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_dsmr_reading_source_id", ["source_id"]
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_dsmr_reading_recorded_at", ["recorded_at"], unique=False
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic_location
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url = sqlite:///./data/locationRecorder.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -1,2 +0,0 @@
|
||||
This directory contains the Alembic migration environment for the Python rewrite skeleton.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Location # noqa: F401
|
||||
from app.models.base import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
settings = get_settings()
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.location_database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""location baseline
|
||||
|
||||
Revision ID: 20260419_01_location_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260419_01_location_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"location",
|
||||
sa.Column("person", sa.Text(), nullable=False),
|
||||
sa.Column("datetime", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.Float(), nullable=False),
|
||||
sa.Column("longitude", sa.Float(), nullable=False),
|
||||
sa.Column("altitude", sa.Float(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("person", "datetime"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("location")
|
||||
@@ -1,37 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic_poo
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url = sqlite:///./data/pooRecorder.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -1,48 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.poo import PooRecord # noqa: F401
|
||||
from app.poo_db import PooBase
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
settings = get_settings()
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/pooRecorder.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.poo_database_url)
|
||||
|
||||
target_metadata = PooBase.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,32 +0,0 @@
|
||||
"""poo baseline
|
||||
|
||||
Revision ID: 20260420_01_poo_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260420_01_poo_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"poo_records",
|
||||
sa.Column("timestamp", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.Float(), nullable=False),
|
||||
sa.Column("longitude", sa.Float(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("timestamp"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("poo_records")
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.integrations.mqtt import MQTT_SETTINGS_KEYS, mqtt_manager
|
||||
from app.schemas.config import (
|
||||
ConfigField,
|
||||
ConfigResponse,
|
||||
ConfigSection,
|
||||
ConfigUpdateRequest,
|
||||
ConfigUpdateResponse,
|
||||
MqttTestResponse,
|
||||
SmtpTestResponse,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
|
||||
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-config"])
|
||||
|
||||
|
||||
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
|
||||
result = []
|
||||
for section in sections_raw:
|
||||
fields = [ConfigField(**f) for f in section["fields"]]
|
||||
result.append(ConfigSection(name=section["name"], fields=fields))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/config", response_model=ConfigResponse)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ConfigResponse:
|
||||
"""Return all configuration sections. Secret field values are masked (empty string)."""
|
||||
sections_raw = build_config_sections(db, settings)
|
||||
return ConfigResponse(sections=_sections_from_raw(sections_raw))
|
||||
|
||||
|
||||
@router.put("/config", response_model=ConfigUpdateResponse)
|
||||
def put_config(
|
||||
body: ConfigUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ConfigUpdateResponse:
|
||||
"""
|
||||
Save configuration updates.
|
||||
|
||||
- Blank secret value keeps the existing stored value (no change).
|
||||
- Invalid values return 422 and nothing is written to the database.
|
||||
- If MQTT-related settings changed, the MQTT client reconnects automatically.
|
||||
"""
|
||||
# Detect whether any MQTT-related key is being submitted (non-secret change
|
||||
# or non-blank secret change) so we know to reconnect after saving.
|
||||
mqtt_keys_submitted = any(k.lower() in MQTT_SETTINGS_KEYS for k in body.updates)
|
||||
|
||||
try:
|
||||
save_config_updates(db, body.updates, settings)
|
||||
except ConfigSaveError as exc:
|
||||
logger.warning("Rejected config update via API: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="invalid config submission",
|
||||
) from exc
|
||||
|
||||
# Re-read settings after save (save_config_updates clears the settings cache).
|
||||
# Use build_runtime_settings so the reconnect picks up DB-stored values, not
|
||||
# just the bootstrap env (otherwise a broker configured via the UI is ignored).
|
||||
from app.services.config_page import build_runtime_settings
|
||||
refreshed_settings = build_runtime_settings(db, get_settings())
|
||||
|
||||
# Reconnect MQTT client if any MQTT setting was updated.
|
||||
if mqtt_keys_submitted:
|
||||
logger.info("MQTT settings changed — triggering reconnect.")
|
||||
mqtt_manager.reconnect(refreshed_settings)
|
||||
|
||||
# Re-apply the DSMR subscription so enabling/disabling DSMR ingest (or changing
|
||||
# its topic / sample interval) takes effect immediately, without an app restart.
|
||||
# Done after any MQTT reconnect so it operates on the current client.
|
||||
from app.services.dsmr_ingest import apply_dsmr_subscription
|
||||
apply_dsmr_subscription(refreshed_settings)
|
||||
|
||||
sections_raw = build_config_sections(db, refreshed_settings)
|
||||
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/config/smtp/test",
|
||||
responses={
|
||||
200: {"model": SmtpTestResponse},
|
||||
400: {"model": SmtpTestResponse},
|
||||
502: {"model": SmtpTestResponse},
|
||||
},
|
||||
)
|
||||
def post_smtp_test(
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Send a test SMTP email using the current runtime settings.
|
||||
|
||||
Returns a structured result indicating success or the category of failure.
|
||||
Three possible outcomes:
|
||||
- 200 { "result": "success", "message": ... }
|
||||
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
|
||||
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
|
||||
|
||||
SMTP credentials are never echoed in the response.
|
||||
"""
|
||||
try:
|
||||
send_smtp_test_email(settings)
|
||||
except EmailConfigurationError as exc:
|
||||
logger.warning("SMTP test rejected due to configuration: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"result": "config-error", "message": str(exc)},
|
||||
)
|
||||
except EmailDeliveryError as exc:
|
||||
logger.warning("SMTP test delivery failed: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={"result": "failed", "message": str(exc)},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={"result": "success", "message": "Test email sent successfully."},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/mqtt/test — M5-T10
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MqttConfigurationError(ValueError):
|
||||
"""Raised when MQTT settings are incomplete or disabled."""
|
||||
|
||||
|
||||
class _MqttConnectionError(RuntimeError):
|
||||
"""Raised when MQTT broker connection or publish fails."""
|
||||
|
||||
|
||||
@router.post(
|
||||
"/config/mqtt/test",
|
||||
responses={
|
||||
200: {"model": MqttTestResponse},
|
||||
400: {"model": MqttTestResponse},
|
||||
502: {"model": MqttTestResponse},
|
||||
},
|
||||
)
|
||||
def post_mqtt_test(
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Test MQTT broker connectivity by attempting to connect and publishing a
|
||||
test message to ``<ha_discovery_prefix>/home-automation/test``.
|
||||
|
||||
The message is visible in MQTT Explorer (or any subscriber) so users can
|
||||
confirm the full broker publish path is working.
|
||||
|
||||
Three possible outcomes:
|
||||
- 200 { "result": "success", "message": ... }
|
||||
- 400 { "result": "config-error", "message": ... } (not configured)
|
||||
- 502 { "result": "failed", "message": ... } (connection/publish error)
|
||||
|
||||
MQTT credentials are never echoed in the response.
|
||||
"""
|
||||
try:
|
||||
_run_mqtt_test(settings)
|
||||
except _MqttConfigurationError as exc:
|
||||
logger.warning("MQTT test rejected due to configuration: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"result": "config-error", "message": str(exc)},
|
||||
)
|
||||
except _MqttConnectionError as exc:
|
||||
logger.warning("MQTT test connection/publish failed: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={"result": "failed", "message": str(exc)},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"result": "success",
|
||||
"message": (
|
||||
f"Test message published to "
|
||||
f"{settings.ha_discovery_prefix}/home-automation/test."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _run_mqtt_test(settings: Settings) -> None:
|
||||
"""Attempt a transient MQTT connection and publish a test message.
|
||||
|
||||
Raises
|
||||
------
|
||||
_MqttConfigurationError
|
||||
When MQTT is not enabled or broker host is not configured.
|
||||
_MqttConnectionError
|
||||
When the broker is unreachable or the publish fails.
|
||||
"""
|
||||
import socket
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
if not settings.mqtt_broker_host:
|
||||
raise _MqttConfigurationError("MQTT broker host is not configured.")
|
||||
|
||||
test_topic = f"{settings.ha_discovery_prefix}/home-automation/test"
|
||||
test_payload = '{"source": "home-automation", "event": "mqtt_test"}'
|
||||
|
||||
connected_event = __import__("threading").Event()
|
||||
published_event = __import__("threading").Event()
|
||||
connect_error: list[str] = []
|
||||
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id="home-automation-test",
|
||||
)
|
||||
|
||||
def _on_connect(
|
||||
_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
connect_error.append(f"Broker refused connection: {reason_code}")
|
||||
connected_event.set()
|
||||
|
||||
def _on_publish(
|
||||
_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_mid: int,
|
||||
_reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None,
|
||||
) -> None:
|
||||
published_event.set()
|
||||
|
||||
client.on_connect = _on_connect
|
||||
client.on_publish = _on_publish
|
||||
|
||||
if settings.mqtt_tls_enabled:
|
||||
try:
|
||||
client.tls_set()
|
||||
except Exception as exc:
|
||||
raise _MqttConnectionError(f"TLS setup failed: {exc}") from exc
|
||||
|
||||
if settings.mqtt_username:
|
||||
client.username_pw_set(
|
||||
username=settings.mqtt_username,
|
||||
password=settings.mqtt_password or None,
|
||||
)
|
||||
|
||||
client.loop_start()
|
||||
try:
|
||||
try:
|
||||
client.connect(
|
||||
host=settings.mqtt_broker_host,
|
||||
port=settings.mqtt_broker_port,
|
||||
keepalive=10,
|
||||
)
|
||||
except (OSError, socket.error) as exc:
|
||||
# Sanitise: ensure password never leaks into the error message.
|
||||
msg = _sanitize_mqtt_error(str(exc), settings.mqtt_password)
|
||||
raise _MqttConnectionError(f"Cannot reach broker: {msg}") from exc
|
||||
|
||||
# Wait up to 5 s for connection acknowledgement.
|
||||
if not connected_event.wait(timeout=5):
|
||||
raise _MqttConnectionError("Broker connection timed out (5 s).")
|
||||
|
||||
if connect_error:
|
||||
raise _MqttConnectionError(connect_error[0])
|
||||
|
||||
# Publish test message.
|
||||
client.publish(test_topic, payload=test_payload, qos=1, retain=False)
|
||||
# Wait up to 5 s for the publish ACK (QoS 1).
|
||||
if not published_event.wait(timeout=5):
|
||||
raise _MqttConnectionError("Publish timed out (5 s) — broker reachable but no ACK.")
|
||||
|
||||
finally:
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(message: str, password: str | None) -> str:
|
||||
"""Replace *password* in *message* with ``[redacted]``."""
|
||||
if password:
|
||||
return message.replace(password, "[redacted]")
|
||||
return message
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_db
|
||||
from app.models.location import Location
|
||||
from app.models.poo import PooRecord
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
from app.schemas.data import (
|
||||
LocationRecord,
|
||||
LocationUpdateRequest,
|
||||
LocationsResponse,
|
||||
PooRecord as PooRecordSchema,
|
||||
PooResponse,
|
||||
PooUpdateRequest,
|
||||
PublicIPHistorySchema,
|
||||
PublicIPResponse,
|
||||
PublicIPStateSchema,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.location import delete_location, update_location
|
||||
from app.services.poo import delete_poo_record, update_poo_record
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-data"])
|
||||
|
||||
|
||||
@router.get("/locations", response_model=LocationsResponse)
|
||||
def get_locations(
|
||||
limit: int = Query(default=1000, ge=1, le=5000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
start: str | None = Query(default=None),
|
||||
end: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> LocationsResponse:
|
||||
"""
|
||||
Return location records with optional time-window filtering and pagination.
|
||||
|
||||
- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.
|
||||
- Results are ordered by ``datetime`` ascending.
|
||||
- ``limit`` is capped at 5000 to prevent full-table exports.
|
||||
"""
|
||||
stmt = select(Location)
|
||||
|
||||
if start is not None:
|
||||
stmt = stmt.where(Location.datetime >= start)
|
||||
if end is not None:
|
||||
stmt = stmt.where(Location.datetime <= end)
|
||||
|
||||
stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit)
|
||||
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
|
||||
items = [
|
||||
LocationRecord(
|
||||
person=row.person,
|
||||
datetime=row.datetime,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
altitude=row.altitude,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return LocationsResponse(items=items, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/poo", response_model=PooResponse)
|
||||
def get_poo(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> PooResponse:
|
||||
"""
|
||||
Return poo records ordered by timestamp descending (most recent first).
|
||||
|
||||
``limit`` is capped at 1000 to prevent full-table exports.
|
||||
"""
|
||||
stmt = (
|
||||
select(PooRecord)
|
||||
.order_by(desc(PooRecord.timestamp))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
|
||||
items = [
|
||||
PooRecordSchema(
|
||||
timestamp=row.timestamp,
|
||||
status=row.status,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return PooResponse(items=items, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/public-ip", response_model=PublicIPResponse)
|
||||
def get_public_ip(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> PublicIPResponse:
|
||||
"""
|
||||
Return the current public IP state and recent history.
|
||||
|
||||
- ``state`` is ``null`` if no IP check has been performed yet.
|
||||
- ``history`` is ordered by ``observed_at`` descending (most recent first).
|
||||
- ``limit`` applies to the history list and is capped at 1000.
|
||||
"""
|
||||
state_row = db.execute(
|
||||
select(PublicIPState).where(PublicIPState.id == 1).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
history_rows = db.execute(
|
||||
select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None
|
||||
history = [PublicIPHistorySchema.model_validate(row) for row in history_rows]
|
||||
|
||||
return PublicIPResponse(state=state, history=history)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/locations/{person}/{datetime}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/locations/{person}/{datetime}", response_model=LocationRecord)
|
||||
def patch_location(
|
||||
person: str,
|
||||
datetime: str,
|
||||
body: LocationUpdateRequest = Body(default=LocationUpdateRequest()),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> LocationRecord:
|
||||
"""
|
||||
Update the non-PK fields of a single location record.
|
||||
|
||||
- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.
|
||||
- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.
|
||||
- Omitted body fields are left unchanged.
|
||||
- Returns **404** if the PK does not exist.
|
||||
"""
|
||||
row = update_location(
|
||||
db,
|
||||
person,
|
||||
datetime,
|
||||
latitude=body.latitude,
|
||||
longitude=body.longitude,
|
||||
altitude=body.altitude,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="location record not found",
|
||||
)
|
||||
return LocationRecord(
|
||||
person=row.person,
|
||||
datetime=row.datetime,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
altitude=row.altitude,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/locations/{person}/{datetime}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/locations/{person}/{datetime}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def delete_location_record(
|
||||
person: str,
|
||||
datetime: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> None:
|
||||
"""
|
||||
Delete the single location record identified by its composite PK.
|
||||
|
||||
- Exactly one row is deleted; **404** if the PK does not exist.
|
||||
- No batch delete / truncate path is available.
|
||||
"""
|
||||
deleted = delete_location(db, person, datetime)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="location record not found",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/poo/{timestamp}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/poo/{timestamp}", response_model=PooRecordSchema)
|
||||
def patch_poo(
|
||||
timestamp: str,
|
||||
body: PooUpdateRequest = Body(default=PooUpdateRequest()),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> PooRecordSchema:
|
||||
"""
|
||||
Update the non-PK fields of a single poo record.
|
||||
|
||||
- ``timestamp`` is the PK and is immutable.
|
||||
- Only ``status``, ``latitude``, and ``longitude`` may be updated.
|
||||
- Omitted body fields are left unchanged.
|
||||
- Returns **404** if the PK does not exist.
|
||||
"""
|
||||
row = update_poo_record(
|
||||
db,
|
||||
timestamp,
|
||||
status=body.status,
|
||||
latitude=body.latitude,
|
||||
longitude=body.longitude,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="poo record not found",
|
||||
)
|
||||
return PooRecordSchema(
|
||||
timestamp=row.timestamp,
|
||||
status=row.status,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/poo/{timestamp}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/poo/{timestamp}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def delete_poo(
|
||||
timestamp: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> None:
|
||||
"""
|
||||
Delete the single poo record identified by its PK.
|
||||
|
||||
- Exactly one row is deleted; **404** if the PK does not exist.
|
||||
- No batch delete / truncate path is available.
|
||||
"""
|
||||
deleted = delete_poo_record(db, timestamp)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="poo record not found",
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.dependencies import get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
|
||||
def require_session(
|
||||
auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> AuthenticatedSession:
|
||||
if auth is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="authentication required",
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
def require_csrf(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
|
||||
) -> None:
|
||||
if not x_csrf_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="missing CSRF token",
|
||||
)
|
||||
@@ -0,0 +1,584 @@
|
||||
"""Energy data API: prices, costs, summary, DSMR, recompute, Tibber test (M6-T09).
|
||||
|
||||
All endpoints are under /api/energy, require an authenticated session, and
|
||||
write endpoints (POST) additionally require a non-empty X-CSRF-Token header.
|
||||
|
||||
Route prefix note
|
||||
-----------------
|
||||
This router shares the ``/api/energy`` prefix with ``energy_contracts.py``
|
||||
(which handles contract CRUD at /contracts/* and /profiles). The sub-paths
|
||||
used here (/prices, /costs, /costs/summary, /costs/recompute, /dsmr/latest,
|
||||
/tibber/test) are disjoint from the contract router's paths, so there is no
|
||||
conflict.
|
||||
|
||||
Tibber token security
|
||||
---------------------
|
||||
``POST /api/energy/tibber/test`` calls the Tibber API but **never** echoes the
|
||||
token in the response body or in log messages. Three-state logic mirrors the
|
||||
MQTT test endpoint (M5-T10, app/api/routes/api/config.py::post_mqtt_test):
|
||||
|
||||
200 { result: "success", message: ..., price: {...} }
|
||||
400 { result: "config-error", message: ... }
|
||||
502 { result: "failed", message: ... }
|
||||
|
||||
Recompute safety
|
||||
----------------
|
||||
``POST /api/energy/costs/recompute`` is idempotent: it calls
|
||||
``energy_cost.recompute_range`` which upserts existing rows without deleting
|
||||
anything. The endpoint enforces a maximum time-window of 366 days to avoid
|
||||
unbounded recomputation triggered by erroneous client requests.
|
||||
|
||||
Prices endpoint behaviour
|
||||
-------------------------
|
||||
``GET /api/energy/prices`` queries the ``tibber_price`` table for tibber
|
||||
contracts, or derives the effective fixed-tariff prices for manual contracts
|
||||
using the same formula as the billing engine (_manual_strategy in strategies.py):
|
||||
|
||||
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
|
||||
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
|
||||
sell_dal = energy.sell.dal (no tax added to sell price)
|
||||
sell_normal = energy.sell.normal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.config import Settings
|
||||
from app.integrations.tibber.client import (
|
||||
TibberAuthError,
|
||||
TibberError,
|
||||
fetch_current_price,
|
||||
)
|
||||
from app.models.energy import DsmrReading, EnergyCostPeriod, TibberPrice
|
||||
from app.schemas.energy import (
|
||||
CostPeriodSchema,
|
||||
CostsResponse,
|
||||
DsmrLatestResponse,
|
||||
ManualTariffSchema,
|
||||
PricePointSchema,
|
||||
PricesResponse,
|
||||
RecomputeResponse,
|
||||
SummaryResponse,
|
||||
TibberTestPriceSchema,
|
||||
TibberTestResponse,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.contracts import active_contract_version_at
|
||||
from app.services.energy_cost import recompute_range, summarize
|
||||
from app.services.timezone import local_midnight_utc, local_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/energy", tags=["api-energy"])
|
||||
|
||||
# Maximum number of cost periods returned per request (mirrors modbus readings cap).
|
||||
_COSTS_LIMIT_MAX = 5000
|
||||
|
||||
# Maximum allowed time-window for recompute to prevent unbounded computation.
|
||||
_RECOMPUTE_MAX_DAYS = 366
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def _manual_tariff_from_values(values: dict[str, Any]) -> ManualTariffSchema:
|
||||
"""Derive the effective fixed tariff from a manual contract version's values dict.
|
||||
|
||||
Mirrors the _manual_strategy formula (strategies.py):
|
||||
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
|
||||
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
|
||||
sell_dal = energy.sell.dal (no tax added)
|
||||
sell_normal = energy.sell.normal
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
def _d(v: Any) -> Decimal:
|
||||
return Decimal(str(v or 0))
|
||||
|
||||
energy = values.get("energy", {})
|
||||
buy = energy.get("buy", {})
|
||||
sell = energy.get("sell", {})
|
||||
energy_tax = _d(energy.get("energy_tax", 0))
|
||||
ode = _d(energy.get("ode", 0))
|
||||
|
||||
buy_dal = _d(buy.get("dal", 0)) + energy_tax + ode
|
||||
buy_normal = _d(buy.get("normal", 0)) + energy_tax + ode
|
||||
sell_dal = _d(sell.get("dal", 0))
|
||||
sell_normal = _d(sell.get("normal", 0))
|
||||
|
||||
return ManualTariffSchema(
|
||||
buy_dal=float(buy_dal),
|
||||
buy_normal=float(buy_normal),
|
||||
sell_dal=float(sell_dal),
|
||||
sell_normal=float(sell_normal),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/prices", response_model=PricesResponse)
|
||||
def get_prices(
|
||||
start: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive start of the time window (ISO 8601). "
|
||||
"Defaults to the start of today UTC when omitted.",
|
||||
),
|
||||
end: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive end of the time window (ISO 8601). "
|
||||
"Defaults to the end of tomorrow UTC when omitted.",
|
||||
),
|
||||
limit: int = Query(
|
||||
default=500,
|
||||
ge=1,
|
||||
le=_COSTS_LIMIT_MAX,
|
||||
description="Maximum number of Tibber price points to return.",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> PricesResponse:
|
||||
"""Return the price curve for the active contract.
|
||||
|
||||
**Tibber contracts** (kind="tibber"):
|
||||
Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending
|
||||
by ``starts_at``. At most ``limit`` rows are returned (most recent first
|
||||
within the window, then reversed to ascending order — identical to the
|
||||
modbus readings pattern).
|
||||
|
||||
Response ``points`` carries per-slot:
|
||||
- ``buy = total`` (Tibber all-inclusive price)
|
||||
- ``sell = total − energy_tax − sell_adjust`` (from active version values)
|
||||
- ``level`` (Tibber price level, may be null)
|
||||
|
||||
``tariff`` is null.
|
||||
|
||||
**Manual contracts** (kind="manual"):
|
||||
``points`` is empty. ``tariff`` carries the four effective prices
|
||||
derived using the billing engine formula:
|
||||
- ``buy_dal = energy.buy.dal + energy_tax + ode``
|
||||
- ``buy_normal = energy.buy.normal + energy_tax + ode``
|
||||
- ``sell_dal = energy.sell.dal``
|
||||
- ``sell_normal = energy.sell.normal``
|
||||
|
||||
**No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200).
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Default window: today + tomorrow.
|
||||
if start is None:
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end is None:
|
||||
end = (start + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
|
||||
# Resolve the active contract version at the start of the window.
|
||||
version = active_contract_version_at(db, start_utc)
|
||||
|
||||
if version is None:
|
||||
return PricesResponse(
|
||||
kind=None,
|
||||
currency="EUR",
|
||||
points=[],
|
||||
tariff=None,
|
||||
)
|
||||
|
||||
contract = version.contract
|
||||
currency = contract.currency
|
||||
|
||||
if contract.kind == "tibber":
|
||||
# Fetch tibber_price rows in the window.
|
||||
stmt = (
|
||||
select(TibberPrice)
|
||||
.where(
|
||||
TibberPrice.starts_at >= start_utc,
|
||||
TibberPrice.starts_at <= end_utc,
|
||||
)
|
||||
.order_by(TibberPrice.starts_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||
|
||||
# Derive sell price per-point using version values (energy_tax + sell_adjust).
|
||||
from decimal import Decimal
|
||||
|
||||
def _d(v: Any) -> Decimal:
|
||||
return Decimal(str(v or 0))
|
||||
|
||||
energy = version.values.get("energy", {}) if version.values else {}
|
||||
energy_tax = _d(energy.get("energy_tax", 0))
|
||||
sell_adjust = _d(energy.get("sell_adjust", 0))
|
||||
|
||||
points = []
|
||||
for row in rows:
|
||||
total = _d(row.total)
|
||||
sell = float(total - energy_tax - sell_adjust)
|
||||
points.append(
|
||||
PricePointSchema(
|
||||
starts_at=_as_utc(row.starts_at),
|
||||
buy=row.total,
|
||||
sell=sell,
|
||||
level=row.level,
|
||||
)
|
||||
)
|
||||
|
||||
return PricesResponse(
|
||||
kind="tibber",
|
||||
currency=currency,
|
||||
points=points,
|
||||
tariff=None,
|
||||
)
|
||||
|
||||
elif contract.kind == "manual":
|
||||
tariff = _manual_tariff_from_values(version.values or {})
|
||||
return PricesResponse(
|
||||
kind="manual",
|
||||
currency=currency,
|
||||
points=[],
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
else:
|
||||
# Unknown kind — return empty response gracefully.
|
||||
return PricesResponse(
|
||||
kind=contract.kind,
|
||||
currency=currency,
|
||||
points=[],
|
||||
tariff=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/costs", response_model=CostsResponse)
|
||||
def get_costs(
|
||||
start: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive lower bound for period_start (ISO 8601).",
|
||||
),
|
||||
end: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive upper bound for period_start (ISO 8601).",
|
||||
),
|
||||
limit: int = Query(
|
||||
default=500,
|
||||
ge=1,
|
||||
le=_COSTS_LIMIT_MAX,
|
||||
description=f"Maximum number of cost periods to return (default 500, max {_COSTS_LIMIT_MAX}).",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> CostsResponse:
|
||||
"""Return energy_cost_period rows within a time window.
|
||||
|
||||
Rows are ordered by ``period_start`` ascending. When the window contains
|
||||
more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
|
||||
then reversed to ascending order — identical to the modbus readings pattern.
|
||||
|
||||
Query parameters:
|
||||
- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
|
||||
- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).
|
||||
- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).
|
||||
"""
|
||||
stmt = (
|
||||
select(EnergyCostPeriod)
|
||||
.order_by(EnergyCostPeriod.period_start.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if start is not None:
|
||||
stmt = stmt.where(EnergyCostPeriod.period_start >= _as_utc(start))
|
||||
if end is not None:
|
||||
stmt = stmt.where(EnergyCostPeriod.period_start <= _as_utc(end))
|
||||
|
||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||
items = [CostPeriodSchema.model_validate(r) for r in rows]
|
||||
return CostsResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/costs/summary", response_model=SummaryResponse)
|
||||
def get_costs_summary(
|
||||
start: datetime | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Inclusive start of the summary interval (ISO 8601). "
|
||||
"Defaults to the start of the current UTC day."
|
||||
),
|
||||
),
|
||||
end: datetime | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Exclusive end of the summary interval (ISO 8601). "
|
||||
"Defaults to the start of the next UTC day (i.e. today's full data)."
|
||||
),
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> SummaryResponse:
|
||||
"""Aggregate billing for a time interval.
|
||||
|
||||
Calls ``energy_cost.summarize(session, start, end)`` which computes:
|
||||
|
||||
total_payable = Σ(net_cost) + fixed_costs − credits
|
||||
|
||||
where ``fixed_costs`` is (network_fee + management_fee) apportioned to the
|
||||
interval length in days (÷30 per month), and ``credits`` is heffingskorting
|
||||
apportioned similarly (÷365 per year).
|
||||
|
||||
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
|
||||
contract version at ``end``**. When no active contract exists they are 0.
|
||||
"""
|
||||
if start is None or end is None:
|
||||
# Default to the server's local today: [local_midnight, next_local_midnight).
|
||||
# This ensures "today" aligns with the local calendar day (NL time) rather
|
||||
# than UTC midnight.
|
||||
local_today = local_now().date()
|
||||
if start is None:
|
||||
start = local_midnight_utc(local_today)
|
||||
if end is None:
|
||||
end = local_midnight_utc(local_today + timedelta(days=1))
|
||||
|
||||
result = summarize(db, _as_utc(start), _as_utc(end))
|
||||
return SummaryResponse(**result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/dsmr/latest", response_model=DsmrLatestResponse)
|
||||
def get_dsmr_latest(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> DsmrLatestResponse:
|
||||
"""Return the most recent dsmr_reading row.
|
||||
|
||||
Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
|
||||
404) when no rows exist yet, so the front-end can distinguish "no data" from
|
||||
a server error.
|
||||
"""
|
||||
row = db.execute(
|
||||
select(DsmrReading)
|
||||
.order_by(DsmrReading.recorded_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return DsmrLatestResponse(found=False)
|
||||
|
||||
return DsmrLatestResponse(
|
||||
found=True,
|
||||
recorded_at=_as_utc(row.recorded_at),
|
||||
payload=row.payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/costs/recompute",
|
||||
responses={
|
||||
200: {"model": RecomputeResponse},
|
||||
422: {"description": "Validation error (missing window or range too large)"},
|
||||
},
|
||||
)
|
||||
def post_recompute(
|
||||
start: datetime = Query(
|
||||
...,
|
||||
description="Inclusive start of the recompute window (ISO 8601). Required.",
|
||||
),
|
||||
end: datetime = Query(
|
||||
...,
|
||||
description="Exclusive end of the recompute window (ISO 8601). Required.",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> RecomputeResponse:
|
||||
"""Idempotently recompute billing records in a time window.
|
||||
|
||||
Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
|
||||
existing rows (including successful ones) for every UTC quarter-hour boundary
|
||||
in ``[start, end)``.
|
||||
|
||||
**Idempotency**: repeated calls with the same window produce the same
|
||||
outcome. No rows are deleted; only upserted.
|
||||
|
||||
**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.
|
||||
Requests exceeding this return 422.
|
||||
|
||||
Returns the number of periods for which a billing record was written.
|
||||
Periods skipped due to missing contract or missing Tibber price are not counted.
|
||||
"""
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
|
||||
if end_utc <= start_utc:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="'end' must be strictly after 'start'.",
|
||||
)
|
||||
|
||||
span_days = (end_utc - start_utc).total_seconds() / 86400
|
||||
if span_days > _RECOMPUTE_MAX_DAYS:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Time window is {span_days:.1f} days which exceeds the maximum of "
|
||||
f"{_RECOMPUTE_MAX_DAYS} days. Use a smaller window."
|
||||
),
|
||||
)
|
||||
|
||||
n = recompute_range(db, start_utc, end_utc)
|
||||
logger.info(
|
||||
"POST /api/energy/costs/recompute [%s, %s): wrote %d period(s).",
|
||||
start_utc.isoformat(),
|
||||
end_utc.isoformat(),
|
||||
n,
|
||||
)
|
||||
return RecomputeResponse(recomputed=n)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tibber/test",
|
||||
responses={
|
||||
200: {"model": TibberTestResponse},
|
||||
400: {"model": TibberTestResponse},
|
||||
502: {"model": TibberTestResponse},
|
||||
},
|
||||
)
|
||||
def post_tibber_test(
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
"""Test Tibber API connectivity by fetching the current price point.
|
||||
|
||||
Three possible outcomes:
|
||||
|
||||
- **200** ``{ result: "success", message: ..., price: {...} }``
|
||||
The Tibber API responded with a valid current price. ``price`` contains
|
||||
starts_at, total, energy, tax, currency, and level.
|
||||
|
||||
- **400** ``{ result: "config-error", message: ... }``
|
||||
The Tibber API token is empty or not configured.
|
||||
|
||||
- **502** ``{ result: "failed", message: ... }``
|
||||
The API call failed (authentication rejected, network error, timeout,
|
||||
unexpected response, etc.).
|
||||
|
||||
The API token is **never** included in the response body or logged.
|
||||
"""
|
||||
token = settings.tibber_api_token
|
||||
home_id = settings.tibber_home_id or None # treat empty string as None
|
||||
|
||||
if not token:
|
||||
logger.info("POST /api/energy/tibber/test: no token configured.")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=TibberTestResponse(
|
||||
result="config-error",
|
||||
message=(
|
||||
"Tibber API token is not configured. "
|
||||
"Set TIBBER_API_TOKEN in the Config page."
|
||||
),
|
||||
price=None,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
try:
|
||||
price_point = fetch_current_price(token, home_id)
|
||||
except TibberAuthError:
|
||||
logger.warning("POST /api/energy/tibber/test: authentication failed.")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content=TibberTestResponse(
|
||||
result="failed",
|
||||
message=(
|
||||
"Tibber API authentication failed. "
|
||||
"Check that your API token is correct."
|
||||
),
|
||||
price=None,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
except TibberError as exc:
|
||||
logger.warning("POST /api/energy/tibber/test: API call failed — %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content=TibberTestResponse(
|
||||
result="failed",
|
||||
message=f"Tibber API call failed: {exc}",
|
||||
price=None,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
price_schema = TibberTestPriceSchema(
|
||||
starts_at=price_point.starts_at,
|
||||
total=price_point.total,
|
||||
energy=price_point.energy,
|
||||
tax=price_point.tax,
|
||||
currency=price_point.currency,
|
||||
level=price_point.level,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"POST /api/energy/tibber/test: success (starts_at=%s, total=%s %s).",
|
||||
price_point.starts_at.isoformat(),
|
||||
price_point.total,
|
||||
price_point.currency,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=TibberTestResponse(
|
||||
result="success",
|
||||
message=(
|
||||
f"Tibber API connected. Current price: "
|
||||
f"{price_point.total} {price_point.currency}/kWh "
|
||||
f"(starts {price_point.starts_at.isoformat()})."
|
||||
),
|
||||
price=price_schema,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""EnergyContract CRUD, versioning, and pricing-profile API (M6-T04).
|
||||
|
||||
All endpoints are under /api/energy, require an authenticated session, and
|
||||
write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
|
||||
header.
|
||||
|
||||
There is deliberately no DELETE endpoint for contracts: the FK RESTRICT
|
||||
constraint on energy_contract_version.contract_id and
|
||||
energy_cost_period.contract_version_id prevents accidental deletion of
|
||||
contracts that have billing history. Users should instead deactivate a
|
||||
contract (PATCH active=false) to stop it from being used.
|
||||
|
||||
Route ordering note
|
||||
-------------------
|
||||
GET /api/energy/profiles and GET /api/energy/contracts/{id} are on separate
|
||||
path prefixes (/energy/profiles vs /energy/contracts/{id}) so there is no
|
||||
ambiguity even without extra ordering gymnastics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_db
|
||||
from app.integrations.pricing.profiles import (
|
||||
ProfileNotFoundError,
|
||||
ProfileValidationError,
|
||||
list_profiles,
|
||||
)
|
||||
from app.schemas.energy_contract import (
|
||||
ContractCreate,
|
||||
ContractDetailResponse,
|
||||
ContractListResponse,
|
||||
ContractPatch,
|
||||
ContractResponse,
|
||||
ContractVersionResponse,
|
||||
ProfilesResponse,
|
||||
VersionCreate,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services import timezone as _tz_mod
|
||||
from app.services.contracts import (
|
||||
ContractVersionError,
|
||||
activate_contract,
|
||||
add_version,
|
||||
create_contract,
|
||||
deactivate_contract,
|
||||
get_contract_or_none,
|
||||
list_contracts,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/energy", tags=["api-energy-contracts"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_contract_or_404(db: Session, contract_id: int):
|
||||
"""Return the contract with the given id or raise 404."""
|
||||
contract = get_contract_or_none(db, contract_id)
|
||||
if contract is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Energy contract {contract_id!r} not found.",
|
||||
)
|
||||
return contract
|
||||
|
||||
|
||||
def _contract_detail(db: Session, contract) -> ContractDetailResponse:
|
||||
"""Build a ContractDetailResponse for *contract*, loading versions."""
|
||||
# Eager-load versions ordered by effective_from for consistent display.
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.energy import EnergyContractVersion
|
||||
|
||||
versions = list(
|
||||
db.execute(
|
||||
select(EnergyContractVersion)
|
||||
.where(EnergyContractVersion.contract_id == contract.id)
|
||||
.order_by(EnergyContractVersion.effective_from)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
version_schemas = [ContractVersionResponse.model_validate(v) for v in versions]
|
||||
return ContractDetailResponse(
|
||||
id=contract.id,
|
||||
name=contract.name,
|
||||
kind=contract.kind,
|
||||
active=contract.active,
|
||||
currency=contract.currency,
|
||||
created_at=contract.created_at,
|
||||
updated_at=contract.updated_at,
|
||||
versions=version_schemas,
|
||||
)
|
||||
|
||||
|
||||
def _raise_422_for_profile_error(exc: Exception) -> Any:
|
||||
"""Convert a ProfileValidationError or ProfileNotFoundError into HTTP 422."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _localize_effective_from(dt: datetime | None) -> datetime:
|
||||
"""Resolve *dt* to an aware UTC datetime for storage.
|
||||
|
||||
Rules (Principle A):
|
||||
- If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before).
|
||||
- If *dt* is timezone-aware → convert to UTC as-is.
|
||||
- If *dt* is timezone-naive → interpret as server local wall-clock time,
|
||||
localize with ``local_tz()``, then convert to UTC.
|
||||
|
||||
This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it
|
||||
interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
|
||||
not as UTC midnight.
|
||||
"""
|
||||
if dt is None:
|
||||
return datetime.now(UTC)
|
||||
if dt.tzinfo is not None:
|
||||
# Already aware: convert to UTC.
|
||||
return dt.astimezone(UTC)
|
||||
# Naive: assume server local wall-clock.
|
||||
tz = _tz_mod.local_tz()
|
||||
local_dt = dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(UTC)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=ProfilesResponse)
|
||||
def get_profiles(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ProfilesResponse:
|
||||
"""List all available pricing profile structures.
|
||||
|
||||
Returns the full profile structure for each supported contract kind
|
||||
(``manual`` and ``tibber``). The front-end uses this to dynamically
|
||||
render the correct fields and labels for the contract creation/editing form.
|
||||
"""
|
||||
return ProfilesResponse(profiles=list_profiles())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/contracts", response_model=ContractListResponse)
|
||||
def list_energy_contracts(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ContractListResponse:
|
||||
"""List all energy contracts with their active status.
|
||||
|
||||
Returns a flat list (no embedded version history); use
|
||||
GET /api/energy/contracts/{id} to fetch the full version history for a
|
||||
specific contract.
|
||||
"""
|
||||
contracts = list_contracts(db)
|
||||
items = [ContractResponse.model_validate(c) for c in contracts]
|
||||
return ContractListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contracts",
|
||||
response_model=ContractDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_energy_contract(
|
||||
body: ContractCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ContractDetailResponse:
|
||||
"""Create a new energy contract with an initial pricing version.
|
||||
|
||||
The ``values`` dict is validated against the YAML profile for the given
|
||||
``kind``; non-conforming values result in 422 Unprocessable Entity.
|
||||
The new contract is created with ``active=False``; use
|
||||
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
|
||||
"""
|
||||
effective_from = _localize_effective_from(body.effective_from)
|
||||
|
||||
try:
|
||||
contract = create_contract(
|
||||
db,
|
||||
name=body.name,
|
||||
kind=body.kind,
|
||||
currency=body.currency,
|
||||
values=body.values,
|
||||
effective_from=effective_from,
|
||||
)
|
||||
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||
_raise_422_for_profile_error(exc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
logger.info("Created energy contract id=%d name=%r kind=%s", contract.id, contract.name, contract.kind)
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/contracts/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/contracts/{contract_id}", response_model=ContractDetailResponse)
|
||||
def get_energy_contract(
|
||||
contract_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ContractDetailResponse:
|
||||
"""Return a single energy contract with its full version history.
|
||||
|
||||
Versions are ordered by ``effective_from`` ascending so the caller can
|
||||
easily inspect the pricing timeline.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/energy/contracts/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/contracts/{contract_id}", response_model=ContractDetailResponse)
|
||||
def patch_energy_contract(
|
||||
contract_id: int,
|
||||
body: ContractPatch,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ContractDetailResponse:
|
||||
"""Partially update a contract: rename or change activation status.
|
||||
|
||||
- ``name``: updates the human-readable label.
|
||||
- ``active=true``: activates this contract (all others are deactivated).
|
||||
- ``active=false``: deactivates this contract (no effect on others).
|
||||
|
||||
At most one contract may be active at any time; the service layer enforces
|
||||
mutual exclusion.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
|
||||
if body.name is not None:
|
||||
contract.name = body.name
|
||||
contract.updated_at = datetime.now(UTC)
|
||||
|
||||
if body.active is True:
|
||||
activate_contract(db, contract)
|
||||
elif body.active is False:
|
||||
deactivate_contract(db, contract)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/contracts/{id}/versions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contracts/{contract_id}/versions",
|
||||
response_model=ContractDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_contract_version(
|
||||
contract_id: int,
|
||||
body: VersionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ContractDetailResponse:
|
||||
"""Add a new pricing version to an existing contract.
|
||||
|
||||
This is how price changes are recorded: the current open version is
|
||||
automatically closed (its ``effective_to`` is set to ``body.effective_from``)
|
||||
and a new version is created starting at ``body.effective_from``.
|
||||
|
||||
The ``values`` dict must conform to the contract's pricing profile.
|
||||
Non-conforming values return 422. If ``effective_from`` is not strictly
|
||||
after the previous version's ``effective_from``, 422 is returned without
|
||||
writing any rows.
|
||||
|
||||
Historical versions are never modified; this endpoint is append-only.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
effective_from = _localize_effective_from(body.effective_from)
|
||||
|
||||
try:
|
||||
add_version(
|
||||
db,
|
||||
contract,
|
||||
effective_from=effective_from,
|
||||
values=body.values,
|
||||
)
|
||||
except ContractVersionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
)
|
||||
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||
_raise_422_for_profile_error(exc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
return _contract_detail(db, contract)
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Expose API routes (M5-T12).
|
||||
|
||||
Three endpoints:
|
||||
GET /api/expose — Return catalog of exposable entities, per-key
|
||||
toggle state, and MQTT/Discovery connection status.
|
||||
PUT /api/expose — Set per-key toggle state (map key → bool);
|
||||
triggers a HA Discovery re-publish on success.
|
||||
POST /api/expose/republish — Manually trigger a full HA Discovery re-publish.
|
||||
|
||||
Auth model:
|
||||
- GET: session required (no CSRF — read-only).
|
||||
- PUT, POST: session + CSRF required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.config import get_settings
|
||||
from app.dependencies import get_db
|
||||
from app.services.config_page import build_runtime_settings
|
||||
from app.integrations.expose import CatalogEntry, build_catalog
|
||||
from app.integrations.mqtt import mqtt_manager
|
||||
from app.models.expose import ExposedEntityToggle
|
||||
from app.schemas.expose import (
|
||||
CatalogEntrySchema,
|
||||
DeviceInfoSchema,
|
||||
ExposeResponse,
|
||||
ExposeUpdateRequest,
|
||||
ExposeUpdateResponse,
|
||||
ExposableEntitySchema,
|
||||
MqttStatusSchema,
|
||||
RepublishResponse,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-expose"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_mqtt_status(db: Session) -> MqttStatusSchema:
|
||||
"""Build MQTT/Discovery status from live runtime state (DB-merged settings)."""
|
||||
settings = build_runtime_settings(db, get_settings())
|
||||
return MqttStatusSchema(
|
||||
mqtt_configured=mqtt_manager.is_configured(settings),
|
||||
mqtt_connected=mqtt_manager.is_connected,
|
||||
discovery_enabled=bool(settings.ha_discovery_enabled),
|
||||
)
|
||||
|
||||
|
||||
def _entry_to_schema(entry: CatalogEntry) -> CatalogEntrySchema:
|
||||
"""Convert a CatalogEntry to its Pydantic schema form.
|
||||
|
||||
Excludes ``value_getter`` because it is a non-serialisable callable.
|
||||
"""
|
||||
entity = entry.entity
|
||||
return CatalogEntrySchema(
|
||||
entity=ExposableEntitySchema(
|
||||
key=entity.key,
|
||||
component=entity.component,
|
||||
device=DeviceInfoSchema(
|
||||
identifiers=list(entity.device.identifiers),
|
||||
name=entity.device.name,
|
||||
),
|
||||
device_class=entity.device_class,
|
||||
unit=entity.unit,
|
||||
name=entity.name,
|
||||
state_class=entity.state_class,
|
||||
),
|
||||
enabled=entry.enabled,
|
||||
)
|
||||
|
||||
|
||||
def _build_response_data(
|
||||
session: Session,
|
||||
) -> tuple[list[CatalogEntrySchema], MqttStatusSchema]:
|
||||
"""Return (catalog_entries, mqtt_status) for building GET / PUT responses."""
|
||||
catalog = build_catalog(session)
|
||||
catalog_schema = [_entry_to_schema(e) for e in catalog]
|
||||
mqtt_status = _get_mqtt_status(session)
|
||||
return catalog_schema, mqtt_status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/expose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/expose", response_model=ExposeResponse)
|
||||
def get_expose(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ExposeResponse:
|
||||
"""Return the full exposable-entity catalog with toggle states and MQTT status.
|
||||
|
||||
The catalog is computed dynamically from registered providers (e.g. the
|
||||
Modbus provider enumerates all enabled devices and their metric entities).
|
||||
Toggle states come from the ``exposed_entity_toggle`` table; entities with
|
||||
no row default to ``enabled=False``.
|
||||
"""
|
||||
catalog_schema, mqtt_status = _build_response_data(db)
|
||||
return ExposeResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/expose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.put("/expose", response_model=ExposeUpdateResponse)
|
||||
def put_expose(
|
||||
body: ExposeUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ExposeUpdateResponse:
|
||||
"""Set per-entity toggle state.
|
||||
|
||||
Accepts a map of ``{key: bool}`` and upserts rows in the
|
||||
``exposed_entity_toggle`` table. Only keys present in ``body.toggles``
|
||||
are touched; other entities' toggles are left unchanged.
|
||||
|
||||
After writing the toggles, triggers a HA Discovery re-publish so any
|
||||
changes (enabled ↔ disabled) are reflected in Home Assistant immediately.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for key, enabled in body.toggles.items():
|
||||
existing = (
|
||||
db.query(ExposedEntityToggle)
|
||||
.filter(ExposedEntityToggle.key == key)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
existing.enabled = enabled
|
||||
existing.updated_at = now
|
||||
else:
|
||||
db.add(
|
||||
ExposedEntityToggle(
|
||||
key=key,
|
||||
enabled=enabled,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Trigger discovery re-publish after toggle change.
|
||||
_trigger_republish(db)
|
||||
|
||||
catalog_schema, mqtt_status = _build_response_data(db)
|
||||
return ExposeUpdateResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/expose/republish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/expose/republish", response_model=RepublishResponse)
|
||||
def post_expose_republish(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> RepublishResponse:
|
||||
"""Manually trigger a full HA Discovery re-publish.
|
||||
|
||||
Calls ``publish_discovery(session)`` from the HA discovery service (M5-T11).
|
||||
Returns a status indicating whether the publish was attempted (or skipped
|
||||
because MQTT / discovery is not enabled / connected).
|
||||
"""
|
||||
settings = build_runtime_settings(db, get_settings())
|
||||
if not (settings.mqtt_enabled and settings.ha_discovery_enabled and mqtt_manager.is_connected):
|
||||
return RepublishResponse(
|
||||
ok=False,
|
||||
message="MQTT / HA Discovery not enabled or broker not connected.",
|
||||
)
|
||||
|
||||
try:
|
||||
_trigger_republish(db)
|
||||
return RepublishResponse(
|
||||
ok=True,
|
||||
message="HA Discovery re-published successfully.",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("post_expose_republish: unexpected error during publish")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Discovery re-publish failed.",
|
||||
) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: trigger discovery re-publish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _trigger_republish(session: Session) -> None:
|
||||
"""Call publish_discovery from the HA discovery service.
|
||||
|
||||
No-op if MQTT / discovery is not enabled or broker is not connected
|
||||
(publish_discovery guards internally). All errors are swallowed to avoid
|
||||
breaking the API response.
|
||||
"""
|
||||
try:
|
||||
from app.services.ha_discovery import publish_discovery
|
||||
|
||||
publish_discovery(session)
|
||||
except Exception:
|
||||
logger.exception("_trigger_republish: publish_discovery raised an error")
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Modbus device CRUD, readings, metrics, and test-read API (M5-T05).
|
||||
|
||||
All endpoints are under /api/modbus, require an authenticated session, and
|
||||
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
|
||||
non-empty X-CSRF-Token header.
|
||||
|
||||
Deletion safety:
|
||||
DELETE /api/modbus/devices/{uuid} queries the application layer for
|
||||
existing modbus_reading rows before deleting. Without ``cascade`` it
|
||||
returns 409 and suggests disabling the device instead — a friendly guard
|
||||
rather than letting the DB raise. With ``cascade=true`` it removes the
|
||||
readings (and expose toggles) first, then the device. SQLite FK RESTRICT
|
||||
*is* enforced at runtime here (the app sets PRAGMA foreign_keys=ON; see
|
||||
app/db.py), so the cascade delete order matters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import delete as sa_delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_db
|
||||
from app.integrations.modbus import driver as modbus_driver
|
||||
from app.integrations.modbus.driver import ModbusDriverError
|
||||
from app.integrations.modbus.profiles import (
|
||||
ProfileNotFoundError,
|
||||
decode as decode_profile,
|
||||
list_profiles,
|
||||
load_profile,
|
||||
)
|
||||
from app.models.expose import ExposedEntityToggle
|
||||
from app.models.modbus import ModbusDevice, ModbusReading
|
||||
from app.schemas.modbus import (
|
||||
MetricInfo,
|
||||
ModbusDeleteResponse,
|
||||
ModbusDeviceCreate,
|
||||
ModbusDeviceListResponse,
|
||||
ModbusDeviceResponse,
|
||||
ModbusDeviceUpdate,
|
||||
ModbusLatestResponse,
|
||||
ModbusMetricsResponse,
|
||||
ModbusProfilesResponse,
|
||||
ModbusReadingResponse,
|
||||
ModbusReadingsResponse,
|
||||
ModbusTestReadResponse,
|
||||
ProfileSummary,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/modbus", tags=["api-modbus"])
|
||||
|
||||
# Maximum number of readings that can be returned per request.
|
||||
_READINGS_LIMIT_MAX = 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_device_or_404(db: Session, uuid: str) -> ModbusDevice:
|
||||
"""Return the device with the given UUID or raise 404."""
|
||||
device = db.execute(
|
||||
select(ModbusDevice).where(ModbusDevice.uuid == uuid)
|
||||
).scalar_one_or_none()
|
||||
if device is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Device '{uuid}' not found.",
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
def _label_from_key(key: str) -> str:
|
||||
"""Derive a human-readable label from a metric key.
|
||||
|
||||
Example: ``"active_power"`` → ``"Active Power"``
|
||||
"""
|
||||
return key.replace("_", " ").title()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/modbus/profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=ModbusProfilesResponse)
|
||||
def get_profiles(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusProfilesResponse:
|
||||
"""List all available Modbus YAML profiles (name + description).
|
||||
|
||||
Intended for the front-end's device-creation profile drop-down.
|
||||
"""
|
||||
pairs = list_profiles()
|
||||
return ModbusProfilesResponse(
|
||||
profiles=[ProfileSummary(name=name, description=desc) for name, desc in pairs]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/devices", response_model=ModbusDeviceListResponse)
|
||||
def list_devices(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusDeviceListResponse:
|
||||
"""Return all Modbus devices (no pagination — device counts stay small)."""
|
||||
devices = db.execute(select(ModbusDevice)).scalars().all()
|
||||
items = [ModbusDeviceResponse.model_validate(d) for d in devices]
|
||||
return ModbusDeviceListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/devices",
|
||||
response_model=ModbusDeviceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_device(
|
||||
body: ModbusDeviceCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ModbusDeviceResponse:
|
||||
"""Create a new Modbus device.
|
||||
|
||||
- Validates that the referenced ``profile`` exists; returns 422 if not.
|
||||
- Returns 201 with the created device on success.
|
||||
"""
|
||||
# Validate profile exists (422 if not)
|
||||
try:
|
||||
load_profile(body.profile)
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Profile '{body.profile}' does not exist.",
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
device = ModbusDevice(
|
||||
friendly_name=body.friendly_name,
|
||||
transport=body.transport,
|
||||
host=body.host,
|
||||
port=body.port,
|
||||
unit_id=body.unit_id,
|
||||
profile=body.profile,
|
||||
poll_interval_s=body.poll_interval_s,
|
||||
enabled=body.enabled,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
logger.info("Created Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
|
||||
return ModbusDeviceResponse.model_validate(device)
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}", response_model=ModbusDeviceResponse)
|
||||
def get_device(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusDeviceResponse:
|
||||
"""Return a single Modbus device by UUID."""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
return ModbusDeviceResponse.model_validate(device)
|
||||
|
||||
|
||||
@router.patch("/devices/{uuid}", response_model=ModbusDeviceResponse)
|
||||
def patch_device(
|
||||
uuid: str,
|
||||
body: ModbusDeviceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ModbusDeviceResponse:
|
||||
"""Partially update a Modbus device (including enable/disable).
|
||||
|
||||
Only fields explicitly provided in the request body are updated.
|
||||
Providing ``profile`` triggers a profile-existence check (422 if unknown).
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
|
||||
# If profile is being changed, validate it exists.
|
||||
if "profile" in update_data:
|
||||
try:
|
||||
load_profile(update_data["profile"])
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Profile '{update_data['profile']}' does not exist.",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
logger.info("Updated Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
|
||||
return ModbusDeviceResponse.model_validate(device)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/devices/{uuid}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def delete_device(
|
||||
uuid: str,
|
||||
cascade: bool = Query(default=False),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> None | ModbusDeleteResponse:
|
||||
"""Delete a Modbus device.
|
||||
|
||||
**Default behaviour (cascade=false)**:
|
||||
Returns 409 Conflict if the device has any associated readings; use
|
||||
``enabled=false`` to disable it instead.
|
||||
|
||||
**Cascade delete (cascade=true)**:
|
||||
Permanently deletes the device together with all its readings and any
|
||||
``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.
|
||||
Also makes a best-effort attempt to clear the device's HA Discovery
|
||||
config topics from MQTT (empty retained payload) before the DB rows
|
||||
are removed. MQTT failures are swallowed — the DB deletion proceeds
|
||||
regardless.
|
||||
Returns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.
|
||||
|
||||
**Application-layer safety**: the 409 guard uses an explicit SELECT COUNT
|
||||
query to return a friendly message. FK RESTRICT is enforced at runtime
|
||||
(the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes
|
||||
readings before the device.
|
||||
"""
|
||||
from app.services.ha_discovery import clear_device_discovery
|
||||
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
# Application-layer guard: refuse deletion if any readings exist.
|
||||
reading_count: int = db.execute(
|
||||
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
|
||||
).scalar_one()
|
||||
|
||||
if reading_count > 0 and not cascade:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"Device '{uuid}' has {reading_count} associated reading(s) and cannot be "
|
||||
"deleted. Disable the device (set enabled=false) instead to stop polling "
|
||||
"without losing historical data."
|
||||
),
|
||||
)
|
||||
|
||||
if cascade:
|
||||
# --- Cascade deletion path ---
|
||||
logger.info(
|
||||
"Cascade-deleting Modbus device %r (uuid=%s): %d reading(s)",
|
||||
device.friendly_name,
|
||||
uuid,
|
||||
reading_count,
|
||||
)
|
||||
|
||||
# 1. Best-effort HA Discovery cleanup (before DB rows are gone).
|
||||
try:
|
||||
clear_device_discovery(db, uuid)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"delete_device(cascade): HA discovery cleanup raised for uuid=%s; continuing",
|
||||
uuid,
|
||||
)
|
||||
|
||||
# 2. Delete all readings for this device (must happen before device row).
|
||||
deleted_readings_result = db.execute(
|
||||
sa_delete(ModbusReading).where(ModbusReading.device_id == device.id)
|
||||
)
|
||||
readings_deleted: int = deleted_readings_result.rowcount
|
||||
|
||||
# 3. Delete all ExposedEntityToggle rows for this device.
|
||||
# Keys follow the pattern "modbus.<uuid>.<metric>".
|
||||
toggle_prefix = f"modbus.{uuid}.%"
|
||||
deleted_toggles_result = db.execute(
|
||||
sa_delete(ExposedEntityToggle).where(ExposedEntityToggle.key.like(toggle_prefix))
|
||||
)
|
||||
toggles_deleted: int = deleted_toggles_result.rowcount
|
||||
|
||||
# 4. Delete the device itself.
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"Cascade-delete complete for device uuid=%s: %d reading(s), %d toggle(s) removed",
|
||||
uuid,
|
||||
readings_deleted,
|
||||
toggles_deleted,
|
||||
)
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"deleted": True,
|
||||
"readings_deleted": readings_deleted,
|
||||
"toggles_deleted": toggles_deleted,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Non-cascade path (no readings exist at this point) ---
|
||||
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readings endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}/latest", response_model=ModbusLatestResponse)
|
||||
def get_latest_reading(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusLatestResponse:
|
||||
"""Return the most recent reading for a device.
|
||||
|
||||
If no readings exist yet, returns ``{"found": false, "recorded_at": null,
|
||||
"payload": null}`` (200, not 404) so the front-end can distinguish
|
||||
"device exists but has no data" from "device not found".
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
row = db.execute(
|
||||
select(ModbusReading)
|
||||
.where(ModbusReading.device_id == device.id)
|
||||
.order_by(ModbusReading.recorded_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return ModbusLatestResponse(found=False, recorded_at=None, payload=None)
|
||||
|
||||
return ModbusLatestResponse(
|
||||
found=True,
|
||||
recorded_at=row.recorded_at,
|
||||
payload=row.payload,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}/readings", response_model=ModbusReadingsResponse)
|
||||
def get_readings(
|
||||
uuid: str,
|
||||
start: datetime | None = Query(default=None),
|
||||
end: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=500, ge=1, le=_READINGS_LIMIT_MAX),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusReadingsResponse:
|
||||
"""Return time-range readings for a device.
|
||||
|
||||
When the window contains more rows than ``limit``, the **most recent** N rows
|
||||
are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to
|
||||
ascending order before being sent to the client. This ensures that for long
|
||||
time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data
|
||||
rather than the oldest segment of the window.
|
||||
|
||||
When the window has fewer rows than ``limit`` the full window is returned in
|
||||
ascending order — behaviour is identical to a plain ascending query.
|
||||
|
||||
The response schema is unchanged: items are always ``recorded_at`` ascending.
|
||||
|
||||
The query uses the ``(device_id, recorded_at)`` composite index for
|
||||
efficient time-window scans.
|
||||
|
||||
Query parameters:
|
||||
- ``start``: inclusive lower bound (ISO8601 datetime)
|
||||
- ``end``: inclusive upper bound (ISO8601 datetime)
|
||||
- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
stmt = (
|
||||
select(ModbusReading)
|
||||
.where(ModbusReading.device_id == device.id)
|
||||
.order_by(ModbusReading.recorded_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if start is not None:
|
||||
stmt = stmt.where(ModbusReading.recorded_at >= start)
|
||||
if end is not None:
|
||||
stmt = stmt.where(ModbusReading.recorded_at <= end)
|
||||
|
||||
# Fetch most-recent N rows, then reverse to restore ascending order for the response.
|
||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||
items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows]
|
||||
return ModbusReadingsResponse(items=items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}/metrics", response_model=ModbusMetricsResponse)
|
||||
def get_metrics(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusMetricsResponse:
|
||||
"""Return the metric catalogue for a device's profile.
|
||||
|
||||
Each entry has ``key``, ``label`` (derived from key if the profile has
|
||||
none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
|
||||
This is the authoritative metadata source for front-end card labels and
|
||||
chart axis labels.
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
try:
|
||||
profile = load_profile(device.profile)
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Device profile '{device.profile}' could not be loaded.",
|
||||
)
|
||||
|
||||
metrics = [
|
||||
MetricInfo(
|
||||
key=m.key,
|
||||
label=_label_from_key(m.key),
|
||||
unit=m.unit,
|
||||
device_class=m.device_class,
|
||||
)
|
||||
for m in profile.metrics
|
||||
]
|
||||
|
||||
return ModbusMetricsResponse(profile=profile.name, metrics=metrics)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-read endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/devices/{uuid}/test", response_model=ModbusTestReadResponse)
|
||||
def test_read(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ModbusTestReadResponse:
|
||||
"""Immediately read and decode the device once without persisting any data.
|
||||
|
||||
Useful for validating gateway connectivity and Modbus address settings
|
||||
before relying on the background polling job.
|
||||
|
||||
This is a write-class endpoint (it initiates network I/O on demand) and
|
||||
therefore requires a CSRF token. The response payload is never stored.
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
try:
|
||||
profile = load_profile(device.profile)
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Device profile '{device.profile}' could not be loaded.",
|
||||
)
|
||||
|
||||
try:
|
||||
registers = modbus_driver.read_blocks(
|
||||
device.host,
|
||||
device.port,
|
||||
device.unit_id,
|
||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||
)
|
||||
payload: dict[str, Any] = decode_profile(profile, registers)
|
||||
return ModbusTestReadResponse(ok=True, payload=payload)
|
||||
|
||||
except ModbusDriverError as exc:
|
||||
logger.warning(
|
||||
"Test read failed for device %r (uuid=%s): %s",
|
||||
device.friendly_name,
|
||||
uuid,
|
||||
exc,
|
||||
)
|
||||
return ModbusTestReadResponse(ok=False, error=str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Unexpected error during test read for device %r (uuid=%s): %s",
|
||||
device.friendly_name,
|
||||
uuid,
|
||||
exc,
|
||||
)
|
||||
return ModbusTestReadResponse(ok=False, error=f"Unexpected error: {exc}")
|
||||
@@ -0,0 +1,324 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.schemas.session import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
SessionResponse,
|
||||
SessionUser,
|
||||
)
|
||||
from app.schemas.totp import (
|
||||
TotpDisableRequest,
|
||||
TotpEnableRequest,
|
||||
TotpSetupResponse,
|
||||
TotpStatusResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
AuthPasswordChangeError,
|
||||
AuthenticatedSession,
|
||||
authenticate_user,
|
||||
change_password,
|
||||
create_session,
|
||||
revoke_session,
|
||||
)
|
||||
from app.services import login_throttle
|
||||
from app.services import totp as totp_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-session"])
|
||||
|
||||
|
||||
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
|
||||
return SessionResponse(
|
||||
user=SessionUser(
|
||||
username=auth.user.username,
|
||||
force_password_change=auth.user.force_password_change,
|
||||
),
|
||||
csrf_token=auth.session.csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/session", response_model=SessionResponse)
|
||||
def get_session(
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> SessionResponse:
|
||||
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
|
||||
return _build_session_response(auth)
|
||||
|
||||
|
||||
def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str:
|
||||
"""Extract the client IP address from the request.
|
||||
|
||||
When ``trust_forwarded_for`` is True (reverse-proxy deployments) the
|
||||
left-most value from the ``X-Forwarded-For`` header is used. Otherwise the
|
||||
direct socket IP (``request.client.host``) is used.
|
||||
"""
|
||||
if trust_forwarded_for:
|
||||
xff = request.headers.get("X-Forwarded-For", "")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
if request.client is not None:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=SessionResponse)
|
||||
def post_login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Authenticate with username and password.
|
||||
|
||||
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
|
||||
On failure, returns 401 with no cookie set.
|
||||
Repeated failures trigger exponential back-off (429 + Retry-After).
|
||||
No X-CSRF-Token required (unauthenticated endpoint).
|
||||
"""
|
||||
client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for)
|
||||
|
||||
# --- Throttle check (before any password verification) ---
|
||||
if settings.auth_login_throttle_enabled:
|
||||
wait_seconds = login_throttle.check_and_get_wait(
|
||||
db, ip=client_ip, username=body.username
|
||||
)
|
||||
if wait_seconds > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="too many failed login attempts; please try again later",
|
||||
headers={"Retry-After": str(wait_seconds)},
|
||||
)
|
||||
|
||||
# --- Password verification ---
|
||||
user = authenticate_user(db, username=body.username, password=body.password)
|
||||
if user is None:
|
||||
if settings.auth_login_throttle_enabled:
|
||||
login_throttle.register_failure(db, ip=client_ip, username=body.username)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
)
|
||||
|
||||
# --- TOTP second-factor check (only when TOTP is enabled for the user) ---
|
||||
if user.totp_enabled:
|
||||
if not body.totp_code:
|
||||
# Password correct but no TOTP code supplied: signal the front-end to
|
||||
# prompt for the second factor. Do NOT issue a session.
|
||||
# Deliberate: this is a normal two-step protocol step from a legitimate
|
||||
# user — we do NOT register a throttle failure here. The attacker must
|
||||
# already have the correct password to reach this branch, and counting
|
||||
# each normal first-step as a failure would risk locking out the real
|
||||
# admin on every login attempt.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"totp_required": True},
|
||||
)
|
||||
|
||||
# TOTP code supplied — verify against time-based code or a recovery code.
|
||||
totp_ok = totp_service.verify_totp_code(user, body.totp_code)
|
||||
if not totp_ok:
|
||||
totp_ok = totp_service.verify_recovery_code(db, user=user, code=body.totp_code)
|
||||
|
||||
if not totp_ok:
|
||||
# Second-factor failure is an active attack signal — register failure
|
||||
# so that repeated wrong TOTP/recovery-code guesses trigger back-off.
|
||||
if settings.auth_login_throttle_enabled:
|
||||
login_throttle.register_failure(db, ip=client_ip, username=body.username)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
)
|
||||
|
||||
# --- Success: clear back-off state and issue session ---
|
||||
if settings.auth_login_throttle_enabled:
|
||||
login_throttle.clear(db, ip=client_ip, username=body.username)
|
||||
|
||||
auth_session, raw_token = create_session(db, user=user, settings=settings)
|
||||
logger.info("Created API authenticated session for user '%s'", user.username)
|
||||
|
||||
response.set_cookie(
|
||||
key=settings.auth_session_cookie_name,
|
||||
value=raw_token,
|
||||
max_age=settings.auth_session_ttl_hours * 3600,
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
auth = AuthenticatedSession(user=user, session=auth_session)
|
||||
return _build_session_response(auth)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def post_logout(
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Revoke the current session and clear the session cookie.
|
||||
Requires authentication and X-CSRF-Token header.
|
||||
Returns 204 No Content.
|
||||
"""
|
||||
revoke_session(db, auth_session=auth.session)
|
||||
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
|
||||
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
|
||||
return no_content
|
||||
|
||||
|
||||
@router.post("/auth/password")
|
||||
def post_change_password(
|
||||
body: PasswordChangeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Change the current user's password.
|
||||
Requires authentication and X-CSRF-Token header.
|
||||
On AuthPasswordChangeError returns 400 with a generic message.
|
||||
On success, force_password_change becomes False (handled by the service).
|
||||
Returns 204 No Content.
|
||||
"""
|
||||
try:
|
||||
change_password(
|
||||
db,
|
||||
user=auth.user,
|
||||
current_password=body.current_password,
|
||||
new_password=body.new_password,
|
||||
confirm_password=body.confirm_password,
|
||||
)
|
||||
except AuthPasswordChangeError as exc:
|
||||
logger.info(
|
||||
"Rejected password change for user '%s': %s",
|
||||
auth.user.username,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="password change failed",
|
||||
) from exc
|
||||
|
||||
logger.info("Password updated for user '%s'", auth.user.username)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOTP endpoints (M4-T05)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
|
||||
def post_totp_setup(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> TotpSetupResponse:
|
||||
"""
|
||||
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
|
||||
|
||||
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
|
||||
False until the user confirms with POST /api/auth/totp/enable).
|
||||
|
||||
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
|
||||
are persisted immediately so enable only needs to flip the enabled flag.
|
||||
|
||||
Repeating this call replaces any prior pending secret and regenerates codes.
|
||||
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
secret, otpauth_uri, recovery_codes = totp_service.setup(
|
||||
db,
|
||||
user=auth.user,
|
||||
issuer=settings.effective_totp_issuer,
|
||||
)
|
||||
return TotpSetupResponse(
|
||||
secret=secret,
|
||||
otpauth_uri=otpauth_uri,
|
||||
recovery_codes=recovery_codes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def post_totp_enable(
|
||||
body: TotpEnableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
|
||||
|
||||
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
|
||||
exists). On success, totp_enabled becomes True.
|
||||
|
||||
Returns 400 if the code is wrong or there is no pending secret.
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
ok = totp_service.enable(db, user=auth.user, code=body.code)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid TOTP code or no pending setup",
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def post_totp_disable(
|
||||
body: TotpDisableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Disable TOTP. The caller must provide exactly one of:
|
||||
- ``password``: the user's current login password, OR
|
||||
- ``code``: the current 6-digit TOTP code.
|
||||
|
||||
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
|
||||
Returns 400 if neither credential matches or neither is provided.
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
ok = totp_service.disable(
|
||||
db,
|
||||
user=auth.user,
|
||||
password=body.password,
|
||||
code=body.code,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid credential; provide a valid password or TOTP code",
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/auth/totp", response_model=TotpStatusResponse)
|
||||
def get_totp_status(
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> TotpStatusResponse:
|
||||
"""
|
||||
Return the current TOTP status for the authenticated user.
|
||||
|
||||
Response contains only ``{"enabled": bool}``.
|
||||
Secret and recovery codes are NEVER returned here.
|
||||
Requires: session cookie only (no CSRF — read-only).
|
||||
"""
|
||||
return TotpStatusResponse(enabled=auth.user.totp_enabled)
|
||||
@@ -1,234 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
authenticate_user,
|
||||
change_password,
|
||||
create_session,
|
||||
AuthPasswordChangeError,
|
||||
issue_login_csrf_token,
|
||||
revoke_session,
|
||||
validate_csrf_token,
|
||||
)
|
||||
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is not None:
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
csrf_token = issue_login_csrf_token()
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"csrf_token": csrf_token,
|
||||
"error_message": None,
|
||||
},
|
||||
)
|
||||
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(),
|
||||
password: str = Form(),
|
||||
csrf_token: str = Form(),
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> Response:
|
||||
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
|
||||
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
|
||||
logger.warning("Rejected login attempt due to CSRF validation failure")
|
||||
return _render_login_error(
|
||||
request,
|
||||
settings=settings,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
error_message="invalid login request",
|
||||
)
|
||||
|
||||
user = authenticate_user(session, username=username, password=password)
|
||||
if user is None:
|
||||
return _render_login_error(
|
||||
request,
|
||||
settings=settings,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error_message="invalid username or password",
|
||||
)
|
||||
|
||||
auth_session, raw_token = create_session(session, user=user, settings=settings)
|
||||
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
|
||||
response.set_cookie(
|
||||
key=settings.auth_session_cookie_name,
|
||||
value=raw_token,
|
||||
max_age=settings.auth_session_ttl_hours * 3600,
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
logger.info("Created authenticated session for user '%s'", user.username)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/config/change-password", response_class=HTMLResponse)
|
||||
def change_password_submit(
|
||||
request: Request,
|
||||
current_password: str = Form(),
|
||||
new_password: str = Form(),
|
||||
confirm_password: str = Form(),
|
||||
csrf_token: str = Form(),
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
|
||||
logger.warning("Rejected password change attempt due to CSRF validation failure")
|
||||
return _render_config_page(
|
||||
request,
|
||||
settings=settings,
|
||||
auth_db_session=session,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="invalid password change request",
|
||||
)
|
||||
|
||||
try:
|
||||
change_password(
|
||||
session,
|
||||
user=current_auth.user,
|
||||
current_password=current_password,
|
||||
new_password=new_password,
|
||||
confirm_password=confirm_password,
|
||||
)
|
||||
except AuthPasswordChangeError as exc:
|
||||
logger.info(
|
||||
"Rejected password change for user '%s': %s",
|
||||
current_auth.user.username,
|
||||
exc,
|
||||
)
|
||||
return _render_config_page(
|
||||
request,
|
||||
settings=settings,
|
||||
auth_db_session=session,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="password change failed",
|
||||
)
|
||||
|
||||
logger.info("Password updated for user '%s'", current_auth.user.username)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
request: Request,
|
||||
csrf_token: str = Form(),
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is not None and validate_csrf_token(
|
||||
expected=current_auth.session.csrf_token, actual=csrf_token
|
||||
):
|
||||
revoke_session(session, auth_session=current_auth.session)
|
||||
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
|
||||
else:
|
||||
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
|
||||
|
||||
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(settings.auth_session_cookie_name, path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _render_login_error(
|
||||
request: Request,
|
||||
*,
|
||||
settings: Settings,
|
||||
status_code: int,
|
||||
error_message: str,
|
||||
) -> HTMLResponse:
|
||||
csrf_token = issue_login_csrf_token()
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"csrf_token": csrf_token,
|
||||
"error_message": error_message,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
|
||||
return response
|
||||
|
||||
|
||||
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=LOGIN_CSRF_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=1800,
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
path="/login",
|
||||
)
|
||||
|
||||
|
||||
def _render_config_page(
|
||||
request: Request,
|
||||
*,
|
||||
settings: Settings,
|
||||
auth_db_session: Session,
|
||||
current_auth: AuthenticatedSession,
|
||||
status_code: int,
|
||||
password_change_error: str | None,
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": password_change_error,
|
||||
"config_error": None,
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
||||
"ticktick_oauth_notice": None,
|
||||
"ticktick_oauth_error": None,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
@@ -11,7 +11,6 @@ from app.dependencies import (
|
||||
get_app_settings,
|
||||
get_db,
|
||||
get_homeassistant_client,
|
||||
get_poo_db,
|
||||
get_ticktick_client,
|
||||
)
|
||||
from app.integrations.homeassistant import (
|
||||
@@ -36,7 +35,6 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
||||
async def publish_from_homeassistant(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
poo_db: Session = Depends(get_poo_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
@@ -49,7 +47,7 @@ async def publish_from_homeassistant(
|
||||
db,
|
||||
envelope,
|
||||
ticktick_client=ticktick_client,
|
||||
poo_session=poo_db,
|
||||
poo_session=db,
|
||||
settings=settings,
|
||||
homeassistant_client=homeassistant_client,
|
||||
)
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import (
|
||||
ConfigSaveError,
|
||||
build_config_sections,
|
||||
is_ticktick_oauth_ready,
|
||||
save_config_updates,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["pages"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
|
||||
if status_value == "success":
|
||||
return "TickTick authorization completed successfully.", None
|
||||
if status_value == "invalid-state":
|
||||
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
|
||||
if status_value == "invalid-callback":
|
||||
return None, "TickTick authorization callback was missing required parameters."
|
||||
if status_value == "failed":
|
||||
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
|
||||
return None, None
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def home(
|
||||
request: Request,
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_redirect(
|
||||
request: Request,
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
def config_page(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
|
||||
request.query_params.get("ticktick_oauth")
|
||||
)
|
||||
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": None,
|
||||
"config_saved": request.query_params.get("saved") == "1",
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
||||
"ticktick_oauth_notice": ticktick_oauth_notice,
|
||||
"ticktick_oauth_error": ticktick_oauth_error,
|
||||
}
|
||||
return templates.TemplateResponse(request, "config.html", context)
|
||||
|
||||
|
||||
@router.post("/config", response_class=HTMLResponse)
|
||||
async def config_submit(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
form = await request.form()
|
||||
csrf_token = form.get("csrf_token")
|
||||
if csrf_token != current_auth.session.csrf_token:
|
||||
logger.warning("Rejected config update due to CSRF validation failure")
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": "invalid config update request",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
||||
"ticktick_oauth_notice": None,
|
||||
"ticktick_oauth_error": None,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
context,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
save_config_updates(auth_db_session, dict(form), settings)
|
||||
except ConfigSaveError:
|
||||
logger.warning("Rejected config update due to invalid submitted values")
|
||||
refreshed_settings = get_settings()
|
||||
context = {
|
||||
"app_name": refreshed_settings.app_name,
|
||||
"app_env": refreshed_settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": "invalid config submission",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings),
|
||||
"ticktick_redirect_uri": refreshed_settings.ticktick_redirect_uri,
|
||||
"ticktick_oauth_notice": None,
|
||||
"ticktick_oauth_error": None,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
context,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_homeassistant_client, get_poo_db
|
||||
from app.dependencies import get_app_settings, get_homeassistant_client, get_db
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.schemas.poo import PooRecordRequest
|
||||
from app.services.poo import publish_latest_poo_status, record_poo
|
||||
@@ -21,7 +21,7 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
||||
@router.post("/poo/record")
|
||||
async def create_poo_record(
|
||||
request: Request,
|
||||
db: Session = Depends(get_poo_db),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
) -> Response:
|
||||
@@ -56,7 +56,7 @@ async def create_poo_record(
|
||||
|
||||
@router.get("/poo/latest")
|
||||
def notify_latest_poo(
|
||||
db: Session = Depends(get_poo_db),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
) -> Response:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_auth_session
|
||||
from app.schemas.public_ip import PublicIPCheckResponse
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
|
||||
router = APIRouter(tags=["public-ip"])
|
||||
|
||||
|
||||
@router.get("/public-ip/check", response_model=PublicIPCheckResponse)
|
||||
def run_public_ip_check(
|
||||
session: Session = Depends(get_db),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> PublicIPCheckResponse:
|
||||
if current_auth is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required")
|
||||
|
||||
result = check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
|
||||
return PublicIPCheckResponse(
|
||||
status=result.status,
|
||||
checked_at=result.checked_at,
|
||||
changed=result.changed,
|
||||
)
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings
|
||||
from app.dependencies import (
|
||||
get_app_settings,
|
||||
get_auth_db,
|
||||
get_db,
|
||||
get_current_auth_session,
|
||||
get_ticktick_client,
|
||||
)
|
||||
@@ -39,7 +39,7 @@ def start_ticktick_auth(
|
||||
@router.get("/ticktick/auth/code")
|
||||
def handle_ticktick_auth_code(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
auth_db_session: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
) -> Response:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class AuthBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||
connect_args: dict[str, object] = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
return connect_args
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_auth_engine(database_url: str):
|
||||
return create_engine(database_url, connect_args=_build_connect_args(database_url))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_auth_session_local(database_url: str):
|
||||
engine = _get_auth_engine(database_url)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_auth_engine():
|
||||
settings = get_settings()
|
||||
return _get_auth_engine(settings.app_database_url)
|
||||
|
||||
|
||||
def get_auth_session_local():
|
||||
settings = get_settings()
|
||||
return _get_auth_session_local(settings.app_database_url)
|
||||
|
||||
|
||||
def reset_auth_db_caches() -> None:
|
||||
_get_auth_session_local.cache_clear()
|
||||
_get_auth_engine.cache_clear()
|
||||
|
||||
|
||||
def get_auth_db_session() -> Generator[Session, None, None]:
|
||||
session_local = get_auth_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
+51
-13
@@ -12,9 +12,6 @@ class Settings(BaseSettings):
|
||||
app_hostname: str = "localhost:8000"
|
||||
app_database_url: str = "sqlite:///./data/app.db"
|
||||
|
||||
location_database_url: str = "sqlite:///./data/locationRecorder.db"
|
||||
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
|
||||
|
||||
ticktick_client_id: str = ""
|
||||
ticktick_client_secret: str = ""
|
||||
ticktick_token: str = ""
|
||||
@@ -23,6 +20,15 @@ class Settings(BaseSettings):
|
||||
home_assistant_auth_token: str = ""
|
||||
home_assistant_timeout_seconds: float = 1.0
|
||||
home_assistant_action_task_project_id: str = ""
|
||||
smtp_enabled: bool = False
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_username: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_name: str = ""
|
||||
smtp_from_address: str = ""
|
||||
smtp_to_address: str = ""
|
||||
smtp_use_starttls: bool = True
|
||||
poo_webhook_id: str = ""
|
||||
poo_sensor_entity_name: str = "sensor.test_poo_status"
|
||||
poo_sensor_friendly_name: str = "Poo Status"
|
||||
@@ -31,6 +37,42 @@ class Settings(BaseSettings):
|
||||
auth_session_cookie_name: str = "home_automation_session"
|
||||
auth_session_ttl_hours: int = 12
|
||||
auth_cookie_secure_override: bool | None = True
|
||||
auth_login_throttle_enabled: bool = True
|
||||
auth_trust_forwarded_for: bool = False
|
||||
auth_totp_issuer: str = "" # defaults to app_name when empty
|
||||
|
||||
# Modbus polling — global kill-switch (CONFIG_FIELDS registered in T08).
|
||||
# Off by default: polling is opt-in so a fresh deploy writes no modbus_reading
|
||||
# rows until the admin explicitly enables it (matches mqtt/discovery defaults).
|
||||
modbus_polling_enabled: bool = False
|
||||
|
||||
# MQTT broker connection (T08 wires into CONFIG_FIELDS/UI; T10 builds the client).
|
||||
mqtt_enabled: bool = False
|
||||
mqtt_broker_host: str = ""
|
||||
mqtt_broker_port: int = 1883
|
||||
mqtt_username: str = ""
|
||||
mqtt_password: str = ""
|
||||
mqtt_tls_enabled: bool = False
|
||||
|
||||
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
||||
ha_discovery_enabled: bool = False
|
||||
ha_discovery_prefix: str = "homeassistant"
|
||||
# State/availability topics use a separate prefix so they live outside the HA discovery
|
||||
# namespace. HA still receives state via the state_topic declared in the discovery config.
|
||||
ha_state_topic_prefix: str = "home_automation"
|
||||
|
||||
# DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
|
||||
# Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True.
|
||||
dsmr_ingest_enabled: bool = False
|
||||
dsmr_mqtt_topic: str = "dsmr/json"
|
||||
dsmr_sample_interval_s: int = 10
|
||||
# DSMR dual-tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak).
|
||||
# Empty string = do not subscribe (tariff-aware pricing disabled).
|
||||
dsmr_tariff_topic: str = "dsmr/meter-stats/electricity_tariff"
|
||||
|
||||
# Tibber dynamic pricing credentials (M6; only used when active contract kind=tibber).
|
||||
tibber_api_token: str = ""
|
||||
tibber_home_id: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -68,21 +110,11 @@ class Settings(BaseSettings):
|
||||
raw_path = database_url[len(prefix) :]
|
||||
return Path(raw_path)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def location_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.location_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def app_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.app_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def poo_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.poo_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def auth_cookie_secure(self) -> bool:
|
||||
@@ -90,6 +122,12 @@ class Settings(BaseSettings):
|
||||
return self.auth_cookie_secure_override
|
||||
return not self.is_development
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def effective_totp_issuer(self) -> str:
|
||||
"""The issuer label shown in Authenticator apps. Falls back to app_name."""
|
||||
return self.auth_totp_issuer.strip() or self.app_name
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from collections.abc import Generator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -10,18 +12,50 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||
connect_args: dict[str, object] = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
return connect_args
|
||||
|
||||
connect_args: dict[str, object] = {}
|
||||
if settings.location_database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(settings.location_database_url, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
@lru_cache
|
||||
def _get_engine(database_url: str) -> Engine:
|
||||
engine = create_engine(database_url, connect_args=_build_connect_args(database_url))
|
||||
if database_url.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_wal(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_session_local(database_url: str) -> sessionmaker:
|
||||
engine = _get_engine(database_url)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
return _get_engine(get_settings().app_database_url)
|
||||
|
||||
|
||||
def get_session_local() -> sessionmaker:
|
||||
return _get_session_local(get_settings().app_database_url)
|
||||
|
||||
|
||||
def reset_db_caches() -> None:
|
||||
_get_session_local.cache_clear()
|
||||
_get_engine.cache_clear()
|
||||
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
|
||||
+3
-13
@@ -3,30 +3,20 @@ from collections.abc import Generator
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import get_auth_db_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.integrations.ticktick import TickTickClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
from app.services.config_page import build_runtime_settings
|
||||
|
||||
|
||||
def get_auth_db() -> Generator[Session, None, None]:
|
||||
yield from get_auth_db_session()
|
||||
|
||||
|
||||
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
|
||||
def get_poo_db() -> Generator[Session, None, None]:
|
||||
yield from get_poo_db_session()
|
||||
def get_app_settings(session: Session = Depends(get_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
||||
@@ -39,7 +29,7 @@ def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickT
|
||||
|
||||
def get_current_auth_session(
|
||||
request: Request,
|
||||
session: Session = Depends(get_auth_db),
|
||||
session: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> AuthenticatedSession | None:
|
||||
raw_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
"""Expose framework: ExposableEntity, provider protocol, and build_catalog.
|
||||
|
||||
This module defines:
|
||||
|
||||
- ``ExposableEntity`` — a dataclass describing one publishable MQTT/HA entity.
|
||||
- Provider protocol — a simple callable ``(session) -> list[ExposableEntity]``.
|
||||
- A provider registry and registration helper.
|
||||
- ``build_catalog(session)`` — merges all registered providers' entities with
|
||||
per-key toggle state from the ``exposed_entity_toggle`` table.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- **No over-engineering**: providers are plain callables, not abstract base
|
||||
classes. This project is personal / single-user; keep it flat.
|
||||
- **Key stability**: entity keys MUST be derived from device uuid + metric key
|
||||
(e.g. ``"modbus.<uuid>.voltage"``), never from auto-increment IDs. Stable
|
||||
keys mean the toggle table survives device removal/re-addition without drift.
|
||||
- **Metadata from profile**: the modbus provider reads ``device_class``,
|
||||
``unit``, and ``component`` directly from the YAML profile's ``metrics[]``
|
||||
rather than maintaining a separate mapping.
|
||||
- **value_getter**: a callable ``() -> object | None`` or ``None`` if not yet
|
||||
implemented (T11 will wire real getters). Presence of the field lets T11
|
||||
call it without changing the dataclass interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExposableEntity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Grouping metadata that maps an entity to a logical HA device.
|
||||
|
||||
``identifiers`` corresponds to ``device.identifiers`` in HA Discovery
|
||||
config — a stable tuple used to group entities under one device card.
|
||||
``name`` is the human-readable device name (friendly_name).
|
||||
"""
|
||||
|
||||
identifiers: tuple[str, ...]
|
||||
"""Stable identifiers for HA device grouping (e.g. ``("modbus", "<uuid>")``).
|
||||
|
||||
These must not change over the device lifetime; they anchor the HA device
|
||||
card even when friendly_name changes.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Human-readable device name (may change; triggers HA discovery re-publish)."""
|
||||
|
||||
provides_availability: bool = True
|
||||
"""Whether this device publishes an availability ("online"/"offline") heartbeat.
|
||||
|
||||
When True (the default — e.g. Modbus devices, which have an ``online``
|
||||
binary_sensor driving availability), the HA Discovery config for each entity
|
||||
declares an ``availability`` topic and HA marks the entity unavailable until
|
||||
"online" is published.
|
||||
|
||||
When False (e.g. the energy-cost device, which has only sensors and no
|
||||
heartbeat source), the config omits ``availability`` so HA treats the entity
|
||||
as *always available* and shows its state as soon as one arrives. Without
|
||||
this, such entities would stay perpetually ``unavailable`` in HA even though
|
||||
their state is being published.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExposableEntity:
|
||||
"""One publishable entity in the MQTT / HA Discovery expose framework.
|
||||
|
||||
Fields
|
||||
------
|
||||
key
|
||||
Stable unique identifier for this entity. Used as the toggle table
|
||||
key and as part of the HA Discovery ``unique_id`` derivation.
|
||||
Convention: ``"<provider>.<device_uuid>.<metric_key>"``,
|
||||
e.g. ``"modbus.abc123.voltage"`` or ``"modbus.abc123.online"``.
|
||||
|
||||
component
|
||||
HA MQTT component type: ``"sensor"``, ``"binary_sensor"``, ``"switch"``, etc.
|
||||
|
||||
device
|
||||
Grouping info (DeviceInfo) that determines which HA device card this
|
||||
entity belongs to.
|
||||
|
||||
device_class
|
||||
HA ``device_class`` string (e.g. ``"voltage"``, ``"power"``, ``"None"``).
|
||||
Empty string or ``None`` means no device_class (e.g. for the online sensor).
|
||||
|
||||
unit
|
||||
Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``).
|
||||
Empty string for dimensionless.
|
||||
|
||||
name
|
||||
Human-readable entity label (friendly display name, may include device
|
||||
context, e.g. ``"SDM120 Voltage"``).
|
||||
|
||||
value_getter
|
||||
Optional callable ``(session) -> object | None`` that returns the current
|
||||
entity value given an open SQLAlchemy session. ``None`` means "not yet
|
||||
wired". T11 calls this to obtain the current state before publishing.
|
||||
Signature: ``Callable[[Session], Any]``.
|
||||
|
||||
state_class
|
||||
HA ``state_class`` string (e.g. ``"measurement"``, ``"total_increasing"``).
|
||||
``None`` means not set.
|
||||
"""
|
||||
|
||||
key: str
|
||||
component: str
|
||||
device: DeviceInfo
|
||||
device_class: Optional[str]
|
||||
unit: str
|
||||
name: str
|
||||
value_getter: Optional[Callable[["Session"], Any]] = field(default=None, repr=False)
|
||||
state_class: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EntityProvider(Protocol):
|
||||
"""Protocol for entity provider callables.
|
||||
|
||||
A provider is any callable that accepts a ``Session`` and returns a list
|
||||
of ``ExposableEntity`` objects. Using a Protocol (not ABC) keeps the
|
||||
implementation lightweight — any matching callable qualifies.
|
||||
"""
|
||||
|
||||
def __call__(self, session: Session) -> list[ExposableEntity]: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY: list[EntityProvider] = []
|
||||
|
||||
|
||||
def register_provider(provider: EntityProvider) -> EntityProvider:
|
||||
"""Register an entity provider.
|
||||
|
||||
Can be used as a decorator::
|
||||
|
||||
@register_provider
|
||||
def my_provider(session: Session) -> list[ExposableEntity]:
|
||||
...
|
||||
|
||||
Or called directly::
|
||||
|
||||
register_provider(my_provider)
|
||||
|
||||
Returns the provider unchanged (for decorator compatibility).
|
||||
"""
|
||||
_REGISTRY.append(provider)
|
||||
return provider
|
||||
|
||||
|
||||
def get_providers() -> list[EntityProvider]:
|
||||
"""Return a snapshot of the currently registered providers."""
|
||||
return list(_REGISTRY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry:
|
||||
"""An entity from the catalog, enriched with its current toggle state."""
|
||||
|
||||
entity: ExposableEntity
|
||||
enabled: bool
|
||||
"""True if this entity is currently enabled in the toggle table."""
|
||||
|
||||
|
||||
def build_catalog(session: Session) -> list[CatalogEntry]:
|
||||
"""Enumerate all entities from registered providers and attach toggle state.
|
||||
|
||||
For each entity key, the toggle state is looked up in ``exposed_entity_toggle``.
|
||||
Entities with no toggle row default to ``enabled=False``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session for DB access.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[CatalogEntry]
|
||||
All entities from all registered providers, each paired with its
|
||||
current ``enabled`` state.
|
||||
"""
|
||||
from app.models.expose import ExposedEntityToggle # local import to avoid circular
|
||||
|
||||
# Collect all entities from all providers.
|
||||
all_entities: list[ExposableEntity] = []
|
||||
for provider in _REGISTRY:
|
||||
try:
|
||||
entities = provider(session)
|
||||
all_entities.extend(entities)
|
||||
except Exception:
|
||||
logger.exception("Provider %r raised an error; skipping its entities", provider)
|
||||
|
||||
if not all_entities:
|
||||
return []
|
||||
|
||||
# Load all toggle rows in one query for efficiency.
|
||||
keys = [e.key for e in all_entities]
|
||||
toggle_rows = session.query(ExposedEntityToggle).filter(
|
||||
ExposedEntityToggle.key.in_(keys)
|
||||
).all()
|
||||
toggle_map: dict[str, bool] = {row.key: row.enabled for row in toggle_rows}
|
||||
|
||||
return [
|
||||
CatalogEntry(entity=entity, enabled=toggle_map.get(entity.key, False))
|
||||
for entity in all_entities
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modbus provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _modbus_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""Enumerate ExposableEntity objects for all enabled Modbus devices.
|
||||
|
||||
For each enabled ``ModbusDevice``:
|
||||
- Loads its YAML profile.
|
||||
- Produces one ``sensor`` entity per metric in ``profile.metrics``
|
||||
(device_class / unit / component taken from the metric spec).
|
||||
- Produces one ``binary_sensor`` entity named "online" (derived from
|
||||
``device.last_poll_ok``).
|
||||
|
||||
Entity keys follow the pattern ``"modbus.<uuid>.<metric_key>"``, which is
|
||||
stable across device renames and DB rebuilds.
|
||||
"""
|
||||
from app.models.modbus import ModbusDevice # local import
|
||||
from app.integrations.modbus.profiles import (
|
||||
load_profile,
|
||||
ProfileNotFoundError,
|
||||
ProfileValidationError,
|
||||
)
|
||||
|
||||
devices: list[ModbusDevice] = session.query(ModbusDevice).filter(
|
||||
ModbusDevice.enabled.is_(True)
|
||||
).all()
|
||||
|
||||
entities: list[ExposableEntity] = []
|
||||
|
||||
for device in devices:
|
||||
device_info = DeviceInfo(
|
||||
identifiers=("modbus", device.uuid),
|
||||
name=device.friendly_name,
|
||||
)
|
||||
|
||||
# Load the profile to get metric metadata.
|
||||
try:
|
||||
profile = load_profile(device.profile)
|
||||
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||
logger.warning(
|
||||
"Skipping device %r (uuid=%s): cannot load profile %r: %s",
|
||||
device.friendly_name,
|
||||
device.uuid,
|
||||
device.profile,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
# One sensor entity per profile metric.
|
||||
for metric in profile.metrics:
|
||||
entity_key = f"modbus.{device.uuid}.{metric.key}"
|
||||
|
||||
# Capture device id and metric key for the value_getter closure.
|
||||
# The getter queries the most recent ModbusReading for this device
|
||||
# and extracts the metric value from its payload.
|
||||
_device_id = device.id
|
||||
_metric_key = metric.key
|
||||
|
||||
def _make_value_getter(
|
||||
dev_id: int, m_key: str
|
||||
) -> Callable[["Session"], Any]:
|
||||
"""Return a getter that fetches the latest reading value from DB."""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.modbus import ModbusReading
|
||||
from sqlalchemy import desc
|
||||
|
||||
reading = (
|
||||
sess.query(ModbusReading)
|
||||
.filter(ModbusReading.device_id == dev_id)
|
||||
.order_by(desc(ModbusReading.recorded_at))
|
||||
.first()
|
||||
)
|
||||
if reading is None:
|
||||
return None
|
||||
return reading.payload.get(m_key)
|
||||
|
||||
return _getter
|
||||
|
||||
entities.append(
|
||||
ExposableEntity(
|
||||
key=entity_key,
|
||||
component=metric.ha_component,
|
||||
device=device_info,
|
||||
device_class=metric.device_class or None,
|
||||
unit=metric.unit,
|
||||
name=f"{device.friendly_name} {metric.key.replace('_', ' ').title()}",
|
||||
value_getter=_make_value_getter(_device_id, _metric_key),
|
||||
state_class=metric.state_class,
|
||||
)
|
||||
)
|
||||
|
||||
# One binary_sensor entity for device "online" status.
|
||||
online_key = f"modbus.{device.uuid}.online"
|
||||
_dev_id_online = device.id
|
||||
|
||||
def _make_online_getter(dev_id: int) -> Callable[["Session"], Any]:
|
||||
"""Return a getter that reports the device online status from DB."""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.modbus import ModbusDevice
|
||||
|
||||
dev = sess.query(ModbusDevice).filter(
|
||||
ModbusDevice.id == dev_id
|
||||
).first()
|
||||
if dev is None:
|
||||
return None
|
||||
# Map last_poll_ok to HA binary_sensor payload strings.
|
||||
if dev.last_poll_ok is True:
|
||||
return "ON"
|
||||
return "OFF"
|
||||
|
||||
return _getter
|
||||
|
||||
entities.append(
|
||||
ExposableEntity(
|
||||
key=online_key,
|
||||
component="binary_sensor",
|
||||
device=device_info,
|
||||
device_class="connectivity",
|
||||
unit="",
|
||||
name=f"{device.friendly_name} Online",
|
||||
value_getter=_make_online_getter(_dev_id_online),
|
||||
state_class=None,
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
# Register the modbus provider at module load time.
|
||||
register_provider(_modbus_provider)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Energy Cost provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||
|
||||
Produces 4 sensor entities grouped under a single HA device "Energy Cost":
|
||||
|
||||
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
||||
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
||||
- ``import_cost_total`` — cumulative import cost (total_increasing, monetary).
|
||||
- ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary).
|
||||
|
||||
Current-price algorithm (source-agnostic, with fallback)
|
||||
---------------------------------------------------------
|
||||
Strategy A: read the ``pricing`` snapshot from the most recent non-degraded
|
||||
``energy_cost_period`` row.
|
||||
|
||||
- For ``kind="tibber"``: the snapshot contains ``"buy"`` and ``"sell"`` keys
|
||||
(per-unit prices in the contract currency).
|
||||
- For ``kind="manual"``: ``"buy_normal"`` and ``"sell_normal"`` are used as
|
||||
representative effective per-unit prices. (Both tariff-slot prices differ
|
||||
only by the base rate; energy_tax and ODE are the same for both, so
|
||||
buy_normal is the higher/conservative single representative value.)
|
||||
|
||||
When no non-degraded period exists or the pricing snapshot lacks the
|
||||
expected keys, ``value_getter`` returns ``None`` (``publish_states``
|
||||
skips None values automatically).
|
||||
|
||||
Cumulative totals
|
||||
-----------------
|
||||
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
||||
``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded
|
||||
to avoid double-counting when they are later overwritten by real values.
|
||||
|
||||
Currency
|
||||
--------
|
||||
Taken from the most recent non-degraded period's ``currency`` column.
|
||||
Falls back to ``"EUR"`` when no such row exists.
|
||||
|
||||
Key convention
|
||||
--------------
|
||||
Fixed stable string keys (not derived from any mutable field or DB id):
|
||||
- ``"energy.buy_price_now"``
|
||||
- ``"energy.sell_price_now"``
|
||||
- ``"energy.import_cost_total"``
|
||||
- ``"energy.export_revenue_total"``
|
||||
|
||||
DeviceInfo identifiers
|
||||
----------------------
|
||||
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that
|
||||
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid
|
||||
(the service uses index [1] as the node_id throughout).
|
||||
"""
|
||||
from app.models.energy import EnergyCostPeriod # local import to avoid circular
|
||||
|
||||
# --- Determine currency and representative pricing from the latest non-degraded row ---
|
||||
|
||||
latest_period: EnergyCostPeriod | None = (
|
||||
session.query(EnergyCostPeriod)
|
||||
.filter(EnergyCostPeriod.degraded.is_(False))
|
||||
.order_by(EnergyCostPeriod.period_start.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
currency: str = "EUR"
|
||||
if latest_period is not None and latest_period.currency:
|
||||
currency = latest_period.currency
|
||||
|
||||
# --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) ---
|
||||
# provides_availability=False: the energy-cost device has only sensors and no
|
||||
# online/offline heartbeat, so its entities must be "always available" in HA.
|
||||
# (Otherwise HA shows them unavailable despite state being published.)
|
||||
device_info = DeviceInfo(
|
||||
identifiers=("energy-cost", "energy-cost"),
|
||||
name="Energy Cost",
|
||||
provides_availability=False,
|
||||
)
|
||||
|
||||
# --- value_getter: current buy price ---
|
||||
|
||||
def _make_buy_price_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the current buy price per kWh.
|
||||
|
||||
For ``kind="manual"`` contracts the getter selects the price slot that
|
||||
matches the current DSMR electricity tariff:
|
||||
|
||||
- tariff == 1 (dal / off-peak) → ``buy_dal``
|
||||
- tariff == 2 (normal / peak) → ``buy_normal``
|
||||
- tariff is None / unknown → ``buy_normal`` (safe fallback = current status quo)
|
||||
|
||||
For ``kind="tibber"`` (hourly dynamic pricing) the tariff slot is not
|
||||
applicable; the getter always uses the ``buy`` key from the snapshot.
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.dsmr_ingest import get_current_tariff
|
||||
|
||||
period = (
|
||||
sess.query(_ECP)
|
||||
.filter(_ECP.degraded.is_(False))
|
||||
.order_by(_ECP.period_start.desc())
|
||||
.first()
|
||||
)
|
||||
if period is None or not period.pricing:
|
||||
return None
|
||||
snap = period.pricing
|
||||
kind = snap.get("kind")
|
||||
if kind == "tibber":
|
||||
raw = snap.get("buy")
|
||||
elif kind == "manual":
|
||||
tariff = get_current_tariff()
|
||||
if tariff == 1:
|
||||
raw = snap.get("buy_dal")
|
||||
else:
|
||||
# tariff == 2, None, or any unexpected value → normal (peak) rate.
|
||||
raw = snap.get("buy_normal")
|
||||
else:
|
||||
# Unknown kind — attempt common keys gracefully.
|
||||
raw = snap.get("buy") or snap.get("buy_normal")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: current sell price ---
|
||||
|
||||
def _make_sell_price_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the current sell price per kWh.
|
||||
|
||||
For ``kind="manual"`` contracts the getter selects the price slot that
|
||||
matches the current DSMR electricity tariff:
|
||||
|
||||
- tariff == 1 (dal / off-peak) → ``sell_dal``
|
||||
- tariff == 2 (normal / peak) → ``sell_normal``
|
||||
- tariff is None / unknown → ``sell_normal`` (safe fallback = current status quo)
|
||||
|
||||
For ``kind="tibber"`` the tariff slot is not applicable; always uses ``sell``.
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.dsmr_ingest import get_current_tariff
|
||||
|
||||
period = (
|
||||
sess.query(_ECP)
|
||||
.filter(_ECP.degraded.is_(False))
|
||||
.order_by(_ECP.period_start.desc())
|
||||
.first()
|
||||
)
|
||||
if period is None or not period.pricing:
|
||||
return None
|
||||
snap = period.pricing
|
||||
kind = snap.get("kind")
|
||||
if kind == "tibber":
|
||||
raw = snap.get("sell")
|
||||
elif kind == "manual":
|
||||
tariff = get_current_tariff()
|
||||
if tariff == 1:
|
||||
raw = snap.get("sell_dal")
|
||||
else:
|
||||
# tariff == 2, None, or any unexpected value → normal (peak) rate.
|
||||
raw = snap.get("sell_normal")
|
||||
else:
|
||||
raw = snap.get("sell") or snap.get("sell_normal")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: cumulative import cost (incl. standing charges) ---
|
||||
|
||||
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
||||
|
||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
|
||||
where ``anchor_utc`` is the **max** of:
|
||||
- the earliest version's effective_from (contract billing start), and
|
||||
- the earliest non-degraded EnergyCostPeriod.period_start (recording start).
|
||||
|
||||
This prevents counting fixed costs for the period before any data was recorded
|
||||
(e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not
|
||||
want to include ~€270 of standing charges for a period with no meter data).
|
||||
|
||||
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
|
||||
|
||||
Returns None when no non-degraded period exists.
|
||||
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from datetime import UTC, datetime as _dt
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.services.contracts import _as_utc as _cu
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
# Quick check: any non-degraded period at all? (avoids full summarize overhead
|
||||
# when there are zero periods, which should return None)
|
||||
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
# No active contract — return pure SUM(import_cost) without standing charges.
|
||||
return float(has_data)
|
||||
|
||||
# anchor = max(contract billing start, earliest recording start).
|
||||
# This prevents accumulating fixed costs for days with no meter data.
|
||||
contract_anchor_utc = _cu(versions[0].effective_from)
|
||||
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if earliest_period_start is not None:
|
||||
recording_anchor_utc = _cu(earliest_period_start)
|
||||
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
|
||||
else:
|
||||
anchor_utc = contract_anchor_utc
|
||||
|
||||
now_utc = _dt.now(UTC)
|
||||
|
||||
result = _summarize(sess, anchor_utc, now_utc)
|
||||
return result["metered_import"] + result["fixed_costs"]
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: cumulative export revenue (incl. tax credit) ---
|
||||
|
||||
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
||||
|
||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
|
||||
Anchor is the same max(contract_start, recording_start) logic as import getter.
|
||||
|
||||
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
||||
|
||||
Returns None when no non-degraded period exists in [anchor, now].
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from datetime import UTC, datetime as _dt
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.services.contracts import _as_utc as _cu
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
# Quick check: any non-degraded period at all?
|
||||
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
# No active contract — return pure SUM(export_revenue) without credits.
|
||||
return float(has_data)
|
||||
|
||||
# anchor = max(contract billing start, earliest recording start).
|
||||
contract_anchor_utc = _cu(versions[0].effective_from)
|
||||
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if earliest_period_start is not None:
|
||||
recording_anchor_utc = _cu(earliest_period_start)
|
||||
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
|
||||
else:
|
||||
anchor_utc = contract_anchor_utc
|
||||
|
||||
now_utc = _dt.now(UTC)
|
||||
|
||||
result = _summarize(sess, anchor_utc, now_utc)
|
||||
return result["metered_export"] + result["credits"]
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: today's import cost (local-day window, resets at local midnight) ---
|
||||
|
||||
def _make_import_cost_today_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for today's import cost (metered + standing for today).
|
||||
|
||||
Window: [local today 00:00, local tomorrow 00:00) in UTC.
|
||||
After local midnight the window rolls to the new day → value resets to that
|
||||
day's running cost, implementing "resets at local midnight" semantics for HA.
|
||||
|
||||
Uses timezone module via module-attribute access to preserve monkeypatch safety.
|
||||
Returns None when no non-degraded period exists or no active contract.
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.services import timezone as _tz_mod
|
||||
from sqlalchemy import func as _func
|
||||
from datetime import timedelta as _td
|
||||
|
||||
# Quick check: any non-degraded period at all?
|
||||
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
||||
today_local = _tz_mod.local_now().date()
|
||||
tomorrow_local = today_local + _td(days=1)
|
||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||
|
||||
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
|
||||
return result["metered_import"] + result["fixed_costs"]
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: today's export revenue (local-day window, resets at local midnight) ---
|
||||
|
||||
def _make_export_revenue_today_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for today's export revenue (metered + tax credit for today).
|
||||
|
||||
Same window semantics as import_cost_today.
|
||||
Returns None when no non-degraded period exists or no active contract.
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.services import timezone as _tz_mod
|
||||
from sqlalchemy import func as _func
|
||||
from datetime import timedelta as _td
|
||||
|
||||
# Quick check: any non-degraded period at all?
|
||||
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
today_local = _tz_mod.local_now().date()
|
||||
tomorrow_local = today_local + _td(days=1)
|
||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||
|
||||
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
|
||||
return result["metered_export"] + result["credits"]
|
||||
|
||||
return _getter
|
||||
|
||||
# Price unit string: "<currency>/kWh"
|
||||
price_unit = f"{currency}/kWh"
|
||||
|
||||
entities: list[ExposableEntity] = [
|
||||
ExposableEntity(
|
||||
key="energy.buy_price_now",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class=None,
|
||||
unit=price_unit,
|
||||
name="Energy Buy Price Now",
|
||||
value_getter=_make_buy_price_getter(),
|
||||
state_class="measurement",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.sell_price_now",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class=None,
|
||||
unit=price_unit,
|
||||
name="Energy Sell Price Now",
|
||||
value_getter=_make_sell_price_getter(),
|
||||
state_class="measurement",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.import_cost_total",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Import Cost (incl. standing)",
|
||||
value_getter=_make_import_cost_getter(),
|
||||
state_class="total",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.export_revenue_total",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Export Revenue (incl. tax credit)",
|
||||
value_getter=_make_export_revenue_getter(),
|
||||
state_class="total",
|
||||
),
|
||||
# Daily entities: reset at local midnight, state_class=total_increasing.
|
||||
#
|
||||
# device_class=monetary is correct here. The HA developer docs (long-term
|
||||
# statistics section) prohibit MONETARY from being combined with
|
||||
# state_class=measurement — they do NOT prohibit it with total or
|
||||
# total_increasing. monetary+total_increasing is valid (the cumulative
|
||||
# entities above use monetary+total with no issue).
|
||||
#
|
||||
# total_increasing is appropriate because these daily quantities are
|
||||
# non-negative (import/export metered cost ≥ 0, fixed standing fee/tax
|
||||
# credit ≥ 0) and monotonically non-decreasing within any given local day.
|
||||
# When the local midnight rolls over the value drops back to ~1 day's fixed
|
||||
# fee; HA interprets that decrease as a new cycle, giving correct daily
|
||||
# aggregation without any explicit last_reset pipeline.
|
||||
ExposableEntity(
|
||||
key="energy.import_cost_today",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Import Cost Today (incl. standing)",
|
||||
value_getter=_make_import_cost_today_getter(),
|
||||
state_class="total_increasing",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.export_revenue_today",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Export Revenue Today (incl. tax credit)",
|
||||
value_getter=_make_export_revenue_today_getter(),
|
||||
state_class="total_increasing",
|
||||
),
|
||||
]
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
# Register the energy cost provider at module load time.
|
||||
register_provider(_energy_cost_provider)
|
||||
@@ -0,0 +1 @@
|
||||
"""Modbus integration package — driver, profile loader, and decoder."""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Modbus TCP driver — thin wrapper around pymodbus.
|
||||
|
||||
This module provides a single public function ``read_blocks`` that performs
|
||||
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
|
||||
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- **Only read function codes** (FC03/FC04) are exposed. There is no write path.
|
||||
- float32 decoding uses ``struct.unpack('>f', ...)`` directly rather than the
|
||||
pymodbus ``BinaryPayloadDecoder`` helper, which has had API churn across 3.x
|
||||
releases. Big-endian word order + big-endian byte order means the 4 raw bytes
|
||||
are already in standard network (big-endian) order.
|
||||
- ``ModbusTcpClient`` is used in synchronous mode (``connect()`` / ``close()``).
|
||||
The client is created fresh per call; this keeps the driver stateless and
|
||||
avoids threading concerns at the cost of one TCP handshake per read cycle.
|
||||
APScheduler jobs call this from a thread pool, so stateless is safer.
|
||||
- pymodbus 3.13.x uses ``device_id=`` as the slave-address keyword argument
|
||||
(renamed from ``slave=`` in earlier 3.x releases).
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
``ModbusDriverError``
|
||||
Base exception for all driver-level errors.
|
||||
``ModbusConnectionError``
|
||||
Raised when the TCP connection to the gateway cannot be established or is
|
||||
lost during the read.
|
||||
``ModbusResponseError``
|
||||
Raised when the gateway responds with a Modbus exception frame or when the
|
||||
returned register count does not match the requested count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from pymodbus.exceptions import ConnectionException, ModbusException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusDriverError(RuntimeError):
|
||||
"""Base class for all Modbus driver errors."""
|
||||
|
||||
|
||||
class ModbusConnectionError(ModbusDriverError):
|
||||
"""Cannot connect to (or lost connection with) the Modbus TCP gateway."""
|
||||
|
||||
|
||||
class ModbusResponseError(ModbusDriverError):
|
||||
"""Modbus gateway returned an exception frame or an unexpected response."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block definition type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A single read block: ``{"start": int, "count": int}``.
|
||||
Block = dict[str, int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_blocks(
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
blocks: Sequence[Block],
|
||||
*,
|
||||
timeout: float = 3.0,
|
||||
) -> dict[int, int]:
|
||||
"""Read one or more contiguous register blocks via FC04 (input registers).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
host:
|
||||
Hostname or IP address of the Modbus TCP gateway.
|
||||
port:
|
||||
TCP port (typically 502).
|
||||
unit_id:
|
||||
Modbus slave / unit address (the device's Meter ID for SDM120).
|
||||
blocks:
|
||||
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
||||
contiguous register ranges to read. ``count`` is the number of
|
||||
16-bit registers (not bytes).
|
||||
timeout:
|
||||
TCP connect/read timeout in seconds (default 3 s).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[int, int]
|
||||
Flat mapping of ``{register_address: 16-bit_register_value}`` for
|
||||
every register returned across all blocks.
|
||||
|
||||
Raises
|
||||
------
|
||||
ModbusConnectionError
|
||||
If the TCP connection to the gateway fails.
|
||||
ModbusResponseError
|
||||
If the gateway returns a Modbus exception frame or an unexpected
|
||||
number of registers.
|
||||
"""
|
||||
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||
try:
|
||||
connected = client.connect()
|
||||
if not connected:
|
||||
raise ModbusConnectionError(
|
||||
f"Could not connect to Modbus gateway at {host}:{port}"
|
||||
)
|
||||
|
||||
registers: dict[int, int] = {}
|
||||
for block in blocks:
|
||||
start: int = block["start"]
|
||||
count: int = block["count"]
|
||||
_read_block(client, unit_id, start, count, registers)
|
||||
|
||||
return registers
|
||||
|
||||
except ConnectionException as exc:
|
||||
raise ModbusConnectionError(
|
||||
f"Connection to Modbus gateway {host}:{port} failed: {exc}"
|
||||
) from exc
|
||||
except ModbusException as exc:
|
||||
raise ModbusResponseError(f"Modbus protocol error: {exc}") from exc
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _read_block(
|
||||
client: ModbusTcpClient,
|
||||
unit_id: int,
|
||||
start: int,
|
||||
count: int,
|
||||
result: dict[int, int],
|
||||
) -> None:
|
||||
"""Read one block and merge into *result*. Raises on any error."""
|
||||
try:
|
||||
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||
except ConnectionException as exc:
|
||||
raise ModbusConnectionError(
|
||||
f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}"
|
||||
) from exc
|
||||
|
||||
if response.isError():
|
||||
raise ModbusResponseError(
|
||||
f"Modbus exception reading registers 0x{start:04X}+{count}: {response}"
|
||||
)
|
||||
|
||||
regs = response.registers
|
||||
if len(regs) != count:
|
||||
raise ModbusResponseError(
|
||||
f"Expected {count} registers from 0x{start:04X}, got {len(regs)}"
|
||||
)
|
||||
|
||||
for offset, value in enumerate(regs):
|
||||
result[start + offset] = value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoding helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def registers_to_float(hi_reg: int, lo_reg: int) -> float:
|
||||
"""Decode two 16-bit registers to a big-endian IEEE-754 float32.
|
||||
|
||||
The SDM120 (and most Modbus float devices) use big-endian word order:
|
||||
the high 16-bit register comes first, then the low register. Combined
|
||||
with big-endian byte order within each register, the raw 4-byte sequence
|
||||
is standard network-byte-order (big-endian) float32.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hi_reg:
|
||||
The first (higher-address-range, most-significant) 16-bit register.
|
||||
lo_reg:
|
||||
The second (lower-address-range, least-significant) 16-bit register.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
The decoded IEEE-754 single-precision floating-point value.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> registers_to_float(0x4366, 0x3334)
|
||||
230.20001220703125
|
||||
"""
|
||||
raw = struct.pack(">HH", hi_reg, lo_reg)
|
||||
return struct.unpack(">f", raw)[0]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Modbus profile loader, validator, and decoder.
|
||||
|
||||
A *profile* is a YAML file that describes the protocol-level knowledge for
|
||||
a particular Modbus device model:
|
||||
|
||||
- Which registers to read (``blocks`` for efficient bulk reads).
|
||||
- How to decode each quantity (``metrics``: register address, type, unit, …).
|
||||
- Home Assistant metadata per metric (``device_class``, ``ha_component``, …).
|
||||
|
||||
Profiles live in ``app/integrations/modbus/profiles/<name>.yaml`` and are
|
||||
located at runtime relative to *this file* (not CWD), so they work whether
|
||||
the package is installed as a wheel, run in-process, or invoked via
|
||||
``python -m scripts.modbus_cli``.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- **Purely data + functions** — no abstract base classes or inheritance.
|
||||
- ``ModbusProfile`` and ``MetricSpec`` are Pydantic models: they validate on
|
||||
construction and raise ``pydantic.ValidationError`` for malformed YAML.
|
||||
- ``decode`` uses ``driver.registers_to_float`` for float32 quantities.
|
||||
Unsupported types are silently skipped with a warning (future-proofing for
|
||||
int16/uint16 etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.integrations.modbus.driver import registers_to_float
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directory containing the YAML profiles, located relative to *this* file.
|
||||
_PROFILES_DIR = Path(__file__).parent / "profiles"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models for profile validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MetricSpec(BaseModel):
|
||||
"""Specification for a single measurable quantity within a profile."""
|
||||
|
||||
key: str
|
||||
"""Stable identifier used as the key in decoded payloads (e.g. ``"voltage"``)."""
|
||||
|
||||
address: int
|
||||
"""Starting Modbus register address (0-based, e.g. ``0x0000`` for voltage)."""
|
||||
|
||||
type: str
|
||||
"""Encoding type — currently only ``"float32"`` is supported."""
|
||||
|
||||
unit: str
|
||||
"""Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``). Empty string for dimensionless."""
|
||||
|
||||
device_class: str
|
||||
"""Home Assistant device class (e.g. ``"voltage"``, ``"power"``, ``"energy"``)."""
|
||||
|
||||
ha_component: str
|
||||
"""Home Assistant component type (e.g. ``"sensor"``)."""
|
||||
|
||||
state_class: Optional[str] = None
|
||||
"""Home Assistant state class, if applicable (e.g. ``"total_increasing"``)."""
|
||||
|
||||
|
||||
class BlockSpec(BaseModel):
|
||||
"""A contiguous register block for bulk reading."""
|
||||
|
||||
start: int
|
||||
"""First register address in the block."""
|
||||
|
||||
count: int
|
||||
"""Number of 16-bit registers to read."""
|
||||
|
||||
|
||||
class ModbusProfile(BaseModel):
|
||||
"""Complete description of a Modbus device's protocol-level knowledge."""
|
||||
|
||||
name: str
|
||||
"""Short identifier matching the YAML file name (e.g. ``"sdm120"``)."""
|
||||
|
||||
description: str
|
||||
"""Human-readable description of the device."""
|
||||
|
||||
function_code: int
|
||||
"""Modbus function code for reading measurements (4 = FC04 input registers)."""
|
||||
|
||||
word_order: str
|
||||
"""Word (register) order: ``"big"`` means high register first."""
|
||||
|
||||
byte_order: str
|
||||
"""Byte order within each register: ``"big"`` means standard network order."""
|
||||
|
||||
blocks: list[BlockSpec]
|
||||
"""Register blocks to read in bulk, reducing Modbus transaction count."""
|
||||
|
||||
metrics: list[MetricSpec]
|
||||
"""List of individual measurable quantities and their decoding rules."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfileNotFoundError(FileNotFoundError):
|
||||
"""Raised when the requested profile YAML file does not exist."""
|
||||
|
||||
|
||||
class ProfileValidationError(ValueError):
|
||||
"""Raised when a profile YAML file fails Pydantic validation."""
|
||||
|
||||
|
||||
def load_profile(name: str) -> ModbusProfile:
|
||||
"""Load and validate a Modbus device profile by name.
|
||||
|
||||
The profile file is expected at
|
||||
``app/integrations/modbus/profiles/<name>.yaml``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Profile name without extension (e.g. ``"sdm120"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ModbusProfile
|
||||
Validated profile model.
|
||||
|
||||
Raises
|
||||
------
|
||||
ProfileNotFoundError
|
||||
If ``profiles/<name>.yaml`` does not exist.
|
||||
ProfileValidationError
|
||||
If the YAML is syntactically valid but the content fails schema
|
||||
validation (missing required fields, wrong types, etc.).
|
||||
"""
|
||||
path = _PROFILES_DIR / f"{name}.yaml"
|
||||
if not path.exists():
|
||||
raise ProfileNotFoundError(
|
||||
f"Modbus profile '{name}' not found (looked for {path})"
|
||||
)
|
||||
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh)
|
||||
|
||||
try:
|
||||
profile = ModbusProfile.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise ProfileValidationError(
|
||||
f"Profile '{name}' failed validation: {exc}"
|
||||
) from exc
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
def list_profiles() -> list[tuple[str, str]]:
|
||||
"""Return ``(name, description)`` pairs for all available profiles.
|
||||
|
||||
Scans ``app/integrations/modbus/profiles/*.yaml``, loads each, and
|
||||
returns a sorted list of ``(name, description)`` tuples. Profiles that
|
||||
fail to load are skipped with a warning.
|
||||
"""
|
||||
results: list[tuple[str, str]] = []
|
||||
if not _PROFILES_DIR.exists():
|
||||
return results
|
||||
|
||||
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
|
||||
name = path.stem
|
||||
try:
|
||||
profile = load_profile(name)
|
||||
results.append((profile.name, profile.description))
|
||||
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
|
||||
logger.warning("Skipping malformed profile '%s': %s", name, exc)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def decode(profile: ModbusProfile, registers: dict[int, int]) -> dict[str, float]:
|
||||
"""Decode raw register values into a mapping of ``{key: float_value}``.
|
||||
|
||||
For each metric in *profile*, the function looks up the two registers at
|
||||
``address`` and ``address + 1`` in *registers* and decodes them as a
|
||||
big-endian float32 (high register first).
|
||||
|
||||
Metrics whose registers are absent from *registers* are skipped (not
|
||||
an error — this can happen if a block was not requested or a device does
|
||||
not populate that register). Metrics with unsupported ``type`` values
|
||||
are also skipped.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
profile:
|
||||
Validated ``ModbusProfile`` describing the device.
|
||||
registers:
|
||||
Mapping of ``{register_address: 16-bit_value}`` as returned by
|
||||
``driver.read_blocks``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, float]
|
||||
Decoded engineering values keyed by each metric's ``key`` field.
|
||||
"""
|
||||
result: dict[str, float] = {}
|
||||
for metric in profile.metrics:
|
||||
if metric.type == "float32":
|
||||
hi_addr = metric.address
|
||||
lo_addr = metric.address + 1
|
||||
if hi_addr not in registers or lo_addr not in registers:
|
||||
logger.debug(
|
||||
"Skipping metric '%s': registers 0x%04X/0x%04X not available",
|
||||
metric.key,
|
||||
hi_addr,
|
||||
lo_addr,
|
||||
)
|
||||
continue
|
||||
value = registers_to_float(registers[hi_addr], registers[lo_addr])
|
||||
result[metric.key] = value
|
||||
else:
|
||||
logger.warning(
|
||||
"Metric '%s' has unsupported type '%s', skipping",
|
||||
metric.key,
|
||||
metric.type,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,74 @@
|
||||
name: sdm120
|
||||
description: Eastron SDM120 single-phase energy meter
|
||||
function_code: 4 # input registers (FC04)
|
||||
word_order: big # high register first (big-endian word order)
|
||||
byte_order: big # high byte first within each register
|
||||
|
||||
blocks:
|
||||
# Registers 30001–30095 (0x0000–0x005F): voltage through max export demand
|
||||
- { start: 0x0000, count: 0x0060 }
|
||||
# Registers 30343–30346 (0x0156–0x0159): total active/reactive energy
|
||||
- { start: 0x0156, count: 0x0004 }
|
||||
|
||||
metrics:
|
||||
# Core measurements — addresses from SDM120 Modbus Protocol §4 (FC04 input registers)
|
||||
# Each float32 occupies two consecutive 16-bit registers; address = Hex start in §4.
|
||||
|
||||
- key: voltage
|
||||
address: 0x0000 # register 30001 — Voltage (V)
|
||||
type: float32
|
||||
unit: "V"
|
||||
device_class: voltage
|
||||
ha_component: sensor
|
||||
|
||||
- key: current
|
||||
address: 0x0006 # register 30007 — Current (A)
|
||||
type: float32
|
||||
unit: "A"
|
||||
device_class: current
|
||||
ha_component: sensor
|
||||
|
||||
- key: active_power
|
||||
address: 0x000C # register 30013 — Active power (W)
|
||||
type: float32
|
||||
unit: "W"
|
||||
device_class: power
|
||||
ha_component: sensor
|
||||
|
||||
- key: power_factor
|
||||
address: 0x001E # register 30031 — Power factor (dimensionless)
|
||||
type: float32
|
||||
unit: ""
|
||||
device_class: power_factor
|
||||
ha_component: sensor
|
||||
|
||||
- key: frequency
|
||||
address: 0x0046 # register 30071 — Frequency (Hz)
|
||||
type: float32
|
||||
unit: "Hz"
|
||||
device_class: frequency
|
||||
ha_component: sensor
|
||||
|
||||
- key: import_energy
|
||||
address: 0x0048 # register 30073 — Import active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
|
||||
- key: export_energy
|
||||
address: 0x004A # register 30075 — Export active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
|
||||
- key: total_energy
|
||||
address: 0x0156 # register 30343 — Total active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
@@ -0,0 +1,341 @@
|
||||
"""MQTT client integration (paho-mqtt 2.x).
|
||||
|
||||
Provides :class:`MqttManager`: a long-lived MQTT client that runs paho's
|
||||
loop in a background thread. Designed to be started in FastAPI's lifespan
|
||||
and shared across the application as a module-level singleton (``mqtt_manager``).
|
||||
|
||||
Key design decisions
|
||||
--------------------
|
||||
- **paho 2.x API**: ``Client`` requires ``CallbackAPIVersion.VERSION2`` as the
|
||||
first positional argument. VERSION2 on_connect callback receives
|
||||
``(client, userdata, connect_flags, reason_code, properties)`` — not the
|
||||
older RC int.
|
||||
- **Background thread**: ``loop_start()`` spawns a daemon thread; ``loop_stop()``
|
||||
joins it cleanly on shutdown.
|
||||
- **Never crash the process**: all paho operations are wrapped in try/except;
|
||||
connection failure is logged but does not propagate to the caller.
|
||||
- **Password safety**: the MQTT password is never passed to ``logger`` calls.
|
||||
- **Reconnect**: ``reconnect(settings)`` tears down the old client and
|
||||
establishes a fresh connection with the new settings. Callers (e.g. PUT
|
||||
/api/config) call this after saving MQTT-related config values.
|
||||
- **Subscribe**: ``subscribe(topic, handler)`` registers a topic → handler
|
||||
mapping. Subscriptions are re-established automatically on reconnect.
|
||||
The ``on_message`` callback dispatches incoming payloads to the registered
|
||||
handler; handler exceptions are caught and logged so they never crash the
|
||||
paho loop thread or the network connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MQTT settings keys that, when changed, should trigger a reconnect.
|
||||
MQTT_SETTINGS_KEYS = {
|
||||
"mqtt_enabled",
|
||||
"mqtt_broker_host",
|
||||
"mqtt_broker_port",
|
||||
"mqtt_username",
|
||||
"mqtt_password",
|
||||
"mqtt_tls_enabled",
|
||||
}
|
||||
|
||||
|
||||
def _is_configured(settings: Settings) -> bool:
|
||||
"""Return True if MQTT is enabled *and* the broker host is set."""
|
||||
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
|
||||
|
||||
|
||||
class MqttManager:
|
||||
"""Long-lived MQTT client wrapper.
|
||||
|
||||
Lifecycle
|
||||
---------
|
||||
1. ``connect(settings)`` — start background loop + connect to broker.
|
||||
2. ``publish(...)`` — publish messages while connected.
|
||||
3. ``disconnect()`` — graceful teardown (called on app shutdown).
|
||||
4. ``reconnect(settings)`` — disconnect then re-connect with new settings
|
||||
(called after saving updated MQTT configuration).
|
||||
|
||||
When MQTT is not enabled or not configured, all methods are no-ops.
|
||||
Connection failures are caught and logged; they do not raise.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: mqtt.Client | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._connected = False
|
||||
# topic → handler registry; persists across reconnects so subscriptions
|
||||
# are automatically re-established when the client reconnects.
|
||||
self._subscriptions: dict[str, Callable[[bytes], None]] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""True if the underlying paho client is currently connected."""
|
||||
return self._connected
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_configured(self, settings: Settings) -> bool:
|
||||
"""Return True if MQTT is enabled and broker host is configured."""
|
||||
return _is_configured(settings)
|
||||
|
||||
def connect(self, settings: Settings) -> None:
|
||||
"""Connect to the MQTT broker and start the background loop thread.
|
||||
|
||||
No-op if MQTT is not enabled or broker host is not set.
|
||||
Connection errors are logged but do not raise.
|
||||
"""
|
||||
if not _is_configured(settings):
|
||||
logger.debug("MQTT not configured or not enabled — skipping connect.")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._start_client(settings)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the broker and stop the background loop thread.
|
||||
|
||||
No-op if no client is active.
|
||||
"""
|
||||
with self._lock:
|
||||
self._stop_client()
|
||||
|
||||
def reconnect(self, settings: Settings) -> None:
|
||||
"""Disconnect the current client (if any) and reconnect with *settings*.
|
||||
|
||||
Call this after saving updated MQTT configuration values.
|
||||
No-op if MQTT is not enabled or broker host is not set.
|
||||
"""
|
||||
with self._lock:
|
||||
self._stop_client()
|
||||
self.connect(settings)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: str | bytes | None,
|
||||
*,
|
||||
retain: bool = False,
|
||||
qos: int = 0,
|
||||
) -> None:
|
||||
"""Publish a message to *topic*.
|
||||
|
||||
If the client is not connected the call is silently skipped.
|
||||
Errors are logged but do not raise.
|
||||
"""
|
||||
with self._lock:
|
||||
client = self._client
|
||||
|
||||
if client is None or not self._connected:
|
||||
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
|
||||
return
|
||||
|
||||
try:
|
||||
client.publish(topic, payload=payload, qos=qos, retain=retain)
|
||||
except Exception:
|
||||
logger.exception("MQTT publish error (topic=%s).", topic)
|
||||
|
||||
def subscribe(self, topic: str, handler: Callable[[bytes], None]) -> None:
|
||||
"""Register *handler* to be called when a message arrives on *topic*.
|
||||
|
||||
If the client is already connected, the subscription is sent to the
|
||||
broker immediately. Otherwise it is queued and will be established
|
||||
the next time ``_on_connect`` fires (including after a reconnect).
|
||||
|
||||
Handler exceptions are swallowed by the ``on_message`` dispatcher so
|
||||
that a buggy handler can never crash the paho loop thread.
|
||||
"""
|
||||
self._subscriptions[topic] = handler
|
||||
# Grab the client reference outside the lock to avoid holding the lock
|
||||
# while calling back into paho (which may itself acquire internal locks).
|
||||
with self._lock:
|
||||
client = self._client
|
||||
connected = self._connected
|
||||
|
||||
if client is not None and connected:
|
||||
try:
|
||||
client.subscribe(topic)
|
||||
logger.debug("MQTT subscribed to topic=%s (immediate).", topic)
|
||||
except Exception:
|
||||
logger.exception("MQTT subscribe error (topic=%s).", topic)
|
||||
|
||||
def unsubscribe(self, topic: str) -> None:
|
||||
"""Remove the handler for *topic* and unsubscribe from the broker.
|
||||
|
||||
Idempotent: unknown topics are ignored. Used to apply config changes
|
||||
without a restart (e.g. when DSMR ingest is turned off or its topic
|
||||
changes). If the client is connected, the broker unsubscribe is sent
|
||||
immediately; either way the handler is removed from the registry so it
|
||||
will not be re-subscribed on the next ``_on_connect``.
|
||||
"""
|
||||
self._subscriptions.pop(topic, None)
|
||||
with self._lock:
|
||||
client = self._client
|
||||
connected = self._connected
|
||||
|
||||
if client is not None and connected:
|
||||
try:
|
||||
client.unsubscribe(topic)
|
||||
logger.debug("MQTT unsubscribed from topic=%s.", topic)
|
||||
except Exception:
|
||||
logger.exception("MQTT unsubscribe error (topic=%s).", topic)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers — must be called with self._lock held
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_client(self, settings: Settings) -> None:
|
||||
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id="home-automation",
|
||||
)
|
||||
|
||||
# Callbacks — VERSION2 on_connect signature:
|
||||
# (client, userdata, connect_flags, reason_code, properties)
|
||||
def _on_connect(
|
||||
_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
logger.warning("MQTT connection refused: %s", reason_code)
|
||||
self._connected = False
|
||||
else:
|
||||
logger.info("MQTT connected (reason_code=%s).", reason_code)
|
||||
self._connected = True
|
||||
# Re-establish all registered subscriptions after (re-)connect.
|
||||
for sub_topic in self._subscriptions:
|
||||
try:
|
||||
_client.subscribe(sub_topic)
|
||||
logger.debug("MQTT re-subscribed to topic=%s.", sub_topic)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"MQTT re-subscribe failed (topic=%s).", sub_topic
|
||||
)
|
||||
|
||||
# VERSION2 on_disconnect signature:
|
||||
# (client, userdata, disconnect_flags, reason_code, properties)
|
||||
def _on_disconnect(
|
||||
_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_disconnect_flags: mqtt.DisconnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None,
|
||||
) -> None:
|
||||
self._connected = False
|
||||
if reason_code.is_failure:
|
||||
logger.warning("MQTT disconnected unexpectedly (reason_code=%s).", reason_code)
|
||||
else:
|
||||
logger.info("MQTT disconnected cleanly.")
|
||||
|
||||
# VERSION2 on_message signature: (client, userdata, message)
|
||||
def _on_message(
|
||||
_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
message: mqtt.MQTTMessage,
|
||||
) -> None:
|
||||
handler = self._subscriptions.get(message.topic)
|
||||
if handler is None:
|
||||
logger.debug(
|
||||
"MQTT on_message: no handler for topic=%s.", message.topic
|
||||
)
|
||||
return
|
||||
try:
|
||||
handler(message.payload)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"MQTT message handler raised for topic=%s (swallowed).",
|
||||
message.topic,
|
||||
)
|
||||
|
||||
client.on_connect = _on_connect
|
||||
client.on_disconnect = _on_disconnect
|
||||
client.on_message = _on_message
|
||||
|
||||
# TLS
|
||||
if settings.mqtt_tls_enabled:
|
||||
try:
|
||||
client.tls_set()
|
||||
except Exception:
|
||||
logger.exception("MQTT TLS setup failed.")
|
||||
return
|
||||
|
||||
# Credentials — never log the password
|
||||
if settings.mqtt_username:
|
||||
client.username_pw_set(
|
||||
username=settings.mqtt_username,
|
||||
password=settings.mqtt_password or None,
|
||||
)
|
||||
|
||||
# Start background loop thread *before* connect so paho can handle
|
||||
# the connection handshake asynchronously.
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
client.connect(
|
||||
host=settings.mqtt_broker_host,
|
||||
port=settings.mqtt_broker_port,
|
||||
keepalive=60,
|
||||
)
|
||||
except Exception:
|
||||
# Log without leaking the password
|
||||
logger.exception(
|
||||
"MQTT connect failed (host=%s, port=%s). Stopping loop.",
|
||||
settings.mqtt_broker_host,
|
||||
settings.mqtt_broker_port,
|
||||
)
|
||||
try:
|
||||
client.loop_stop()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._client = client
|
||||
logger.info(
|
||||
"MQTT client started (host=%s, port=%s).",
|
||||
settings.mqtt_broker_host,
|
||||
settings.mqtt_broker_port,
|
||||
)
|
||||
|
||||
def _stop_client(self) -> None:
|
||||
"""Disconnect and stop the background loop. Idempotent."""
|
||||
client = self._client
|
||||
if client is None:
|
||||
return
|
||||
self._client = None
|
||||
self._connected = False
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
logger.debug("MQTT disconnect raised (ignoring).", exc_info=True)
|
||||
try:
|
||||
client.loop_stop()
|
||||
except Exception:
|
||||
logger.debug("MQTT loop_stop raised (ignoring).", exc_info=True)
|
||||
logger.info("MQTT client stopped.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton — shared across lifespan and route handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
mqtt_manager = MqttManager()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Pricing profile framework and strategy registry.
|
||||
|
||||
This package provides:
|
||||
|
||||
- ``profiles``: Pydantic models describing the structure of pricing profiles
|
||||
(``ManualProfile`` / ``TibberProfile``), YAML loaders, and contract-values
|
||||
validation (``load_profile`` / ``list_profiles`` / ``validate_values``).
|
||||
- ``strategies``: A lightweight registry of price-calculation strategies
|
||||
(``register_strategy`` / ``get_strategy``), with built-in implementations for
|
||||
the ``manual`` (fixed dual-tariff) and ``tibber`` (dynamic API) kinds.
|
||||
"""
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Pricing profile loader, validator, and contract-values checker.
|
||||
|
||||
A *pricing profile* is a YAML file that describes the **structure** of an energy
|
||||
contract — which fields exist, their units, and which have defaults. Actual
|
||||
pricing values (the numbers the user fills in via the UI) are stored in the DB
|
||||
as ``EnergyContractVersion.values`` (a JSON blob) and must conform to the
|
||||
corresponding profile's structure.
|
||||
|
||||
Profiles live in ``app/integrations/pricing/profiles/<kind>.yaml`` and are
|
||||
located at runtime relative to *this file* (not CWD), matching the pattern
|
||||
established by the Modbus profile loader.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- **Purely data + functions** — no abstract base classes or inheritance.
|
||||
- Two Pydantic models — ``ManualProfile`` and ``TibberProfile`` — capture the
|
||||
different structures of the two supported kinds. A thin union dispatcher in
|
||||
``load_profile`` picks the right one.
|
||||
- ``validate_values(kind, values)`` fills in fields that carry a ``default`` and
|
||||
raises ``ProfileValidationError`` for missing required fields or wrong types.
|
||||
- All errors are subclasses of built-ins so callers need not import this module
|
||||
just to catch them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ValidationError, model_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directory containing the YAML profiles, located relative to *this* file.
|
||||
_PROFILES_DIR = Path(__file__).parent / "profiles"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfileNotFoundError(FileNotFoundError):
|
||||
"""Raised when the requested profile YAML file does not exist."""
|
||||
|
||||
|
||||
class ProfileValidationError(ValueError):
|
||||
"""Raised when a profile YAML fails Pydantic validation or when contract
|
||||
values do not conform to the profile structure."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leaf-node models (a single pricing field: unit + optional default)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FieldSpec(BaseModel):
|
||||
"""Specification for a single numeric pricing field."""
|
||||
|
||||
unit: str
|
||||
"""Physical / monetary unit string (e.g. ``"EUR/kWh"``, ``"EUR/month"``)."""
|
||||
|
||||
default: Optional[float] = None
|
||||
"""Default value used when the field is absent from contract values.
|
||||
``None`` means the field is required (no default)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ManualProfile — fixed / variable dual-tariff contract structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ManualBuySpec(BaseModel):
|
||||
normal: FieldSpec # high-tariff buy price (delivered_2 registers)
|
||||
dal: FieldSpec # low-tariff buy price (delivered_1 registers)
|
||||
|
||||
|
||||
class ManualSellSpec(BaseModel):
|
||||
normal: FieldSpec # high-tariff sell / return price
|
||||
dal: FieldSpec # low-tariff sell / return price
|
||||
|
||||
|
||||
class ManualEnergySpec(BaseModel):
|
||||
dual_tariff: bool # always True for manual profiles
|
||||
buy: ManualBuySpec
|
||||
sell: ManualSellSpec
|
||||
energy_tax: FieldSpec # added to buy price; includes VAT
|
||||
ode: FieldSpec # currently merged into energy_tax; default 0
|
||||
|
||||
|
||||
class ManualStandingSpec(BaseModel):
|
||||
network_fee: FieldSpec # EUR/month
|
||||
management_fee: FieldSpec # EUR/month
|
||||
|
||||
|
||||
class ManualCreditsSpec(BaseModel):
|
||||
heffingskorting: FieldSpec # EUR/year — energy-tax credit deducted at summary
|
||||
|
||||
|
||||
class ManualProfile(BaseModel):
|
||||
"""Complete structure description for a ``manual`` pricing contract."""
|
||||
|
||||
kind: str
|
||||
label: str
|
||||
energy: ManualEnergySpec
|
||||
standing: ManualStandingSpec
|
||||
credits: ManualCreditsSpec
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_kind(self) -> "ManualProfile":
|
||||
if self.kind != "manual":
|
||||
raise ValueError(f"ManualProfile requires kind='manual', got {self.kind!r}")
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TibberProfile — dynamic Tibber API contract structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TibberEnergySpec(BaseModel):
|
||||
source: str # must be "tibber_api"
|
||||
energy_tax: FieldSpec # subtracted from total to derive sell price
|
||||
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
||||
|
||||
|
||||
class TibberStandingSpec(BaseModel):
|
||||
management_fee: FieldSpec # EUR/month; has a default
|
||||
network_fee: FieldSpec # EUR/month
|
||||
|
||||
|
||||
class TibberCreditsSpec(BaseModel):
|
||||
heffingskorting: FieldSpec # EUR/year
|
||||
|
||||
|
||||
class TibberProfile(BaseModel):
|
||||
"""Complete structure description for a ``tibber`` pricing contract."""
|
||||
|
||||
kind: str
|
||||
label: str
|
||||
energy: TibberEnergySpec
|
||||
standing: TibberStandingSpec
|
||||
credits: TibberCreditsSpec
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_kind(self) -> "TibberProfile":
|
||||
if self.kind != "tibber":
|
||||
raise ValueError(f"TibberProfile requires kind='tibber', got {self.kind!r}")
|
||||
return self
|
||||
|
||||
|
||||
# A union type for type hints where either profile is acceptable.
|
||||
AnyProfile = ManualProfile | TibberProfile
|
||||
|
||||
# Map kind → Pydantic model class used for validation.
|
||||
_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile]] = {
|
||||
"manual": ManualProfile,
|
||||
"tibber": TibberProfile,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_profile(kind: str) -> AnyProfile:
|
||||
"""Load and validate a pricing profile by kind name.
|
||||
|
||||
The profile file is expected at
|
||||
``app/integrations/pricing/profiles/<kind>.yaml``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
kind:
|
||||
Profile kind without extension (``"manual"`` or ``"tibber"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ManualProfile | TibberProfile
|
||||
Validated profile model.
|
||||
|
||||
Raises
|
||||
------
|
||||
ProfileNotFoundError
|
||||
If ``profiles/<kind>.yaml`` does not exist.
|
||||
ProfileValidationError
|
||||
If the YAML is syntactically valid but fails schema validation.
|
||||
"""
|
||||
path = _PROFILES_DIR / f"{kind}.yaml"
|
||||
if not path.exists():
|
||||
raise ProfileNotFoundError(
|
||||
f"Pricing profile '{kind}' not found (looked for {path})"
|
||||
)
|
||||
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ProfileValidationError(
|
||||
f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}"
|
||||
)
|
||||
|
||||
# Choose the right Pydantic model based on the ``kind`` field in the YAML.
|
||||
yaml_kind = raw.get("kind", kind)
|
||||
model_cls = _PROFILE_MODELS.get(yaml_kind)
|
||||
if model_cls is None:
|
||||
raise ProfileValidationError(
|
||||
f"Profile '{kind}' has unknown kind={yaml_kind!r}; "
|
||||
f"supported: {list(_PROFILE_MODELS)}"
|
||||
)
|
||||
|
||||
try:
|
||||
return model_cls.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise ProfileValidationError(
|
||||
f"Profile '{kind}' failed validation: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def list_profiles() -> list[dict[str, Any]]:
|
||||
"""Return structural data for all available pricing profiles.
|
||||
|
||||
Scans ``app/integrations/pricing/profiles/*.yaml``, loads each, and returns
|
||||
a list of dicts (Pydantic model dumps). Profiles that fail to load are
|
||||
skipped with a warning.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]
|
||||
One entry per valid profile, suitable for JSON serialisation and
|
||||
front-end form rendering.
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
if not _PROFILES_DIR.exists():
|
||||
return results
|
||||
|
||||
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
|
||||
kind = path.stem
|
||||
try:
|
||||
profile = load_profile(kind)
|
||||
results.append(profile.model_dump())
|
||||
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
|
||||
logger.warning("Skipping malformed pricing profile '%s': %s", kind, exc)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract-values validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
|
||||
"""Return a copy of *values* with any ``ode`` default applied if missing."""
|
||||
filled = dict(values)
|
||||
|
||||
energy = dict(filled.get("energy", {}))
|
||||
|
||||
# Apply default for ode (default=0) if absent.
|
||||
if "ode" not in energy and profile.energy.ode.default is not None:
|
||||
energy["ode"] = profile.energy.ode.default
|
||||
|
||||
filled["energy"] = energy
|
||||
return filled
|
||||
|
||||
|
||||
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."""
|
||||
filled = dict(values)
|
||||
|
||||
energy = dict(filled.get("energy", {}))
|
||||
# Apply default for sell_adjust (default=0) if absent.
|
||||
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
||||
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
||||
filled["energy"] = energy
|
||||
|
||||
standing = dict(filled.get("standing", {}))
|
||||
# Apply default for management_fee if absent.
|
||||
if (
|
||||
"management_fee" not in standing
|
||||
and profile.standing.management_fee.default is not None
|
||||
):
|
||||
standing["management_fee"] = profile.standing.management_fee.default
|
||||
filled["standing"] = standing
|
||||
|
||||
return filled
|
||||
|
||||
|
||||
def _require_numeric(section: str, key: str, container: dict[str, Any]) -> None:
|
||||
"""Assert that *container[key]* exists and is a number; raise ProfileValidationError."""
|
||||
if key not in container:
|
||||
raise ProfileValidationError(
|
||||
f"Contract values missing required field '{section}.{key}'"
|
||||
)
|
||||
val = container[key]
|
||||
if not isinstance(val, (int, float)):
|
||||
raise ProfileValidationError(
|
||||
f"Contract values field '{section}.{key}' must be a number, "
|
||||
f"got {type(val).__name__!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manual_values(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
|
||||
"""Validate and fill-defaults for manual contract values.
|
||||
|
||||
Returns the filled values dict on success. Raises ProfileValidationError
|
||||
on missing required fields or wrong types.
|
||||
"""
|
||||
filled = _fill_defaults_manual(values, profile)
|
||||
energy = filled.get("energy", {})
|
||||
buy = energy.get("buy", {})
|
||||
sell = energy.get("sell", {})
|
||||
standing = filled.get("standing", {})
|
||||
credits = filled.get("credits", {})
|
||||
|
||||
# Required energy.buy fields.
|
||||
_require_numeric("energy.buy", "normal", buy)
|
||||
_require_numeric("energy.buy", "dal", buy)
|
||||
# Required energy.sell fields.
|
||||
_require_numeric("energy.sell", "normal", sell)
|
||||
_require_numeric("energy.sell", "dal", sell)
|
||||
# Required energy fields.
|
||||
_require_numeric("energy", "energy_tax", energy)
|
||||
_require_numeric("energy", "ode", energy)
|
||||
# Required standing fields.
|
||||
_require_numeric("standing", "network_fee", standing)
|
||||
_require_numeric("standing", "management_fee", standing)
|
||||
# Required credits fields.
|
||||
_require_numeric("credits", "heffingskorting", credits)
|
||||
|
||||
return filled
|
||||
|
||||
|
||||
def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||
"""Validate and fill-defaults for tibber contract values.
|
||||
|
||||
Returns the filled values dict on success. Raises ProfileValidationError
|
||||
on missing required fields or wrong types.
|
||||
"""
|
||||
filled = _fill_defaults_tibber(values, profile)
|
||||
energy = filled.get("energy", {})
|
||||
standing = filled.get("standing", {})
|
||||
credits = filled.get("credits", {})
|
||||
|
||||
# Required energy fields.
|
||||
_require_numeric("energy", "energy_tax", energy)
|
||||
_require_numeric("energy", "sell_adjust", energy)
|
||||
# Required standing fields.
|
||||
_require_numeric("standing", "management_fee", standing)
|
||||
_require_numeric("standing", "network_fee", standing)
|
||||
# Required credits fields.
|
||||
_require_numeric("credits", "heffingskorting", credits)
|
||||
|
||||
return filled
|
||||
|
||||
|
||||
def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate a contract-values dict against the named profile structure.
|
||||
|
||||
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
||||
``sell_adjust``, tibber ``management_fee``) are silently filled in when
|
||||
absent from *values*. Fields with no default that are absent, or fields
|
||||
whose value is not a number, cause a ``ProfileValidationError``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
kind:
|
||||
Profile kind (``"manual"`` or ``"tibber"``).
|
||||
values:
|
||||
Contract values dict as stored in ``EnergyContractVersion.values``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, Any]
|
||||
A (possibly mutated) copy of *values* with defaults applied.
|
||||
|
||||
Raises
|
||||
------
|
||||
ProfileNotFoundError
|
||||
If the profile YAML for *kind* does not exist.
|
||||
ProfileValidationError
|
||||
If required fields are missing or have wrong types.
|
||||
"""
|
||||
profile = load_profile(kind)
|
||||
if isinstance(profile, ManualProfile):
|
||||
return _validate_manual_values(values, profile)
|
||||
if isinstance(profile, TibberProfile):
|
||||
return _validate_tibber_values(values, profile)
|
||||
# Unreachable with current kinds, but guard for future extensions.
|
||||
raise ProfileValidationError(f"No validator implemented for kind={kind!r}")
|
||||
@@ -0,0 +1,20 @@
|
||||
kind: manual
|
||||
label: 固定 / 可变费率(NL,双费率)
|
||||
|
||||
energy:
|
||||
dual_tariff: true # use delivered_1/2, returned_1/2 for low/high tariffs
|
||||
buy:
|
||||
normal: { unit: EUR/kWh } # high-tariff buy price (delivered_2 register)
|
||||
dal: { unit: EUR/kWh } # low-tariff buy price (delivered_1 register)
|
||||
sell:
|
||||
normal: { unit: EUR/kWh } # high-tariff sell / return price
|
||||
dal: { unit: EUR/kWh } # low-tariff sell / return price (currently equal to normal)
|
||||
energy_tax: { unit: EUR/kWh } # energy tax added to buy price (incl. VAT)
|
||||
ode: { unit: EUR/kWh, default: 0 } # currently merged into energy_tax
|
||||
|
||||
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||
network_fee: { unit: EUR/month }
|
||||
management_fee: { unit: EUR/month }
|
||||
|
||||
credits:
|
||||
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
|
||||
@@ -0,0 +1,14 @@
|
||||
kind: tibber
|
||||
label: Tibber 动态电价(15 分钟)
|
||||
|
||||
energy:
|
||||
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
||||
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)
|
||||
|
||||
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||
management_fee: { unit: EUR/month, default: 5.99 }
|
||||
network_fee: { unit: EUR/month }
|
||||
|
||||
credits:
|
||||
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Price-calculation strategy registry and built-in implementations.
|
||||
|
||||
A *strategy* is a plain function registered under a ``kind`` string. Given
|
||||
per-register kWh deltas, the period start time, the active contract-version
|
||||
values, and a DB session, it returns a result dict with:
|
||||
|
||||
{
|
||||
"import_cost": Decimal, # total cost of electricity drawn from grid
|
||||
"export_revenue": Decimal, # total revenue from electricity fed to grid
|
||||
"net_cost": Decimal, # import_cost − export_revenue
|
||||
"pricing": dict, # snapshot of the price inputs used (for auditing)
|
||||
}
|
||||
|
||||
**Import and export are kept separate throughout** — net_cost is only derived
|
||||
at the end. This supports the Dutch no-netting rule (saldering afgebouwd).
|
||||
|
||||
**All monetary arithmetic uses Decimal** converted via ``Decimal(str(x))`` to
|
||||
avoid float binary rounding errors.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- **Purely data + functions**: no ABC, no class hierarchy. A ``dict``-based
|
||||
registry is the simplest structure that supports the two current kinds and
|
||||
leaves the door open for future additions (e.g. ``octopus``, ``frank``).
|
||||
- ``deltas`` is a plain dataclass ``PeriodDeltas`` — typed, but no ORM.
|
||||
- Tibber strategy queries ``TibberPrice`` directly from the session; it does not
|
||||
call the Tibber API (that is T05's job).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared data types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeriodDeltas:
|
||||
"""Per-register kWh deltas for one 15-minute billing period.
|
||||
|
||||
All values are ``Decimal`` and represent ``end_reading − start_reading``
|
||||
for the corresponding cumulative energy register.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
d1: Decimal
|
||||
Delivered (consumed) kWh on the low/dal tariff register (delivered_1).
|
||||
d2: Decimal
|
||||
Delivered (consumed) kWh on the normal/high tariff register (delivered_2).
|
||||
r1: Decimal
|
||||
Returned (fed-to-grid) kWh on the low/dal tariff register (returned_1).
|
||||
r2: Decimal
|
||||
Returned (fed-to-grid) kWh on the normal/high tariff register (returned_2).
|
||||
"""
|
||||
|
||||
d1: Decimal # delivered low-tariff
|
||||
d2: Decimal # delivered high-tariff
|
||||
r1: Decimal # returned low-tariff
|
||||
r2: Decimal # returned high-tariff
|
||||
|
||||
|
||||
# Strategy callable signature.
|
||||
StrategyFn = Callable[
|
||||
[PeriodDeltas, datetime, dict[str, Any], Session],
|
||||
dict[str, Any],
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY: dict[str, StrategyFn] = {}
|
||||
|
||||
|
||||
def register_strategy(kind: str, fn: StrategyFn) -> None:
|
||||
"""Register *fn* as the price-calculation strategy for *kind*.
|
||||
|
||||
Overwrites any previously registered strategy for the same kind (allows
|
||||
monkey-patching in tests).
|
||||
"""
|
||||
_REGISTRY[kind] = fn
|
||||
|
||||
|
||||
def get_strategy(kind: str) -> StrategyFn:
|
||||
"""Return the registered strategy for *kind*.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no strategy is registered for *kind*.
|
||||
"""
|
||||
if kind not in _REGISTRY:
|
||||
raise KeyError(
|
||||
f"No price strategy registered for kind={kind!r}. "
|
||||
f"Available: {sorted(_REGISTRY)}"
|
||||
)
|
||||
return _REGISTRY[kind]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Decimal:
|
||||
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manual strategy — fixed dual-tariff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _manual_strategy(
|
||||
deltas: PeriodDeltas,
|
||||
t0: datetime,
|
||||
values: dict[str, Any],
|
||||
session: Session,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate billing costs for one period using fixed dual-tariff rates.
|
||||
|
||||
Formula (§3.4):
|
||||
- ``buy_dal = energy.buy.dal + energy.energy_tax + energy.ode``
|
||||
- ``buy_normal = energy.buy.normal + energy.energy_tax + energy.ode``
|
||||
- ``import_cost = Δd1 × buy_dal + Δd2 × buy_normal``
|
||||
- ``sell_dal = energy.sell.dal`` (no tax on return price)
|
||||
- ``sell_normal = energy.sell.normal``
|
||||
- ``export_revenue = Δr1 × sell_dal + Δr2 × sell_normal``
|
||||
- ``net_cost = import_cost − export_revenue``
|
||||
|
||||
The ``pricing`` snapshot contains all per-unit prices used so that the
|
||||
result is fully auditable without re-querying the contract version.
|
||||
"""
|
||||
energy = values.get("energy", {})
|
||||
buy = energy.get("buy", {})
|
||||
sell = energy.get("sell", {})
|
||||
|
||||
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||
ode = _to_decimal(energy.get("ode", 0))
|
||||
|
||||
buy_dal_base = _to_decimal(buy.get("dal", 0))
|
||||
buy_normal_base = _to_decimal(buy.get("normal", 0))
|
||||
sell_dal = _to_decimal(sell.get("dal", 0))
|
||||
sell_normal = _to_decimal(sell.get("normal", 0))
|
||||
|
||||
# Effective buy prices including all taxes.
|
||||
buy_dal = buy_dal_base + energy_tax + ode
|
||||
buy_normal = buy_normal_base + energy_tax + ode
|
||||
|
||||
import_cost = deltas.d1 * buy_dal + deltas.d2 * buy_normal
|
||||
export_revenue = deltas.r1 * sell_dal + deltas.r2 * sell_normal
|
||||
net_cost = import_cost - export_revenue
|
||||
|
||||
pricing_snapshot = {
|
||||
"kind": "manual",
|
||||
"buy_dal": str(buy_dal),
|
||||
"buy_normal": str(buy_normal),
|
||||
"sell_dal": str(sell_dal),
|
||||
"sell_normal": str(sell_normal),
|
||||
"energy_tax": str(energy_tax),
|
||||
"ode": str(ode),
|
||||
}
|
||||
|
||||
return {
|
||||
"import_cost": import_cost,
|
||||
"export_revenue": export_revenue,
|
||||
"net_cost": net_cost,
|
||||
"pricing": pricing_snapshot,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tibber strategy — dynamic 15-minute spot price
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TibberPriceNotFoundError(LookupError):
|
||||
"""Raised when no TibberPrice row covers the requested period start.
|
||||
|
||||
The billing engine (T07) catches this to mark the period as ``degraded``
|
||||
or skip it until a price becomes available.
|
||||
"""
|
||||
|
||||
|
||||
def _tibber_strategy(
|
||||
deltas: PeriodDeltas,
|
||||
t0: datetime,
|
||||
values: dict[str, Any],
|
||||
session: Session,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate billing costs for one period using Tibber dynamic pricing.
|
||||
|
||||
Price lookup:
|
||||
Takes the most recent ``TibberPrice`` row where ``starts_at ≤ t0``
|
||||
(i.e. the slot that was in effect at *t0*). This is a DESC LIMIT 1
|
||||
query on ``starts_at``.
|
||||
|
||||
Formula (§3.4):
|
||||
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
||||
- ``sell = total − energy_tax − sell_adjust``
|
||||
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||
- ``net_cost = import_cost − export_revenue``
|
||||
|
||||
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||
API price applies to the full delivered/returned volume.
|
||||
|
||||
Negative ``total`` (extreme negative spot prices):
|
||||
When ``total`` is negative the ``sell`` price will also be negative,
|
||||
meaning ``export_revenue`` becomes negative (feeding to grid *costs*
|
||||
money). This is the mathematically correct outcome and is left as-is.
|
||||
|
||||
Raises
|
||||
------
|
||||
TibberPriceNotFoundError
|
||||
If no ``TibberPrice`` row exists with ``starts_at ≤ t0``. The caller
|
||||
(T07 billing engine) should catch this and mark the period as
|
||||
``degraded`` or skip it for later recomputation.
|
||||
"""
|
||||
from app.models.energy import TibberPrice # local import to avoid circular
|
||||
from sqlalchemy import desc
|
||||
|
||||
price_row: TibberPrice | None = (
|
||||
session.query(TibberPrice)
|
||||
.filter(TibberPrice.starts_at <= t0)
|
||||
.order_by(desc(TibberPrice.starts_at))
|
||||
.first()
|
||||
)
|
||||
|
||||
if price_row is None:
|
||||
raise TibberPriceNotFoundError(
|
||||
f"No TibberPrice found covering t0={t0.isoformat()!r}; "
|
||||
"period cannot be billed until price data is available."
|
||||
)
|
||||
|
||||
energy = values.get("energy", {})
|
||||
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||
|
||||
total = _to_decimal(price_row.total)
|
||||
buy = total
|
||||
sell = total - energy_tax - sell_adjust
|
||||
|
||||
total_delivered = deltas.d1 + deltas.d2
|
||||
total_returned = deltas.r1 + deltas.r2
|
||||
|
||||
import_cost = total_delivered * buy
|
||||
export_revenue = total_returned * sell
|
||||
net_cost = import_cost - export_revenue
|
||||
|
||||
pricing_snapshot = {
|
||||
"kind": "tibber",
|
||||
"tibber_price_starts_at": price_row.starts_at.isoformat(),
|
||||
"tibber_price_id": price_row.id,
|
||||
"total": str(total),
|
||||
"buy": str(buy),
|
||||
"sell": str(sell),
|
||||
"energy_tax": str(energy_tax),
|
||||
"sell_adjust": str(sell_adjust),
|
||||
}
|
||||
|
||||
return {
|
||||
"import_cost": import_cost,
|
||||
"export_revenue": export_revenue,
|
||||
"net_cost": net_cost,
|
||||
"pricing": pricing_snapshot,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register built-in strategies at module import time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_strategy("manual", _manual_strategy)
|
||||
register_strategy("tibber", _tibber_strategy)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tibber dynamic electricity pricing integration.
|
||||
|
||||
This package provides:
|
||||
- ``client``: HTTP client for the Tibber GraphQL API (price fetching).
|
||||
- Custom exceptions: ``TibberError`` (base) and ``TibberAuthError`` (auth failure).
|
||||
|
||||
Usage::
|
||||
|
||||
from app.integrations.tibber.client import fetch_price_range, TibberAuthError
|
||||
"""
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Tibber GraphQL API client for fetching electricity price data.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- Uses ``httpx`` (already a project dependency) for all HTTP requests.
|
||||
- Token is **never** written to log messages or exception strings to prevent
|
||||
credential leakage into log aggregators.
|
||||
- ``fetch_price_range`` returns a list of ``PricePoint`` objects with UTC-aware
|
||||
``starts_at`` datetimes; the number of nodes is not assumed — all returned
|
||||
nodes are parsed regardless of count.
|
||||
- ``fetch_current_price`` uses a separate, simpler query that asks for the
|
||||
*current* price point only; used by the connection-test endpoint (T09) to
|
||||
produce a fast three-state result (success / auth-error / network-error).
|
||||
- Authentication failures (HTTP 401/403) are raised as ``TibberAuthError`` so
|
||||
that callers (the test endpoint) can distinguish them from generic network or
|
||||
API errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
||||
_DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
# GraphQL query to fetch a range of 15-minute price nodes.
|
||||
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
||||
# 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
|
||||
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
||||
# "today + tomorrow").
|
||||
_PRICE_RANGE_QUERY = """
|
||||
{
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
currentSubscription {
|
||||
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
||||
nodes {
|
||||
startsAt
|
||||
total
|
||||
energy
|
||||
tax
|
||||
currency
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# GraphQL query to fetch the single *current* price point.
|
||||
_CURRENT_PRICE_QUERY = """
|
||||
{
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
startsAt
|
||||
total
|
||||
energy
|
||||
tax
|
||||
currency
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TibberError(Exception):
|
||||
"""Base class for all Tibber client errors.
|
||||
|
||||
Raised for network failures, timeouts, unexpected HTTP status codes, and
|
||||
malformed API responses. The exception message will **never** contain the
|
||||
API token.
|
||||
"""
|
||||
|
||||
|
||||
class TibberAuthError(TibberError):
|
||||
"""Raised when the Tibber API rejects the provided token (HTTP 401/403).
|
||||
|
||||
Callers that implement a three-state connection test should catch this
|
||||
exception separately from ``TibberError`` to distinguish auth problems
|
||||
from network / API problems.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PricePoint:
|
||||
"""One 15-minute price slot returned by the Tibber API.
|
||||
|
||||
All monetary values are in ``currency`` and include VAT (user-facing).
|
||||
``starts_at`` is always a timezone-aware UTC datetime regardless of the
|
||||
timezone offset that Tibber returns in ``startsAt``.
|
||||
"""
|
||||
|
||||
starts_at: datetime # UTC-aware
|
||||
total: float # full all-in price (energy + tax)
|
||||
energy: float # spot energy component
|
||||
tax: float # tax component
|
||||
currency: str # ISO 4217 (typically "EUR")
|
||||
level: str | None # e.g. "CHEAP", "NORMAL", "EXPENSIVE"; None if absent
|
||||
resolution: str # label for the slot resolution (e.g. "QUARTER_HOURLY")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_starts_at(raw: str) -> datetime:
|
||||
"""Parse an ISO 8601 timestamp with timezone offset and convert to UTC.
|
||||
|
||||
Tibber returns timestamps like ``2026-06-23T00:00:00.000+02:00``.
|
||||
``datetime.fromisoformat`` handles this format in Python 3.11+; the result
|
||||
is then converted to UTC via ``.astimezone(UTC)``.
|
||||
"""
|
||||
return datetime.fromisoformat(raw).astimezone(UTC)
|
||||
|
||||
|
||||
def _parse_node(node: dict[str, Any], resolution: str) -> PricePoint:
|
||||
"""Parse a single price node dict into a ``PricePoint``."""
|
||||
return PricePoint(
|
||||
starts_at=_parse_starts_at(node["startsAt"]),
|
||||
total=float(node["total"]),
|
||||
energy=float(node["energy"]),
|
||||
tax=float(node["tax"]),
|
||||
currency=str(node["currency"]),
|
||||
level=node.get("level") or None,
|
||||
resolution=resolution,
|
||||
)
|
||||
|
||||
|
||||
def _pick_home(homes: list[dict[str, Any]], home_id: str | None) -> dict[str, Any]:
|
||||
"""Return the home dict matching *home_id*, or the first home if None."""
|
||||
if not homes:
|
||||
raise TibberError("Tibber API returned no homes")
|
||||
if home_id is not None:
|
||||
for h in homes:
|
||||
if h.get("id") == home_id:
|
||||
return h
|
||||
raise TibberError("Tibber home id not found in API response")
|
||||
return homes[0]
|
||||
|
||||
|
||||
def _post_graphql(token: str, query: str, timeout: float) -> dict[str, Any]:
|
||||
"""POST the GraphQL *query* and return the parsed ``data`` dict.
|
||||
|
||||
Raises
|
||||
------
|
||||
TibberAuthError
|
||||
On HTTP 401 or 403.
|
||||
TibberError
|
||||
On timeouts, network errors, non-2xx responses, or unexpected body shape.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
_TIBBER_API_URL,
|
||||
json={"query": query},
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise TibberError("Tibber API request timed out") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise TibberError("Tibber API request failed") from exc
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
raise TibberAuthError("Tibber API authentication failed")
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise TibberError(f"Tibber API returned unexpected status {response.status_code}") from exc
|
||||
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception as exc:
|
||||
raise TibberError("Tibber API returned non-JSON response") from exc
|
||||
|
||||
if "errors" in body:
|
||||
# GraphQL errors are not HTTP errors; surface them as TibberError.
|
||||
# Do not include token in the message.
|
||||
raise TibberError("Tibber GraphQL returned errors")
|
||||
|
||||
data = body.get("data")
|
||||
if data is None:
|
||||
raise TibberError("Tibber API response missing 'data' field")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fetch_price_range(
|
||||
token: str,
|
||||
home_id: str | None = None,
|
||||
*,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> list[PricePoint]:
|
||||
"""Fetch a range of 15-minute price nodes from the Tibber API.
|
||||
|
||||
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
|
||||
and parses every returned node into a ``PricePoint``. The number of nodes
|
||||
is not assumed — all returned nodes are parsed regardless of count.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token:
|
||||
Tibber API token. **Never** logged or included in exception messages.
|
||||
home_id:
|
||||
If given, the home with this Tibber home ID is selected. If ``None``,
|
||||
the first home in the account is used.
|
||||
timeout:
|
||||
HTTP request timeout in seconds (default 15 s).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[PricePoint]
|
||||
List of price points with UTC-aware ``starts_at`` values.
|
||||
|
||||
Raises
|
||||
------
|
||||
TibberAuthError
|
||||
If the token is rejected (HTTP 401/403).
|
||||
TibberError
|
||||
For network failures, timeouts, or unexpected API responses.
|
||||
"""
|
||||
data = _post_graphql(token, _PRICE_RANGE_QUERY, timeout)
|
||||
|
||||
try:
|
||||
homes = data["viewer"]["homes"]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise TibberError("Tibber API response has unexpected shape") from exc
|
||||
|
||||
home = _pick_home(homes, home_id)
|
||||
|
||||
try:
|
||||
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
|
||||
|
||||
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
||||
|
||||
|
||||
def fetch_current_price(
|
||||
token: str,
|
||||
home_id: str | None = None,
|
||||
*,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> PricePoint:
|
||||
"""Fetch the *current* price point from the Tibber API.
|
||||
|
||||
Uses the lighter ``priceInfo { current { ... } }`` query rather than the
|
||||
full range query, making it suitable for a fast connection test.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token:
|
||||
Tibber API token. **Never** logged or included in exception messages.
|
||||
home_id:
|
||||
If given, the home with this Tibber home ID is selected. If ``None``,
|
||||
the first home in the account is used.
|
||||
timeout:
|
||||
HTTP request timeout in seconds (default 15 s).
|
||||
|
||||
Returns
|
||||
-------
|
||||
PricePoint
|
||||
The current price point with a UTC-aware ``starts_at``.
|
||||
|
||||
Raises
|
||||
------
|
||||
TibberAuthError
|
||||
If the token is rejected (HTTP 401/403).
|
||||
TibberError
|
||||
For network failures, timeouts, missing current price, or unexpected
|
||||
API responses.
|
||||
"""
|
||||
data = _post_graphql(token, _CURRENT_PRICE_QUERY, timeout)
|
||||
|
||||
try:
|
||||
homes = data["viewer"]["homes"]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise TibberError("Tibber API response has unexpected shape") from exc
|
||||
|
||||
home = _pick_home(homes, home_id)
|
||||
|
||||
try:
|
||||
current = home["currentSubscription"]["priceInfo"]["current"]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise TibberError("Tibber API response missing current price") from exc
|
||||
|
||||
if current is None:
|
||||
raise TibberError("Tibber API returned null for current price")
|
||||
|
||||
return _parse_node(current, "QUARTER_HOURLY")
|
||||
+260
-37
@@ -1,28 +1,165 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes import pages, status
|
||||
import app.auth_db as auth_db
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.energy import router as api_energy_router
|
||||
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
|
||||
from app.api.routes.api.expose import router as api_expose_router
|
||||
from app.api.routes.api.modbus import router as api_modbus_router
|
||||
from app.api.routes.api.session import router as api_session_router
|
||||
from app.api.routes import status
|
||||
from app.db import get_session_local
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.api.routes.public_ip import router as public_ip_router
|
||||
from app.api.routes.ticktick import router as ticktick_router
|
||||
from app.config import get_settings
|
||||
from app.integrations.mqtt import mqtt_manager
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
||||
from app.services.config_page import build_runtime_settings, seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
||||
from app.services.dsmr_ingest import apply_dsmr_subscription
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
|
||||
from app.services.ha_discovery import publish_discovery, publish_states
|
||||
from app.services.tibber_prices import refresh_prices
|
||||
from app.services.energy_cost import compute_closed_periods
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _get_spa_dist_dir() -> Path:
|
||||
env_val = os.environ.get("SPA_DIST_DIR")
|
||||
if env_val:
|
||||
return Path(env_val)
|
||||
return _REPO_ROOT / "frontend" / "dist"
|
||||
|
||||
|
||||
def _run_scheduled_public_ip_check() -> None:
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _run_scheduled_modbus_poll() -> None:
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
poll_all_enabled_devices(session, bootstrap_settings=get_settings())
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _run_scheduled_tibber_refresh() -> None:
|
||||
"""Scheduled job: fetch Tibber 15-minute prices and upsert into tibber_price.
|
||||
|
||||
Runs every hour so that:
|
||||
- Today's prices are available from startup.
|
||||
- Tomorrow's prices (published by Tibber around 13:00 CET / 11:00 UTC) are
|
||||
picked up within an hour of publication without requiring a server restart.
|
||||
|
||||
The job is a no-op when:
|
||||
- No active energy contract with kind="tibber" exists.
|
||||
- The Tibber API token is empty in the runtime settings.
|
||||
|
||||
Any client exceptions (auth failures, network errors) are caught and logged
|
||||
so that a single failed fetch does not crash the scheduler or affect the
|
||||
other background jobs.
|
||||
"""
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
runtime_settings = build_runtime_settings(session, get_settings())
|
||||
refresh_prices(session, runtime_settings)
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_tibber_refresh: unexpected error")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _run_scheduled_energy_cost() -> None:
|
||||
"""Scheduled job: compute billing records for all uncalculated closed 15-minute periods.
|
||||
|
||||
Runs every minute so that a new period is picked up within 1 minute of
|
||||
closing. The job is a no-op when:
|
||||
- No active energy contract with a version covering the period exists.
|
||||
- DSMR data has not yet arrived for the period boundaries.
|
||||
- The period's billing record already exists and is not degraded.
|
||||
|
||||
Any unexpected exceptions are caught and logged so that a single failure
|
||||
does not crash the scheduler or affect the other background jobs.
|
||||
"""
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
compute_closed_periods(session)
|
||||
# After billing periods are computed, push fresh energy-cost state values
|
||||
# to MQTT/HA. publish_states is internally guarded by _should_publish
|
||||
# (MQTT disabled / not connected → no-op), so this never raises due to
|
||||
# unconfigured MQTT and does not block the billing job.
|
||||
try:
|
||||
from app.services.ha_discovery import publish_states
|
||||
publish_states(session)
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_energy_cost: publish_states failed (non-fatal)")
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_energy_cost: unexpected error")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _run_scheduled_ha_state_publish() -> None:
|
||||
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
|
||||
|
||||
Runs every 60 seconds. When the MQTT broker is connected:
|
||||
- Publishes (or re-publishes) all HA Discovery configs (retained, idempotent).
|
||||
- Publishes the current state / availability for all enabled entities.
|
||||
|
||||
This also serves as the reliable "publish discovery after connect" mechanism:
|
||||
because paho connects asynchronously, a synchronous call immediately after
|
||||
``mqtt_manager.connect()`` would fire before the TCP handshake completes and
|
||||
be a no-op. Instead, this periodic job picks it up within 60 seconds of the
|
||||
broker becoming available — retained payloads make repeated publishes harmless.
|
||||
|
||||
Additionally, if MQTT is configured in DB but the manager is not yet connected
|
||||
(e.g. MQTT was enabled via UI after startup), this job attempts to connect so
|
||||
the user does not need to restart the server.
|
||||
"""
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
runtime_settings = build_runtime_settings(session, get_settings())
|
||||
# Reconnect if MQTT is configured (in DB) but not yet connected.
|
||||
if mqtt_manager.is_configured(runtime_settings) and not mqtt_manager.is_connected:
|
||||
logger.info("_run_scheduled_ha_state_publish: MQTT configured but not connected — attempting connect.")
|
||||
mqtt_manager.connect(runtime_settings)
|
||||
publish_discovery(session)
|
||||
publish_states(session)
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_ha_state_publish: unexpected error")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_auth_db_ready() -> None:
|
||||
session_local = auth_db.get_auth_session_local()
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
validate_app_runtime_db(get_settings().app_database_url)
|
||||
@@ -37,43 +174,90 @@ def ensure_auth_db_ready() -> None:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_location_db_ready() -> None:
|
||||
settings = get_settings()
|
||||
if settings.location_sqlite_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
validate_location_runtime_db(settings.location_database_url)
|
||||
except LocationDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def ensure_poo_db_ready() -> None:
|
||||
settings = get_settings()
|
||||
if settings.poo_sqlite_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
validate_poo_runtime_db(settings.poo_database_url)
|
||||
except PooDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
settings = get_settings()
|
||||
for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path):
|
||||
if path is not None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if settings.app_sqlite_path is not None:
|
||||
settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_runtime_dirs()
|
||||
ensure_auth_db_ready()
|
||||
ensure_location_db_ready()
|
||||
ensure_poo_db_ready()
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
_run_scheduled_public_ip_check,
|
||||
trigger=IntervalTrigger(hours=4),
|
||||
id="public-ip-check",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_scheduled_modbus_poll,
|
||||
trigger=IntervalTrigger(seconds=BASE_POLL_TICK_SECONDS),
|
||||
id="modbus-poll",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
# Periodic HA state / availability publish (60-second fallback sweep).
|
||||
scheduler.add_job(
|
||||
_run_scheduled_ha_state_publish,
|
||||
trigger=IntervalTrigger(seconds=60),
|
||||
id="ha-state-publish",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
# Tibber price refresh: fetch today + tomorrow every hour.
|
||||
# The job is a no-op when no active tibber contract or token is configured,
|
||||
# so it is safe to register unconditionally.
|
||||
scheduler.add_job(
|
||||
_run_scheduled_tibber_refresh,
|
||||
trigger=IntervalTrigger(hours=1),
|
||||
id="tibber-refresh",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
# Energy cost billing: compute uncalculated closed 15-minute periods every minute.
|
||||
# The job is a no-op when no active contract or DSMR data is present, so it is
|
||||
# safe to register unconditionally.
|
||||
scheduler.add_job(
|
||||
_run_scheduled_energy_cost,
|
||||
trigger=IntervalTrigger(minutes=1),
|
||||
id="energy-cost",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||
# is picked up on restart (not just from env/bootstrap settings).
|
||||
# Discovery will be published by the first run of _run_scheduled_ha_state_publish
|
||||
# (within 60 s of startup), after the async paho handshake completes.
|
||||
_startup_session_local = get_session_local()
|
||||
_startup_session: Session = _startup_session_local()
|
||||
try:
|
||||
_startup_runtime_settings = build_runtime_settings(_startup_session, get_settings())
|
||||
finally:
|
||||
_startup_session.close()
|
||||
mqtt_manager.connect(_startup_runtime_settings)
|
||||
|
||||
# DSMR ingest: subscribe to the configured MQTT topic when enabled. The same
|
||||
# applier is called from PUT /api/config, so toggling DSMR via the UI takes
|
||||
# effect without an app restart.
|
||||
apply_dsmr_subscription(_startup_runtime_settings)
|
||||
|
||||
yield
|
||||
|
||||
# MQTT: clean shutdown before the process exits.
|
||||
mqtt_manager.disconnect()
|
||||
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
@@ -92,12 +276,51 @@ def create_app() -> FastAPI:
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
app.include_router(status.router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_energy_router)
|
||||
app.include_router(api_energy_contracts_router)
|
||||
app.include_router(api_expose_router)
|
||||
app.include_router(api_modbus_router)
|
||||
app.include_router(api_session_router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
app.include_router(public_ip_router)
|
||||
app.include_router(ticktick_router)
|
||||
|
||||
# SPA hosting: mount frontend/dist if it exists and has index.html.
|
||||
# If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely
|
||||
# so that pytest stays green with only the API routes registered.
|
||||
spa_dist = _get_spa_dist_dir()
|
||||
spa_index = spa_dist / "index.html"
|
||||
if spa_dist.is_dir() and spa_index.is_file():
|
||||
spa_assets = spa_dist / "assets"
|
||||
if spa_assets.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets")
|
||||
|
||||
# Resolve the dist root once so the containment check is fast and consistent.
|
||||
_spa_root = spa_dist.resolve()
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029
|
||||
# Explicit 404 for unmatched /api/* — never return index.html for API paths.
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# Resolve candidate to an absolute path and verify it stays within the SPA
|
||||
# dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f")
|
||||
# bypass Starlette's path parameter handling and allow arbitrary file reads.
|
||||
candidate = (spa_dist / full_path).resolve()
|
||||
if candidate.is_file() and candidate.is_relative_to(_spa_root):
|
||||
return FileResponse(candidate)
|
||||
# For any path outside the dist root, or for SPA client routes that don't
|
||||
# correspond to a real file, return index.html so the SPA router handles it.
|
||||
return FileResponse(spa_index)
|
||||
else:
|
||||
logger.warning(
|
||||
"SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
+11
-1
@@ -3,5 +3,15 @@
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.location import Location
|
||||
from app.models.poo import PooRecord
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
|
||||
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
|
||||
__all__ = [
|
||||
"AppConfigEntry",
|
||||
"AuthSession",
|
||||
"AuthUser",
|
||||
"Location",
|
||||
"PooRecord",
|
||||
"PublicIPHistory",
|
||||
"PublicIPState",
|
||||
]
|
||||
|
||||
+25
-3
@@ -3,10 +3,10 @@ from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AuthUser(AuthBase):
|
||||
class AuthUser(Base):
|
||||
__tablename__ = "auth_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -15,11 +15,15 @@ class AuthUser(AuthBase):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# TOTP fields (Phase B, M4-T04) — nullable/false by default so existing users are unaffected
|
||||
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
|
||||
recovery_codes: Mapped[list["RecoveryCode"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class AuthSession(AuthBase):
|
||||
class AuthSession(Base):
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -31,3 +35,21 @@ class AuthSession(AuthBase):
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[AuthUser] = relationship(back_populates="sessions")
|
||||
|
||||
|
||||
class RecoveryCode(Base):
|
||||
"""One-time TOTP recovery codes for AuthUser.
|
||||
|
||||
``code_hash`` stores the Argon2 hash of the plaintext code (plaintext is
|
||||
returned only at setup time and never stored). ``used_at`` is NULL while
|
||||
the code is still valid; set to the consumption timestamp when consumed.
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_recovery_code"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
|
||||
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[AuthUser] = relationship(back_populates="recovery_codes")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class LoginThrottle(Base):
|
||||
"""Tracks per-key login failure state for exponential back-off throttling.
|
||||
|
||||
``scope`` is either ``'ip'`` or ``'user'``; ``key`` is the IP address or
|
||||
username string respectively. The pair ``(scope, key)`` is unique — one
|
||||
row per tracked entity. A row is deleted on successful login (clear).
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_login_throttle"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
|
||||
Index("ix_auth_login_throttle_scope_key", "scope", "key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
scope: Mapped[str] = mapped_column(String(16), nullable=False) # 'ip' | 'user'
|
||||
failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
first_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
next_allowed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
from app.db import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
|
||||
@@ -3,10 +3,10 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AppConfigEntry(AuthBase):
|
||||
class AppConfigEntry(Base):
|
||||
__tablename__ = "app_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
|
||||
|
||||
Five tables:
|
||||
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
|
||||
- energy_contract: contract head (manual or tibber, one active at a time).
|
||||
- energy_contract_version: versioned pricing values; append-only for auditability.
|
||||
- tibber_price: cached Tibber 15-minute spot prices (immutable).
|
||||
- energy_cost_period: computed 15-minute billing periods (immutable snapshot).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class DsmrReading(Base):
|
||||
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
||||
|
||||
Identity & idempotency are **independent of the DSMR Reader's telegram id**
|
||||
(that field overflows and must be manually reset to zero — a known DSMR
|
||||
quirk — so relying on it for uniqueness risks silently dropping new data).
|
||||
The table's own autoincrement ``id`` PK is the stable internal identity, and
|
||||
``recorded_at`` (the telegram timestamp) is the UNIQUE de-duplication key: a
|
||||
single P1 meter emits exactly one telegram per timestamp.
|
||||
|
||||
``recorded_at`` is a real column (not inside the payload) so time-range
|
||||
queries are efficient. The entire telegram frame is stored verbatim in
|
||||
``payload``; no field allow-list is applied so future commodities (gas,
|
||||
heating, three-phase) are accommodated without a schema change.
|
||||
"""
|
||||
|
||||
__tablename__ = "dsmr_reading"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# UTC timestamp of the sample — real column, UNIQUE (telegram-id-independent
|
||||
# idempotency key). The unique index also serves time-range queries.
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, unique=True
|
||||
)
|
||||
|
||||
# Telegram's own id (DSMR Reader assigns it). Stored only as a reference /
|
||||
# debugging aid — NOT used for uniqueness or idempotency (it overflows and
|
||||
# gets reset to zero). Nullable because some DSMR sources may not emit one.
|
||||
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Full telegram frame as a JSON object; values are typically JSON strings
|
||||
# (e.g. "20915.154") — callers must cast to Decimal before arithmetic.
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
|
||||
class EnergyContract(Base):
|
||||
"""Contract head: a named energy contract with a chosen pricing strategy.
|
||||
|
||||
``kind`` determines which price strategy is used (``manual`` for fixed
|
||||
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
|
||||
Only one contract may be ``active`` at a time; the service layer enforces
|
||||
mutual exclusion. Specific pricing values live in ``EnergyContractVersion``
|
||||
so that price changes can be tracked without modifying historical records.
|
||||
"""
|
||||
|
||||
__tablename__ = "energy_contract"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Human-readable label; freely editable by the user.
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Strategy selector: "manual" or "tibber". Application-layer validation
|
||||
# enforces the allowed set; no DB CHECK constraint is added to keep the
|
||||
# migration simple and the strategy registry extensible.
|
||||
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
|
||||
# Whether this is the currently active contract (at most one should be True).
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# ISO 4217 currency code for all monetary values in this contract.
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="EUR")
|
||||
|
||||
# Audit timestamps.
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Relationship to versions (back-reference; not loaded eagerly).
|
||||
versions: Mapped[list["EnergyContractVersion"]] = relationship(
|
||||
back_populates="contract", cascade="save-update, merge"
|
||||
)
|
||||
|
||||
|
||||
class EnergyContractVersion(Base):
|
||||
"""One time-bounded version of an energy contract's pricing values.
|
||||
|
||||
Pricing changes are modelled as new versions (append-only); existing versions
|
||||
are never modified so that historical ``EnergyCostPeriod`` records remain
|
||||
fully auditable. ``effective_to`` is ``NULL`` for the currently open version.
|
||||
"""
|
||||
|
||||
__tablename__ = "energy_contract_version"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# FK to the parent contract. RESTRICT prevents deletion of a contract that
|
||||
# still has versioned pricing rows attached to it.
|
||||
contract_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("energy_contract.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
# Start of this version's validity window (inclusive, UTC).
|
||||
effective_from: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
# End of this version's validity window (exclusive, UTC). NULL means open-ended
|
||||
# (i.e. this is the most recent / current version).
|
||||
effective_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# Pricing values as a JSON object conforming to the profile structure for
|
||||
# ``contract.kind`` (validated by the application layer against the YAML profile).
|
||||
values: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
# Creation timestamp.
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Relationship back to the parent contract.
|
||||
contract: Mapped["EnergyContract"] = relationship(back_populates="versions")
|
||||
|
||||
# Relationship to cost periods that reference this version.
|
||||
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
|
||||
back_populates="contract_version", cascade="save-update, merge"
|
||||
)
|
||||
|
||||
|
||||
class TibberPrice(Base):
|
||||
"""Cached Tibber 15-minute spot price point (immutable once fetched).
|
||||
|
||||
``starts_at`` is unique so that upserts are idempotent. Past prices are
|
||||
never overwritten; the fetch job only adds rows for future time slots.
|
||||
"""
|
||||
|
||||
__tablename__ = "tibber_price"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# UTC start of the 15-minute slot; unique so upsert is idempotent.
|
||||
starts_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, unique=True
|
||||
)
|
||||
|
||||
# Resolution label as returned by the Tibber API (e.g. "QUARTER_HOURLY").
|
||||
resolution: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
|
||||
# Price components in the contract currency (all include VAT, user-facing).
|
||||
energy: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
tax: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
total: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
# Tibber price level (e.g. "NORMAL", "CHEAP", "EXPENSIVE"); may be absent.
|
||||
level: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
# ISO 4217 currency code as returned by the API.
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
|
||||
# UTC timestamp of when this row was fetched/inserted.
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class EnergyCostPeriod(Base):
|
||||
"""Computed billing record for one 15-minute metering period (immutable snapshot).
|
||||
|
||||
Each row captures the per-register kWh deltas, the resulting import cost and
|
||||
export revenue, and a full snapshot of the pricing values used so that the
|
||||
calculation is fully auditable and reproducible without re-querying the
|
||||
contract version. Rows are written once and never modified; explicit
|
||||
recomputation via the API is the only way to overwrite a period.
|
||||
"""
|
||||
|
||||
__tablename__ = "energy_cost_period"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# UTC start of the 15-minute period; unique so upsert is idempotent.
|
||||
period_start: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, unique=True
|
||||
)
|
||||
|
||||
# Per-register kWh deltas for the period (end minus start of cumulative registers).
|
||||
# _1 = dal/low-tariff, _2 = normal/high-tariff (NL convention).
|
||||
d1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered low
|
||||
d2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered high
|
||||
r1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned low
|
||||
r2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned high
|
||||
|
||||
# Computed monetary amounts for the period (in ``currency``).
|
||||
import_cost: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
export_revenue: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
net_cost: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
# ISO 4217 currency code matching the contract.
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
|
||||
# Full snapshot of the pricing inputs used during computation. This makes
|
||||
# each row self-contained and auditable even if the contract is later changed.
|
||||
pricing: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
# FK to the exact contract version whose values were used. RESTRICT prevents
|
||||
# deletion of a version that has cost records attached. Nullable to support
|
||||
# periods computed in ``degraded`` mode (missing price data).
|
||||
contract_version_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
|
||||
# True when the period was computed with incomplete data (missing readings or
|
||||
# missing price); serves as a flag for later recomputation.
|
||||
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# UTC timestamp of when this row was computed/inserted.
|
||||
computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Relationship back to the contract version.
|
||||
contract_version: Mapped["EnergyContractVersion | None"] = relationship(
|
||||
back_populates="cost_periods"
|
||||
)
|
||||
|
||||
|
||||
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
||||
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
||||
# no composite index is needed for single-meter deployments.)
|
||||
|
||||
# Index on period_start is covered by the unique constraint (SQLite creates an
|
||||
# implicit index for UNIQUE columns), so no additional index is required.
|
||||
|
||||
# Index on starts_at for TibberPrice is covered by the unique constraint similarly.
|
||||
@@ -0,0 +1,49 @@
|
||||
"""SQLAlchemy model for the exposed-entity toggle table.
|
||||
|
||||
``ExposedEntityToggle`` stores the per-entity enable/disable state for the
|
||||
MQTT / HA Discovery expose framework. The *catalog* of what *can* be
|
||||
exposed is computed dynamically by provider functions (see
|
||||
``app/integrations/expose.py``); this table only records which entries the
|
||||
user has explicitly enabled.
|
||||
|
||||
Key design decisions
|
||||
--------------------
|
||||
- ``key`` is a stable string identifier derived from device uuid + metric key
|
||||
(e.g. ``"modbus.<uuid>.voltage"``), so it does not drift if rows are
|
||||
deleted and re-inserted.
|
||||
- Default state for an entity not yet in this table is **disabled** (false).
|
||||
``build_catalog`` in expose.py treats a missing row as ``enabled=False``.
|
||||
- Only one row per entity key (``key`` is UNIQUE).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class ExposedEntityToggle(Base):
|
||||
"""Per-entity on/off switch for MQTT / HA Discovery publishing.
|
||||
|
||||
Rows are created on demand (first toggle); entities with no row are
|
||||
treated as disabled.
|
||||
"""
|
||||
|
||||
__tablename__ = "exposed_entity_toggle"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Stable entity key, e.g. "modbus.<uuid>.voltage" or "modbus.<uuid>.online".
|
||||
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
|
||||
# Whether this entity should be published via MQTT / HA Discovery.
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Last time this row was created or modified.
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""SQLAlchemy models for Modbus device management and telemetry.
|
||||
|
||||
Two tables:
|
||||
- modbus_device: deployment/configurable metadata for each polled device.
|
||||
- modbus_reading: generic telemetry rows (one per device per poll cycle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def _uuid4_str() -> str:
|
||||
return str(_uuid.uuid4())
|
||||
|
||||
|
||||
class ModbusDevice(Base):
|
||||
"""Deployment-layer record for a Modbus slave device reachable via a TCP gateway.
|
||||
|
||||
Protocol knowledge (register map, decoding rules) lives in the YAML profile
|
||||
referenced by ``profile``. Per-device variables (network address, slave ID,
|
||||
display name, poll rate) live here.
|
||||
"""
|
||||
|
||||
__tablename__ = "modbus_device"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Stable internal identity — used as the API path key and HA Discovery unique_id anchor.
|
||||
uuid: Mapped[str] = mapped_column(
|
||||
String(36), unique=True, nullable=False, default=_uuid4_str
|
||||
)
|
||||
|
||||
# Human-readable label (may be changed; changing it triggers re-publish of HA Discovery).
|
||||
friendly_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Transport protocol — only "tcp" is supported for now.
|
||||
transport: Mapped[str] = mapped_column(String(16), nullable=False, default="tcp")
|
||||
|
||||
# TCP gateway address.
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False, default=502)
|
||||
|
||||
# Modbus slave address (set on the device panel; different meters on the same gateway
|
||||
# must have distinct unit_ids).
|
||||
unit_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
# Which YAML profile to use for register mapping and decoding (e.g. "sdm120").
|
||||
profile: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# Polling interval in seconds.
|
||||
poll_interval_s: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
|
||||
# Whether this device is included in the periodic poll sweep.
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Poll-status fields (updated by the poll service after each attempt).
|
||||
last_poll_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_poll_ok: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
# Audit timestamps.
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
# Relationship to readings (back-reference; not loaded eagerly).
|
||||
readings: Mapped[list["ModbusReading"]] = relationship(
|
||||
back_populates="device", cascade="save-update, merge"
|
||||
)
|
||||
|
||||
|
||||
class ModbusReading(Base):
|
||||
"""Generic telemetry row: one device, one poll instant, all decoded metrics as JSON.
|
||||
|
||||
``payload`` contains the full dict of engineering values produced by the YAML profile
|
||||
decoder, e.g. ``{"voltage": 230.2, "current": 1.3, ...}``. The profile is the key
|
||||
to interpreting which keys exist and what units they carry.
|
||||
"""
|
||||
|
||||
__tablename__ = "modbus_reading"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# FK to the device that produced this reading.
|
||||
# ON DELETE RESTRICT: prevents accidental deletion of a device that has historical data.
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("modbus_device.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
# The UTC timestamp of when the sample was taken — real indexed column, NOT embedded in payload.
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Profile-decoded engineering values as a JSON object.
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
device: Mapped["ModbusDevice"] = relationship(back_populates="readings")
|
||||
|
||||
|
||||
# Composite index for efficient time-range queries scoped to a single device.
|
||||
Index("ix_modbus_reading_device_recorded", ModbusReading.device_id, ModbusReading.recorded_at)
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
from sqlalchemy import Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.poo_db import PooBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PooRecord(PooBase):
|
||||
class PooRecord(Base):
|
||||
__tablename__ = "poo_records"
|
||||
|
||||
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PublicIPState(Base):
|
||||
__tablename__ = "public_ip_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
current_ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
previous_ipv4: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_check_status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
last_check_error: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
last_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class PublicIPHistory(Base):
|
||||
__tablename__ = "public_ip_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
change_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -1,28 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class PooBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
connect_args: dict[str, object] = {}
|
||||
if settings.poo_database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
poo_engine = create_engine(settings.poo_database_url, connect_args=connect_args)
|
||||
PooSessionLocal = sessionmaker(bind=poo_engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_poo_db_session() -> Generator[Session, None, None]:
|
||||
session = PooSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfigField(BaseModel):
|
||||
env_name: str
|
||||
label: str
|
||||
value: str
|
||||
secret: bool
|
||||
input_type: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class ConfigSection(BaseModel):
|
||||
name: str
|
||||
fields: list[ConfigField]
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
sections: list[ConfigSection]
|
||||
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
"""Flat mapping of env_name → value, mirroring the existing form semantics."""
|
||||
|
||||
updates: dict[str, str]
|
||||
|
||||
|
||||
class ConfigUpdateResponse(BaseModel):
|
||||
sections: list[ConfigSection]
|
||||
|
||||
|
||||
class SmtpTestResponse(BaseModel):
|
||||
"""Response from POST /api/config/smtp/test."""
|
||||
|
||||
result: Literal["success", "config-error", "failed"]
|
||||
message: str
|
||||
|
||||
|
||||
class MqttTestResponse(BaseModel):
|
||||
"""Response from POST /api/config/mqtt/test."""
|
||||
|
||||
result: Literal["success", "config-error", "failed"]
|
||||
message: str
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocationRecord(BaseModel):
|
||||
person: str
|
||||
datetime: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: float | None
|
||||
|
||||
|
||||
class LocationsResponse(BaseModel):
|
||||
items: list[LocationRecord]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class LocationUpdateRequest(BaseModel):
|
||||
"""PATCH body for a location record — all fields optional; PK fields excluded."""
|
||||
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
altitude: float | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Poo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PooRecord(BaseModel):
|
||||
timestamp: str
|
||||
status: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
class PooResponse(BaseModel):
|
||||
items: list[PooRecord]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PooUpdateRequest(BaseModel):
|
||||
"""PATCH body for a poo record — all fields optional; PK field excluded."""
|
||||
|
||||
status: str | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public IP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PublicIPStateSchema(BaseModel):
|
||||
id: int
|
||||
current_ipv4: str
|
||||
previous_ipv4: str | None
|
||||
first_seen_at: datetime
|
||||
last_checked_at: datetime
|
||||
last_changed_at: datetime | None
|
||||
last_check_status: str
|
||||
last_check_error: str | None
|
||||
last_provider: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PublicIPHistorySchema(BaseModel):
|
||||
id: int
|
||||
ipv4: str
|
||||
observed_at: datetime
|
||||
change_type: str
|
||||
provider: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PublicIPResponse(BaseModel):
|
||||
state: PublicIPStateSchema | None
|
||||
history: list[PublicIPHistorySchema]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Pydantic schemas for the Energy data API (M6-T09).
|
||||
|
||||
Covers six endpoint groups under /api/energy:
|
||||
- GET /prices — price curve (tibber 15min points or manual tariff)
|
||||
- GET /costs — energy_cost_period rows (time-range, paginated)
|
||||
- GET /costs/summary — aggregated metered + standing charges − credits
|
||||
- GET /dsmr/latest — most recent dsmr_reading blob
|
||||
- POST /costs/recompute — explicit idempotent recompute (returns count)
|
||||
- POST /tibber/test — three-state Tibber connection test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PricePointSchema(BaseModel):
|
||||
"""A single 15-minute price point (tibber) or placeholder entry."""
|
||||
|
||||
starts_at: datetime
|
||||
buy: float = Field(description="All-in buy price in EUR/kWh (including taxes).")
|
||||
sell: float = Field(description="Net sell price in EUR/kWh.")
|
||||
level: str | None = Field(
|
||||
default=None,
|
||||
description="Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.",
|
||||
)
|
||||
|
||||
|
||||
class ManualTariffSchema(BaseModel):
|
||||
"""Fixed-tariff breakdown for manual contracts.
|
||||
|
||||
Prices are the *effective* buy prices as used by the billing engine
|
||||
(energy_buy_x + energy_tax + ode) and the raw sell prices.
|
||||
"""
|
||||
|
||||
buy_dal: float = Field(description="Effective buy price, low-tariff / dal (EUR/kWh).")
|
||||
buy_normal: float = Field(description="Effective buy price, normal / high-tariff (EUR/kWh).")
|
||||
sell_dal: float = Field(description="Sell price, low-tariff / dal (EUR/kWh).")
|
||||
sell_normal: float = Field(description="Sell price, normal / high-tariff (EUR/kWh).")
|
||||
|
||||
|
||||
class PricesResponse(BaseModel):
|
||||
"""Response for GET /api/energy/prices.
|
||||
|
||||
``kind`` mirrors the active contract kind:
|
||||
- ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.
|
||||
- ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
|
||||
- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
|
||||
|
||||
``currency`` comes from the active contract (or "EUR" fallback).
|
||||
``points`` is always ascending by ``starts_at``.
|
||||
"""
|
||||
|
||||
kind: str | None = Field(
|
||||
description="Active contract kind ('tibber' or 'manual'), or null if no active contract."
|
||||
)
|
||||
currency: str = Field(description="ISO 4217 currency code.")
|
||||
points: list[PricePointSchema] = Field(
|
||||
description=(
|
||||
"15-minute price points for tibber contracts (ascending by starts_at). "
|
||||
"Empty for manual contracts or when no active contract exists."
|
||||
)
|
||||
)
|
||||
tariff: ManualTariffSchema | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Fixed tariff table for manual contracts. "
|
||||
"Null for tibber contracts and when no active contract exists."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CostPeriodSchema(BaseModel):
|
||||
"""One 15-minute billing record from the energy_cost_period table."""
|
||||
|
||||
period_start: datetime
|
||||
d1_kwh: float = Field(description="Delivered low-tariff kWh for this period.")
|
||||
d2_kwh: float = Field(description="Delivered normal-tariff kWh for this period.")
|
||||
r1_kwh: float = Field(description="Returned low-tariff kWh for this period.")
|
||||
r2_kwh: float = Field(description="Returned normal-tariff kWh for this period.")
|
||||
import_cost: float = Field(description="Cost of electricity drawn from grid (EUR).")
|
||||
export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).")
|
||||
net_cost: float = Field(description="import_cost − export_revenue (EUR).")
|
||||
currency: str = Field(description="ISO 4217 currency code.")
|
||||
degraded: bool = Field(
|
||||
description="True when the period was computed with incomplete data."
|
||||
)
|
||||
contract_version_id: int | None = Field(
|
||||
default=None,
|
||||
description="FK to the contract version used for this billing period (null when degraded).",
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CostsResponse(BaseModel):
|
||||
"""Response for GET /api/energy/costs."""
|
||||
|
||||
items: list[CostPeriodSchema]
|
||||
total: int = Field(description="Number of items returned.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
"""Response for GET /api/energy/costs/summary.
|
||||
|
||||
All monetary values are in ``currency``.
|
||||
|
||||
``total_payable = metered_net + fixed_costs − credits``
|
||||
"""
|
||||
|
||||
currency: str
|
||||
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
|
||||
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
|
||||
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
|
||||
fixed_costs: float = Field(
|
||||
description="Standing charges (network_fee + management_fee) apportioned over the interval."
|
||||
)
|
||||
credits: float = Field(
|
||||
description="Energy-tax credit (heffingskorting) apportioned over the interval."
|
||||
)
|
||||
total_payable: float = Field(
|
||||
description="metered_net + fixed_costs − credits (actual amount owed)."
|
||||
)
|
||||
period_count: int = Field(description="Number of non-degraded billing periods in range.")
|
||||
degraded_count: int = Field(description="Number of degraded billing periods in range.")
|
||||
days: float = Field(description="Interval length in days.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DsmrLatestResponse(BaseModel):
|
||||
"""Response for GET /api/energy/dsmr/latest.
|
||||
|
||||
``found`` is False when no dsmr_reading rows exist yet. The front-end
|
||||
should check ``found`` before reading ``recorded_at`` or ``payload``.
|
||||
"""
|
||||
|
||||
found: bool
|
||||
recorded_at: datetime | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecomputeResponse(BaseModel):
|
||||
"""Response for POST /api/energy/costs/recompute."""
|
||||
|
||||
recomputed: int = Field(
|
||||
description=(
|
||||
"Number of 15-minute periods for which a billing record was written "
|
||||
"(inserted or updated). Periods skipped due to missing contract or "
|
||||
"missing Tibber price are not counted."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TibberTestPriceSchema(BaseModel):
|
||||
"""Current Tibber price point returned on a successful test.
|
||||
|
||||
Carries enough fields for the front-end to confirm the API is working and
|
||||
display the live price. The API token is **never** included.
|
||||
"""
|
||||
|
||||
starts_at: datetime
|
||||
total: float
|
||||
energy: float
|
||||
tax: float
|
||||
currency: str
|
||||
level: str | None = None
|
||||
|
||||
|
||||
class TibberTestResponse(BaseModel):
|
||||
"""Three-state response for POST /api/energy/tibber/test.
|
||||
|
||||
Possible ``result`` values:
|
||||
|
||||
- ``"success"`` — Tibber API responded with a valid price.
|
||||
- ``"config-error"`` — Token is missing or not configured.
|
||||
- ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.).
|
||||
|
||||
``price`` is populated only on ``"success"``; it is null otherwise.
|
||||
``message`` always contains a human-readable explanation.
|
||||
"""
|
||||
|
||||
result: Literal["success", "config-error", "failed"]
|
||||
message: str
|
||||
price: TibberTestPriceSchema | None = None
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Pydantic schemas for the EnergyContract CRUD + versioning API (M6-T04).
|
||||
|
||||
Schema hierarchy
|
||||
----------------
|
||||
ContractVersionResponse — single version row (id, dates, values, created_at)
|
||||
ContractResponse — contract head (id, name, kind, active, currency, timestamps)
|
||||
ContractDetailResponse — contract head + embedded versions list (for GET{id})
|
||||
ContractListResponse — paginated list of ContractResponse items
|
||||
ContractCreate — POST /api/energy/contracts body
|
||||
ContractPatch — PATCH /api/energy/contracts/{id} body (all fields optional)
|
||||
VersionCreate — POST /api/energy/contracts/{id}/versions body
|
||||
ProfilesResponse — GET /api/energy/profiles response
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractVersionResponse(BaseModel):
|
||||
"""Response schema for a single EnergyContractVersion row."""
|
||||
|
||||
id: int
|
||||
effective_from: datetime
|
||||
effective_to: datetime | None
|
||||
values: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractResponse(BaseModel):
|
||||
"""Response schema for a single EnergyContract (without embedded versions).
|
||||
|
||||
Used for list responses where embedding all versions would be expensive.
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
active: bool
|
||||
currency: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ContractDetailResponse(BaseModel):
|
||||
"""Response schema for a single EnergyContract with full version history.
|
||||
|
||||
Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH
|
||||
operations where the caller needs to see all version data.
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
active: bool
|
||||
currency: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
versions: list[ContractVersionResponse]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ContractListResponse(BaseModel):
|
||||
"""Response schema for GET /api/energy/contracts."""
|
||||
|
||||
items: list[ContractResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractCreate(BaseModel):
|
||||
"""Request body for POST /api/energy/contracts.
|
||||
|
||||
``effective_from`` defaults to the current UTC time if not provided,
|
||||
giving the first version an open-ended start from "now".
|
||||
``kind`` is validated at the application layer against the profile registry;
|
||||
clients should send ``"manual"`` or ``"tibber"``.
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
kind: str = Field(..., min_length=1, max_length=32)
|
||||
currency: str = Field(default="EUR", min_length=1, max_length=8)
|
||||
values: dict[str, Any]
|
||||
effective_from: datetime | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"UTC datetime from which the first pricing version is effective. "
|
||||
"Defaults to the current UTC time when omitted."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ContractPatch(BaseModel):
|
||||
"""Request body for PATCH /api/energy/contracts/{id}.
|
||||
|
||||
All fields are optional. Sending ``active=true`` activates this contract
|
||||
(deactivating all others); ``active=false`` deactivates it without affecting
|
||||
other contracts.
|
||||
"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
class VersionCreate(BaseModel):
|
||||
"""Request body for POST /api/energy/contracts/{id}/versions."""
|
||||
|
||||
effective_from: datetime
|
||||
values: dict[str, Any]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfilesResponse(BaseModel):
|
||||
"""Response schema for GET /api/energy/profiles.
|
||||
|
||||
``profiles`` is a list of raw profile dicts as produced by
|
||||
``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum
|
||||
``kind`` and ``label``; the full nested structure allows the front-end to
|
||||
render a type-appropriate form for each pricing profile.
|
||||
"""
|
||||
|
||||
profiles: list[dict[str, Any]]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pydantic schemas for the Expose API (M5-T12).
|
||||
|
||||
Three endpoints:
|
||||
GET /api/expose — catalog + toggle state + MQTT/Discovery status
|
||||
PUT /api/expose — set toggles (map key → bool)
|
||||
POST /api/expose/republish — trigger discovery re-publish
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceInfoSchema(BaseModel):
|
||||
"""HA device grouping info for an exposable entity."""
|
||||
|
||||
identifiers: list[str]
|
||||
name: str
|
||||
|
||||
|
||||
class ExposableEntitySchema(BaseModel):
|
||||
"""One exposable entity in the catalog.
|
||||
|
||||
``value_getter`` is intentionally excluded — it is a non-serialisable
|
||||
callable and is only used internally by the HA Discovery service.
|
||||
"""
|
||||
|
||||
key: str
|
||||
component: str
|
||||
device: DeviceInfoSchema
|
||||
device_class: str | None
|
||||
unit: str
|
||||
name: str
|
||||
state_class: str | None = None
|
||||
|
||||
|
||||
class CatalogEntrySchema(BaseModel):
|
||||
"""An entity from the catalog with its current toggle state."""
|
||||
|
||||
entity: ExposableEntitySchema
|
||||
enabled: bool
|
||||
|
||||
|
||||
class MqttStatusSchema(BaseModel):
|
||||
"""Connection status for MQTT and HA Discovery."""
|
||||
|
||||
mqtt_configured: bool
|
||||
mqtt_connected: bool
|
||||
discovery_enabled: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExposeResponse(BaseModel):
|
||||
"""Response for GET /api/expose."""
|
||||
|
||||
catalog: list[CatalogEntrySchema]
|
||||
mqtt_status: MqttStatusSchema
|
||||
|
||||
|
||||
class ExposeUpdateResponse(BaseModel):
|
||||
"""Response for PUT /api/expose (returns updated catalog + status)."""
|
||||
|
||||
catalog: list[CatalogEntrySchema]
|
||||
mqtt_status: MqttStatusSchema
|
||||
|
||||
|
||||
class RepublishResponse(BaseModel):
|
||||
"""Response for POST /api/expose/republish."""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExposeUpdateRequest(BaseModel):
|
||||
"""Request body for PUT /api/expose.
|
||||
|
||||
``toggles`` is a map from entity key to desired enabled state (bool).
|
||||
Only keys present in the map are updated; absent keys are untouched.
|
||||
"""
|
||||
|
||||
toggles: dict[str, bool]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Pydantic schemas for the Modbus device CRUD + readings + metrics API (M5-T05)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusDeviceCreate(BaseModel):
|
||||
"""Request body for POST /api/modbus/devices."""
|
||||
|
||||
friendly_name: str = Field(..., min_length=1, max_length=255)
|
||||
transport: str = Field(default="tcp", max_length=16)
|
||||
host: str = Field(..., min_length=1, max_length=255)
|
||||
port: int = Field(default=502, ge=1, le=65535)
|
||||
unit_id: int = Field(default=1, ge=0, le=247)
|
||||
profile: str = Field(..., min_length=1, max_length=64)
|
||||
poll_interval_s: int = Field(default=5, ge=1, le=3600)
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class ModbusDeviceUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."""
|
||||
|
||||
friendly_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
transport: str | None = Field(default=None, max_length=16)
|
||||
host: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
unit_id: int | None = Field(default=None, ge=0, le=247)
|
||||
profile: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
poll_interval_s: int | None = Field(default=None, ge=1, le=3600)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ModbusDeviceResponse(BaseModel):
|
||||
"""Response schema for a single Modbus device."""
|
||||
|
||||
uuid: str
|
||||
friendly_name: str
|
||||
transport: str
|
||||
host: str
|
||||
port: int
|
||||
unit_id: int
|
||||
profile: str
|
||||
poll_interval_s: int
|
||||
enabled: bool
|
||||
last_poll_at: datetime | None
|
||||
last_poll_ok: bool | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ModbusDeviceListResponse(BaseModel):
|
||||
"""Response schema for listing Modbus devices."""
|
||||
|
||||
items: list[ModbusDeviceResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusReadingResponse(BaseModel):
|
||||
"""A single reading row: timestamp + decoded payload."""
|
||||
|
||||
recorded_at: datetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ModbusReadingsResponse(BaseModel):
|
||||
"""Response schema for the readings time-range endpoint."""
|
||||
|
||||
items: list[ModbusReadingResponse]
|
||||
|
||||
|
||||
class ModbusLatestResponse(BaseModel):
|
||||
"""Response for the /latest endpoint.
|
||||
|
||||
``found`` is False and ``recorded_at``/``payload`` are None when the device
|
||||
has no readings yet. Callers should check ``found`` before using the values.
|
||||
"""
|
||||
|
||||
found: bool
|
||||
recorded_at: datetime | None
|
||||
payload: dict[str, Any] | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics / profile schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MetricInfo(BaseModel):
|
||||
"""Metadata for a single measurable quantity in a device's profile."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
unit: str
|
||||
device_class: str
|
||||
|
||||
|
||||
class ModbusMetricsResponse(BaseModel):
|
||||
"""Response schema for GET /api/modbus/devices/{uuid}/metrics."""
|
||||
|
||||
profile: str
|
||||
metrics: list[MetricInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile list schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfileSummary(BaseModel):
|
||||
"""One entry in the GET /api/modbus/profiles response."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class ModbusProfilesResponse(BaseModel):
|
||||
"""Response schema for GET /api/modbus/profiles."""
|
||||
|
||||
profiles: list[ProfileSummary]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-read schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusTestReadResponse(BaseModel):
|
||||
"""Response for POST /api/modbus/devices/{uuid}/test.
|
||||
|
||||
On success ``ok=True`` and ``payload`` contains the decoded values.
|
||||
On failure ``ok=False`` and ``error`` describes the problem.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
payload: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade-delete schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusDeleteResponse(BaseModel):
|
||||
"""Response for DELETE /api/modbus/devices/{uuid}?cascade=true.
|
||||
|
||||
Returned only when cascade deletion succeeds (HTTP 200). Non-cascade
|
||||
successful deletes continue to return HTTP 204 (no body).
|
||||
"""
|
||||
|
||||
deleted: bool
|
||||
readings_deleted: int
|
||||
toggles_deleted: int
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
PublicIPCheckStatus = Literal["first_seen", "unchanged", "changed", "error"]
|
||||
|
||||
|
||||
class PublicIPCheckResponse(BaseModel):
|
||||
status: PublicIPCheckStatus
|
||||
checked_at: datetime
|
||||
changed: bool
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionUser(BaseModel):
|
||||
username: str
|
||||
force_password_change: bool
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
user: SessionUser
|
||||
csrf_token: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: str | None = None
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Pydantic schemas for TOTP setup / enable / disable / status endpoints (M4-T05)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup (POST /api/auth/totp/setup) — response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpSetupResponse(BaseModel):
|
||||
"""Returned once after a setup call.
|
||||
|
||||
``secret`` and ``recovery_codes`` are **one-time plaintext values**.
|
||||
They are never returned again by any subsequent API call.
|
||||
The frontend must display and instruct the user to save them before confirming.
|
||||
"""
|
||||
|
||||
secret: str
|
||||
otpauth_uri: str
|
||||
recovery_codes: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enable (POST /api/auth/totp/enable) — request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpEnableRequest(BaseModel):
|
||||
"""The user confirms setup by providing the 6-digit TOTP code."""
|
||||
|
||||
code: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disable (POST /api/auth/totp/disable) — request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpDisableRequest(BaseModel):
|
||||
"""Disable TOTP by proving identity.
|
||||
|
||||
Exactly one of ``password`` or ``code`` must be provided.
|
||||
"""
|
||||
|
||||
password: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status (GET /api/auth/totp) — response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpStatusResponse(BaseModel):
|
||||
"""Minimal status response — never exposes secret or recovery codes."""
|
||||
|
||||
enabled: bool
|
||||
+103
-7
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.db import reset_db_caches
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
@@ -25,8 +25,17 @@ class ConfigField:
|
||||
CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
ConfigField("System", "APP_NAME", "app_name", "App Name"),
|
||||
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
||||
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
|
||||
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug", input_type="checkbox"),
|
||||
ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"),
|
||||
ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled", input_type="checkbox"),
|
||||
ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"),
|
||||
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
|
||||
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
|
||||
ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True),
|
||||
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
|
||||
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
|
||||
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
|
||||
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS", input_type="checkbox"),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_SESSION_COOKIE_NAME",
|
||||
@@ -40,6 +49,13 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
"auth_cookie_secure_override",
|
||||
"Cookie Secure Override",
|
||||
),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_LOGIN_THROTTLE_ENABLED",
|
||||
"auth_login_throttle_enabled",
|
||||
"Login Throttle Enabled",
|
||||
input_type="checkbox",
|
||||
),
|
||||
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
|
||||
ConfigField(
|
||||
"Poo",
|
||||
@@ -87,6 +103,61 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
"home_assistant_action_task_project_id",
|
||||
"Home Assistant Action Task Project ID",
|
||||
),
|
||||
ConfigField("MQTT", "MQTT_ENABLED", "mqtt_enabled", "MQTT Enabled", input_type="checkbox"),
|
||||
ConfigField("MQTT", "MQTT_BROKER_HOST", "mqtt_broker_host", "MQTT Broker Host"),
|
||||
ConfigField("MQTT", "MQTT_BROKER_PORT", "mqtt_broker_port", "MQTT Broker Port", input_type="number"),
|
||||
ConfigField("MQTT", "MQTT_USERNAME", "mqtt_username", "MQTT Username"),
|
||||
ConfigField("MQTT", "MQTT_PASSWORD", "mqtt_password", "MQTT Password", secret=True),
|
||||
ConfigField("MQTT", "MQTT_TLS_ENABLED", "mqtt_tls_enabled", "MQTT TLS Enabled", input_type="checkbox"),
|
||||
ConfigField(
|
||||
"Home Assistant Discovery",
|
||||
"HA_DISCOVERY_ENABLED",
|
||||
"ha_discovery_enabled",
|
||||
"HA Discovery Enabled",
|
||||
input_type="checkbox",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant Discovery",
|
||||
"HA_DISCOVERY_PREFIX",
|
||||
"ha_discovery_prefix",
|
||||
"HA Discovery Prefix",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant Discovery",
|
||||
"HA_STATE_TOPIC_PREFIX",
|
||||
"ha_state_topic_prefix",
|
||||
"HA State Topic Prefix",
|
||||
),
|
||||
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
"DSMR_INGEST_ENABLED",
|
||||
"dsmr_ingest_enabled",
|
||||
"DSMR Ingest Enabled",
|
||||
input_type="checkbox",
|
||||
),
|
||||
ConfigField("DSMR", "DSMR_MQTT_TOPIC", "dsmr_mqtt_topic", "DSMR MQTT Topic"),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
"DSMR_SAMPLE_INTERVAL_S",
|
||||
"dsmr_sample_interval_s",
|
||||
"DSMR Sample Interval (s)",
|
||||
input_type="number",
|
||||
),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
"DSMR_TARIFF_TOPIC",
|
||||
"dsmr_tariff_topic",
|
||||
"DSMR Tariff Topic",
|
||||
),
|
||||
ConfigField(
|
||||
"Tibber",
|
||||
"TIBBER_API_TOKEN",
|
||||
"tibber_api_token",
|
||||
"Tibber API Token",
|
||||
secret=True,
|
||||
),
|
||||
ConfigField("Tibber", "TIBBER_HOME_ID", "tibber_home_id", "Tibber Home ID"),
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +189,7 @@ def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Setti
|
||||
current_values["APP_HOSTNAME"] = bootstrap_hostname
|
||||
_persist_config_values(session, current_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
|
||||
@@ -175,7 +246,7 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
|
||||
_validate_config_values(merged_values, bootstrap_settings)
|
||||
_persist_config_values(session, merged_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def save_config_value(
|
||||
@@ -190,7 +261,7 @@ def save_config_value(
|
||||
_validate_config_values(current_values, bootstrap_settings)
|
||||
_persist_config_values(session, current_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def is_ticktick_oauth_ready(settings: Settings) -> bool:
|
||||
@@ -251,8 +322,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"app_debug": settings.app_debug,
|
||||
"app_hostname": settings.app_hostname,
|
||||
"app_database_url": settings.app_database_url,
|
||||
"location_database_url": settings.location_database_url,
|
||||
"poo_database_url": settings.poo_database_url,
|
||||
"ticktick_client_id": settings.ticktick_client_id,
|
||||
"ticktick_client_secret": settings.ticktick_client_secret,
|
||||
"ticktick_token": settings.ticktick_token,
|
||||
@@ -260,6 +329,15 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"home_assistant_auth_token": settings.home_assistant_auth_token,
|
||||
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
|
||||
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
|
||||
"smtp_enabled": settings.smtp_enabled,
|
||||
"smtp_host": settings.smtp_host,
|
||||
"smtp_port": settings.smtp_port,
|
||||
"smtp_username": settings.smtp_username,
|
||||
"smtp_password": settings.smtp_password,
|
||||
"smtp_from_name": settings.smtp_from_name,
|
||||
"smtp_from_address": settings.smtp_from_address,
|
||||
"smtp_to_address": settings.smtp_to_address,
|
||||
"smtp_use_starttls": settings.smtp_use_starttls,
|
||||
"poo_webhook_id": settings.poo_webhook_id,
|
||||
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
|
||||
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
|
||||
@@ -268,4 +346,22 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"auth_session_cookie_name": settings.auth_session_cookie_name,
|
||||
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
|
||||
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
|
||||
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
|
||||
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
|
||||
"modbus_polling_enabled": settings.modbus_polling_enabled,
|
||||
"mqtt_enabled": settings.mqtt_enabled,
|
||||
"mqtt_broker_host": settings.mqtt_broker_host,
|
||||
"mqtt_broker_port": settings.mqtt_broker_port,
|
||||
"mqtt_username": settings.mqtt_username,
|
||||
"mqtt_password": settings.mqtt_password,
|
||||
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
||||
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
||||
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
||||
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
|
||||
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
|
||||
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
|
||||
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
|
||||
"dsmr_tariff_topic": settings.dsmr_tariff_topic,
|
||||
"tibber_api_token": settings.tibber_api_token,
|
||||
"tibber_home_id": settings.tibber_home_id,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Service layer for EnergyContract CRUD, versioning, and activation.
|
||||
|
||||
All functions accept an explicit SQLAlchemy Session; callers are responsible
|
||||
for committing or rolling back the transaction.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- ``create_contract``: validates values against the pricing profile *before*
|
||||
writing any rows; raises ``ProfileValidationError`` on non-compliance.
|
||||
- ``add_version``: append-only; closes the previous open version by
|
||||
setting its ``effective_to`` to the new version's ``effective_from``; raises
|
||||
``ContractVersionError`` if the new date is strictly earlier than the previous
|
||||
version's ``effective_from``.
|
||||
- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active``
|
||||
to False, then sets the given contract's ``active`` to True.
|
||||
- ``active_contract_version_at``: returns the single version of the currently
|
||||
active contract that covers *ts* (``effective_from ≤ ts < effective_to``,
|
||||
or open-ended when ``effective_to`` is None).
|
||||
|
||||
SQLite timezone note
|
||||
--------------------
|
||||
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
|
||||
read-back they come out as **timezone-naive** datetimes. Wherever this code
|
||||
compares timestamps from the DB against timezone-aware values (e.g. from
|
||||
Pydantic or ``datetime.now(UTC)``), it calls ``_as_utc()`` to make both sides
|
||||
comparable without tripping on "offset-naive vs offset-aware" TypeErrors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.pricing.profiles import validate_values
|
||||
from app.models.energy import EnergyContract, EnergyContractVersion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
"""Return *dt* as a timezone-aware UTC datetime.
|
||||
|
||||
SQLite's DateTime(timezone=True) column type stores datetimes as naive UTC
|
||||
strings and gives them back as naive datetimes on read. This helper
|
||||
re-attaches the UTC timezone info when it is missing, making cross-origin
|
||||
comparisons safe.
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractVersionError(ValueError):
|
||||
"""Raised when a new contract version has an invalid effective date.
|
||||
|
||||
Specifically: the new version's ``effective_from`` must be greater than or
|
||||
equal to the previous open version's ``effective_from``. Allowing equal
|
||||
timestamps would cause ambiguous overlap; the service therefore also rejects
|
||||
strictly-equal values (same second) to avoid silent data loss.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract | None:
|
||||
"""Return the contract with the given id, or None if not found."""
|
||||
return session.execute(
|
||||
select(EnergyContract).where(EnergyContract.id == contract_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_contracts(session: Session) -> list[EnergyContract]:
|
||||
"""Return all contracts ordered by id (ascending)."""
|
||||
return list(
|
||||
session.execute(select(EnergyContract).order_by(EnergyContract.id)).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def _open_version(session: Session, contract: EnergyContract) -> EnergyContractVersion | None:
|
||||
"""Return the current open version (effective_to IS NULL) for *contract*, or None."""
|
||||
return session.execute(
|
||||
select(EnergyContractVersion)
|
||||
.where(
|
||||
EnergyContractVersion.contract_id == contract.id,
|
||||
EnergyContractVersion.effective_to.is_(None),
|
||||
)
|
||||
.order_by(EnergyContractVersion.effective_from.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core service functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_contract(
|
||||
session: Session,
|
||||
*,
|
||||
name: str,
|
||||
kind: str,
|
||||
currency: str = "EUR",
|
||||
values: dict[str, Any],
|
||||
effective_from: datetime,
|
||||
) -> EnergyContract:
|
||||
"""Create a new energy contract with its first pricing version.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session. Caller must commit after this returns.
|
||||
name:
|
||||
Human-readable label for the contract.
|
||||
kind:
|
||||
Pricing strategy identifier (``"manual"`` or ``"tibber"``).
|
||||
currency:
|
||||
ISO 4217 currency code (default ``"EUR"``).
|
||||
values:
|
||||
Pricing values dict conforming to the named profile's structure.
|
||||
Validated via ``validate_values(kind, values)`` before any writes.
|
||||
effective_from:
|
||||
UTC datetime at which the first pricing version takes effect.
|
||||
|
||||
Returns
|
||||
-------
|
||||
EnergyContract
|
||||
The newly created contract (not yet committed).
|
||||
|
||||
Raises
|
||||
------
|
||||
ProfileNotFoundError
|
||||
If no YAML profile exists for *kind*.
|
||||
ProfileValidationError
|
||||
If *values* does not conform to the profile structure.
|
||||
"""
|
||||
# Validate (and fill defaults) before any DB write.
|
||||
filled_values = validate_values(kind, values)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
contract = EnergyContract(
|
||||
name=name,
|
||||
kind=kind,
|
||||
currency=currency,
|
||||
active=False, # New contracts are inactive; caller must explicitly activate.
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(contract)
|
||||
session.flush() # Assign contract.id so we can reference it in the version FK.
|
||||
|
||||
version = EnergyContractVersion(
|
||||
contract_id=contract.id,
|
||||
effective_from=effective_from,
|
||||
effective_to=None,
|
||||
values=filled_values,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(version)
|
||||
|
||||
logger.info("Created energy contract %r (kind=%s, id=%d)", name, kind, contract.id)
|
||||
return contract
|
||||
|
||||
|
||||
def add_version(
|
||||
session: Session,
|
||||
contract: EnergyContract,
|
||||
*,
|
||||
effective_from: datetime,
|
||||
values: dict[str, Any],
|
||||
) -> EnergyContractVersion:
|
||||
"""Add a new pricing version to an existing contract (append-only).
|
||||
|
||||
The previous open version's ``effective_to`` is automatically set to the
|
||||
new version's ``effective_from`` (version closure), ensuring there is never
|
||||
a gap or overlap between consecutive versions.
|
||||
|
||||
The new ``effective_from`` **must be strictly greater than** the previous
|
||||
open version's ``effective_from``. Equal timestamps are rejected because
|
||||
they would produce two versions starting at the same instant, making it
|
||||
impossible to determine which is current. If this constraint is not met,
|
||||
``ContractVersionError`` is raised and **no rows are written**.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session. Caller must commit after this returns.
|
||||
contract:
|
||||
The parent ``EnergyContract`` to append a version to.
|
||||
effective_from:
|
||||
UTC datetime at which this version's pricing takes effect.
|
||||
values:
|
||||
New pricing values dict conforming to the contract's profile.
|
||||
|
||||
Returns
|
||||
-------
|
||||
EnergyContractVersion
|
||||
The newly created version (not yet committed).
|
||||
|
||||
Raises
|
||||
------
|
||||
ContractVersionError
|
||||
If *effective_from* is not strictly after the previous open version's
|
||||
``effective_from``.
|
||||
ProfileNotFoundError
|
||||
If no YAML profile exists for the contract's kind.
|
||||
ProfileValidationError
|
||||
If *values* does not conform to the profile structure.
|
||||
"""
|
||||
# Validate (and fill defaults) before any DB write.
|
||||
filled_values = validate_values(contract.kind, values)
|
||||
|
||||
prev = _open_version(session, contract)
|
||||
if prev is not None:
|
||||
# Enforce strictly-after constraint to prevent ambiguous overlap.
|
||||
# Use _as_utc() on both sides: the incoming value may be tz-aware
|
||||
# while the DB-read value is tz-naive (SQLite limitation).
|
||||
if _as_utc(effective_from) <= _as_utc(prev.effective_from):
|
||||
raise ContractVersionError(
|
||||
f"New version effective_from ({effective_from.isoformat()}) must be strictly "
|
||||
f"after the previous open version's effective_from "
|
||||
f"({prev.effective_from.isoformat()})."
|
||||
)
|
||||
# Close the previous open version.
|
||||
prev.effective_to = effective_from
|
||||
|
||||
now = datetime.now(UTC)
|
||||
new_version = EnergyContractVersion(
|
||||
contract_id=contract.id,
|
||||
effective_from=effective_from,
|
||||
effective_to=None,
|
||||
values=filled_values,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(new_version)
|
||||
|
||||
logger.info(
|
||||
"Added version to contract id=%d (kind=%s, effective_from=%s)",
|
||||
contract.id,
|
||||
contract.kind,
|
||||
effective_from.isoformat(),
|
||||
)
|
||||
return new_version
|
||||
|
||||
|
||||
def activate_contract(session: Session, contract: EnergyContract) -> None:
|
||||
"""Activate a contract with mutual exclusion.
|
||||
|
||||
Sets every other contract's ``active`` flag to False, then sets the given
|
||||
contract's ``active`` to True. This guarantees at most one active contract
|
||||
at any time.
|
||||
|
||||
Caller must commit after this returns.
|
||||
"""
|
||||
# Deactivate all contracts (including the target; we re-activate below).
|
||||
for other in session.execute(select(EnergyContract)).scalars().all():
|
||||
other.active = False
|
||||
contract.active = True
|
||||
contract.updated_at = datetime.now(UTC)
|
||||
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
|
||||
|
||||
|
||||
def deactivate_contract(session: Session, contract: EnergyContract) -> None:
|
||||
"""Deactivate a contract without touching other contracts.
|
||||
|
||||
Caller must commit after this returns.
|
||||
"""
|
||||
contract.active = False
|
||||
contract.updated_at = datetime.now(UTC)
|
||||
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
||||
|
||||
|
||||
def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
|
||||
"""Return all versions of the currently active contract, ordered by effective_from ascending.
|
||||
|
||||
Returns an empty list when there is no active contract. The list spans the
|
||||
full history of the active contract (all closed + the current open version)
|
||||
and is used to iterate over pricing-rate segments for cross-version fixed-
|
||||
cost / credit accumulation (Principle C).
|
||||
"""
|
||||
active = session.execute(
|
||||
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if active is None:
|
||||
return []
|
||||
|
||||
return list(
|
||||
session.execute(
|
||||
select(EnergyContractVersion)
|
||||
.where(EnergyContractVersion.contract_id == active.id)
|
||||
.order_by(EnergyContractVersion.effective_from)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def active_contract_version_at(
|
||||
session: Session, ts: datetime
|
||||
) -> EnergyContractVersion | None:
|
||||
"""Return the active contract's version that covers *ts*.
|
||||
|
||||
A version covers *ts* when:
|
||||
``effective_from ≤ ts`` AND (``effective_to IS NULL`` OR ``ts < effective_to``)
|
||||
|
||||
Returns None when:
|
||||
- There is no active contract.
|
||||
- The active contract has no version covering *ts*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session (read-only usage).
|
||||
ts:
|
||||
UTC datetime to look up.
|
||||
|
||||
Returns
|
||||
-------
|
||||
EnergyContractVersion | None
|
||||
"""
|
||||
active = session.execute(
|
||||
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if active is None:
|
||||
return None
|
||||
|
||||
# Build a query for all versions of the active contract covering ts.
|
||||
stmt = (
|
||||
select(EnergyContractVersion)
|
||||
.where(
|
||||
EnergyContractVersion.contract_id == active.id,
|
||||
EnergyContractVersion.effective_from <= ts,
|
||||
)
|
||||
.order_by(EnergyContractVersion.effective_from.desc())
|
||||
.limit(1)
|
||||
)
|
||||
version = session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if version is None:
|
||||
return None
|
||||
|
||||
# Exclude versions whose effective window has already closed before ts.
|
||||
if version.effective_to is not None and _as_utc(ts) >= _as_utc(version.effective_to):
|
||||
return None
|
||||
|
||||
return version
|
||||
@@ -0,0 +1,297 @@
|
||||
"""DSMR telegram ingest service.
|
||||
|
||||
Subscribes to the DSMR Reader MQTT topic (``dsmr/json``) and persists
|
||||
down-sampled DSMR telegram frames to the ``dsmr_reading`` table.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- **Whole-frame storage**: the entire parsed telegram dict is stored as a JSON
|
||||
blob in ``DsmrReading.payload``; no field allow-list is applied. This lets
|
||||
future commodities (gas, heating, three-phase) be accommodated without a
|
||||
table-schema change.
|
||||
- **10-second down-sampling** (configurable via ``dsmr_sample_interval_s``):
|
||||
only telegrams whose ``timestamp`` second falls on an exact multiple of the
|
||||
interval are persisted. This reduces write volume from ~60 rows/min to ~6
|
||||
rows/min while guaranteeing that every 15-minute boundary (second=00) is
|
||||
captured.
|
||||
- **Idempotency**: the telegram's own ``id`` field is stored as ``source_id``
|
||||
with a UNIQUE constraint. A second delivery of the same telegram (e.g. after
|
||||
a broker reconnect) is silently skipped.
|
||||
- **Network-thread safety**: ``handle_message`` is called from paho's background
|
||||
loop thread. It opens and closes its own short-lived SQLAlchemy session and
|
||||
swallows all exceptions so that a buggy payload or transient DB error never
|
||||
crashes the paho loop or drops the MQTT connection.
|
||||
- **Numeric values kept as strings**: the DSMR Reader emits all numeric readings
|
||||
as JSON strings (e.g. ``"20915.154"``). They are stored verbatim; conversion
|
||||
to ``Decimal`` is deferred to the billing engine (T07) where precision matters.
|
||||
- **Null phases**: some telegrams omit certain phase readings (``null`` in JSON);
|
||||
these are stored as-is without special handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy.exc
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import get_session_local
|
||||
from app.models.energy import DsmrReading
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Tracks the DSMR topic currently subscribed via the MQTT manager, so a config
|
||||
# change can unsubscribe the old topic before subscribing the new one.
|
||||
_current_dsmr_topic: str | None = None
|
||||
|
||||
# Tracks the DSMR tariff topic currently subscribed via the MQTT manager.
|
||||
_current_tariff_topic: str | None = None
|
||||
|
||||
# Current electricity tariff: 1 = dal/off-peak, 2 = normal/peak.
|
||||
# Written by the paho network thread, read by the publish job — guarded by a lock.
|
||||
_current_tariff: int | None = None
|
||||
_tariff_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_current_tariff() -> int | None:
|
||||
"""Return the most recently received electricity tariff (1 or 2), or None."""
|
||||
with _tariff_lock:
|
||||
return _current_tariff
|
||||
|
||||
|
||||
def set_current_tariff(value: int | None) -> None:
|
||||
"""Set the current electricity tariff (1 or 2), or clear it with None."""
|
||||
with _tariff_lock:
|
||||
global _current_tariff
|
||||
_current_tariff = value
|
||||
|
||||
|
||||
def handle_tariff_message(payload_bytes: bytes) -> None:
|
||||
"""Parse one DSMR tariff MQTT payload and update the in-memory tariff state.
|
||||
|
||||
Called from the paho network thread; *must* swallow all exceptions so that
|
||||
a bad payload never crashes the loop or drops the broker connection.
|
||||
|
||||
Accepts payload as bytes or str (paho can deliver either). Ignores
|
||||
whitespace. Only accepts integer values 1 or 2; anything else is discarded
|
||||
and the previous known tariff is preserved.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
payload_bytes:
|
||||
Raw bytes from the MQTT message (may also be a str in some paho versions).
|
||||
"""
|
||||
try:
|
||||
# Decode bytes → str if needed; strip surrounding whitespace.
|
||||
if isinstance(payload_bytes, (bytes, bytearray)):
|
||||
raw = payload_bytes.decode("utf-8", errors="replace").strip()
|
||||
else:
|
||||
raw = str(payload_bytes).strip()
|
||||
|
||||
value = int(raw)
|
||||
if value not in (1, 2):
|
||||
logger.debug(
|
||||
"dsmr_ingest.handle_tariff_message: unexpected tariff value %d (expected 1 or 2, ignored).",
|
||||
value,
|
||||
)
|
||||
return
|
||||
|
||||
set_current_tariff(value)
|
||||
logger.debug("dsmr_ingest.handle_tariff_message: tariff updated to %d.", value)
|
||||
|
||||
except Exception:
|
||||
# Malformed payload (e.g. non-numeric); swallow silently to protect network thread.
|
||||
logger.debug(
|
||||
"dsmr_ingest.handle_tariff_message: could not parse payload %r (ignored).",
|
||||
payload_bytes,
|
||||
)
|
||||
|
||||
|
||||
def apply_dsmr_subscription(settings: "Settings") -> None:
|
||||
"""(Re)apply the DSMR MQTT subscriptions to match *settings* — restart-free.
|
||||
|
||||
Call this at startup and after every config save. It makes the live MQTT
|
||||
subscriptions reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic``
|
||||
/ ``dsmr_sample_interval_s`` / ``dsmr_tariff_topic`` settings without an app
|
||||
restart:
|
||||
|
||||
- **Disabled** → unsubscribe any active DSMR and tariff subscriptions.
|
||||
- **Enabled** → (re)subscribe to ``dsmr_mqtt_topic`` with a handler bound to
|
||||
a *fresh* settings snapshot, so a changed sample interval also takes effect.
|
||||
Also subscribe to ``dsmr_tariff_topic`` when non-empty.
|
||||
- **Topic changed** → unsubscribe the old topic before subscribing the new one.
|
||||
|
||||
Idempotent and safe to call when MQTT is not connected (the subscription is
|
||||
queued in the manager and established on the next connect).
|
||||
"""
|
||||
# Imported here (not at module top) to avoid a circular import at app start.
|
||||
from app.integrations.mqtt import mqtt_manager
|
||||
|
||||
global _current_dsmr_topic, _current_tariff_topic
|
||||
|
||||
if not settings.dsmr_ingest_enabled:
|
||||
if _current_dsmr_topic is not None:
|
||||
mqtt_manager.unsubscribe(_current_dsmr_topic)
|
||||
logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic)
|
||||
_current_dsmr_topic = None
|
||||
if _current_tariff_topic is not None:
|
||||
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||
logger.info(
|
||||
"DSMR ingest disabled — unsubscribed from tariff topic=%s.",
|
||||
_current_tariff_topic,
|
||||
)
|
||||
_current_tariff_topic = None
|
||||
return
|
||||
|
||||
# --- Main DSMR telegram topic ---
|
||||
topic = settings.dsmr_mqtt_topic
|
||||
if _current_dsmr_topic is not None and _current_dsmr_topic != topic:
|
||||
mqtt_manager.unsubscribe(_current_dsmr_topic)
|
||||
|
||||
# Re-subscribe (overwrites any existing handler for this topic) with a fresh
|
||||
# settings snapshot so dsmr_sample_interval_s changes take effect too.
|
||||
snapshot = settings
|
||||
mqtt_manager.subscribe(topic, lambda payload: handle_message(payload, snapshot))
|
||||
_current_dsmr_topic = topic
|
||||
logger.info("DSMR ingest enabled — subscribed to topic=%s.", topic)
|
||||
|
||||
# --- DSMR tariff topic (dual-tariff slot indicator) ---
|
||||
tariff_topic = settings.dsmr_tariff_topic if settings.dsmr_tariff_topic else ""
|
||||
if tariff_topic:
|
||||
if _current_tariff_topic is not None and _current_tariff_topic != tariff_topic:
|
||||
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||
mqtt_manager.subscribe(tariff_topic, lambda payload: handle_tariff_message(payload))
|
||||
_current_tariff_topic = tariff_topic
|
||||
logger.info("DSMR tariff topic — subscribed to topic=%s.", tariff_topic)
|
||||
else:
|
||||
# tariff_topic is empty → unsubscribe any existing tariff subscription.
|
||||
if _current_tariff_topic is not None:
|
||||
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||
logger.info(
|
||||
"DSMR tariff topic cleared — unsubscribed from topic=%s.",
|
||||
_current_tariff_topic,
|
||||
)
|
||||
_current_tariff_topic = None
|
||||
|
||||
|
||||
def handle_message(payload_bytes: bytes, settings: "Settings") -> None:
|
||||
"""Parse one DSMR MQTT payload and persist it if it passes the sample filter.
|
||||
|
||||
Called from the paho network thread; *must* swallow all exceptions so that
|
||||
a bad payload or transient error does not crash the loop or drop the broker
|
||||
connection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
payload_bytes:
|
||||
Raw bytes from the MQTT message.
|
||||
settings:
|
||||
Runtime settings snapshot (captured at subscription time). Used for
|
||||
``dsmr_sample_interval_s``.
|
||||
"""
|
||||
try:
|
||||
_handle_message_inner(payload_bytes, settings)
|
||||
except Exception:
|
||||
logger.exception("dsmr_ingest.handle_message: unexpected error (swallowed).")
|
||||
|
||||
|
||||
def _handle_message_inner(payload_bytes: bytes, settings: "Settings") -> None:
|
||||
"""Inner implementation — may raise; caller wraps in try/except."""
|
||||
# --- 1. Parse JSON ---
|
||||
try:
|
||||
data: dict = json.loads(payload_bytes)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.debug("dsmr_ingest: invalid JSON payload (skipped).")
|
||||
return
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.debug("dsmr_ingest: payload is not a JSON object (skipped).")
|
||||
return
|
||||
|
||||
# --- 2. Parse timestamp ---
|
||||
raw_ts = data.get("timestamp")
|
||||
if raw_ts is None:
|
||||
logger.debug("dsmr_ingest: missing 'timestamp' field (skipped).")
|
||||
return
|
||||
|
||||
try:
|
||||
# Python 3.11+ accepts the trailing 'Z' directly; for 3.10 compat we
|
||||
# replace 'Z' with '+00:00' before parsing.
|
||||
ts_str = raw_ts if not isinstance(raw_ts, str) else raw_ts.replace("Z", "+00:00")
|
||||
ts_utc: datetime = datetime.fromisoformat(ts_str)
|
||||
# Ensure it is timezone-aware UTC.
|
||||
if ts_utc.tzinfo is None:
|
||||
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
logger.debug(
|
||||
"dsmr_ingest: cannot parse 'timestamp' value %r (skipped).", raw_ts
|
||||
)
|
||||
return
|
||||
|
||||
# --- 3. Down-sample: only persist if second falls on interval boundary ---
|
||||
interval = settings.dsmr_sample_interval_s
|
||||
if interval > 0 and (ts_utc.second % interval) != 0:
|
||||
# This telegram is between sample points; discard silently.
|
||||
return
|
||||
|
||||
# --- 4. Extract source_id (telegram's own id) — stored only as a reference,
|
||||
# NOT used for uniqueness/idempotency (it overflows and gets reset). ---
|
||||
source_id: int | None = data.get("id")
|
||||
if source_id is not None and not isinstance(source_id, int):
|
||||
# Unexpected type — treat as missing rather than raising.
|
||||
logger.debug(
|
||||
"dsmr_ingest: 'id' field has unexpected type %s (ignoring).",
|
||||
type(source_id).__name__,
|
||||
)
|
||||
source_id = None
|
||||
|
||||
# --- 5. Persist to database ---
|
||||
# Idempotency is keyed on recorded_at (the telegram timestamp), which is
|
||||
# telegram-id-independent: a single P1 meter emits one telegram per second,
|
||||
# and down-sampling keeps at most one per interval-aligned second. The
|
||||
# UNIQUE(recorded_at) constraint is the backstop for the IntegrityError race.
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
existing = session.scalar(
|
||||
select(DsmrReading).where(DsmrReading.recorded_at == ts_utc)
|
||||
)
|
||||
if existing is not None:
|
||||
logger.debug(
|
||||
"dsmr_ingest: recorded_at=%s already in DB, skipping.",
|
||||
ts_utc.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
reading = DsmrReading(
|
||||
recorded_at=ts_utc,
|
||||
source_id=source_id,
|
||||
payload=data, # full frame, verbatim
|
||||
)
|
||||
session.add(reading)
|
||||
session.commit()
|
||||
logger.debug(
|
||||
"dsmr_ingest: persisted reading recorded_at=%s source_id=%s.",
|
||||
ts_utc.isoformat(),
|
||||
source_id,
|
||||
)
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
# Race / duplicate: another insert beat us to the same recorded_at.
|
||||
session.rollback()
|
||||
logger.debug(
|
||||
"dsmr_ingest: IntegrityError for recorded_at=%s (duplicate, skipped).",
|
||||
ts_utc.isoformat(),
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.exception("dsmr_ingest: DB error (swallowed).")
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
import smtplib
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class EmailConfigurationError(ValueError):
|
||||
"""Raised when SMTP settings are incomplete or disabled."""
|
||||
|
||||
|
||||
class EmailDeliveryError(RuntimeError):
|
||||
"""Raised when sending email fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMTPConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
from_name: str
|
||||
from_address: str
|
||||
to_address: str
|
||||
use_starttls: bool
|
||||
|
||||
|
||||
def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTPConfig:
|
||||
if require_enabled and not settings.smtp_enabled:
|
||||
raise EmailConfigurationError("SMTP is disabled")
|
||||
|
||||
if not settings.smtp_host:
|
||||
raise EmailConfigurationError("SMTP host is required")
|
||||
|
||||
if settings.smtp_port <= 0:
|
||||
raise EmailConfigurationError("SMTP port must be greater than zero")
|
||||
|
||||
if not settings.smtp_from_address:
|
||||
raise EmailConfigurationError("SMTP from address is required")
|
||||
|
||||
if not settings.smtp_to_address:
|
||||
raise EmailConfigurationError("SMTP to address is required")
|
||||
|
||||
return SMTPConfig(
|
||||
host=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=settings.smtp_password,
|
||||
from_name=settings.smtp_from_name,
|
||||
from_address=settings.smtp_from_address,
|
||||
to_address=settings.smtp_to_address,
|
||||
use_starttls=settings.smtp_use_starttls,
|
||||
)
|
||||
|
||||
|
||||
def is_smtp_ready(settings: Settings) -> bool:
|
||||
try:
|
||||
get_smtp_config(settings, require_enabled=False)
|
||||
except EmailConfigurationError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def send_plaintext_email(
|
||||
settings: Settings,
|
||||
*,
|
||||
subject: str,
|
||||
body: str,
|
||||
recipient: str | None = None,
|
||||
require_enabled: bool = True,
|
||||
) -> None:
|
||||
smtp_config = get_smtp_config(settings, require_enabled=require_enabled)
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = _build_from_header(smtp_config)
|
||||
message["To"] = recipient or smtp_config.to_address
|
||||
message.set_content(body)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=10) as smtp:
|
||||
smtp.ehlo()
|
||||
if smtp_config.use_starttls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if smtp_config.username:
|
||||
smtp.login(smtp_config.username, smtp_config.password)
|
||||
smtp.send_message(
|
||||
message,
|
||||
from_addr=smtp_config.from_address,
|
||||
to_addrs=[recipient or smtp_config.to_address],
|
||||
)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
error_message = _sanitize_error_message(str(exc), smtp_config.password)
|
||||
raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc
|
||||
|
||||
|
||||
def send_smtp_test_email(settings: Settings) -> None:
|
||||
send_plaintext_email(
|
||||
settings,
|
||||
subject="Home Automation SMTP Test",
|
||||
body="This is a test email from Home Automation SMTP settings.",
|
||||
require_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def send_public_ip_changed_email(
|
||||
settings: Settings,
|
||||
*,
|
||||
previous_ipv4: str,
|
||||
current_ipv4: str,
|
||||
detected_at: datetime,
|
||||
) -> None:
|
||||
send_plaintext_email(
|
||||
settings,
|
||||
subject="Public IP changed",
|
||||
body=(
|
||||
"Your public IPv4 address has changed.\n\n"
|
||||
f"Previous IP: {previous_ipv4}\n"
|
||||
f"Current IP: {current_ipv4}\n"
|
||||
f"Detected at: {_format_utc_timestamp(detected_at)}\n\n"
|
||||
"If you use Namecheap API trusted IP restrictions, you may need to "
|
||||
"update the trusted IP manually.\n"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_error_message(message: str, password: str) -> str:
|
||||
sanitized = message
|
||||
if password:
|
||||
sanitized = sanitized.replace(password, "[redacted]")
|
||||
return sanitized
|
||||
|
||||
|
||||
def _format_utc_timestamp(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
normalized = value.replace(tzinfo=UTC)
|
||||
else:
|
||||
normalized = value.astimezone(UTC)
|
||||
return normalized.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
def _build_from_header(smtp_config: SMTPConfig) -> str:
|
||||
if smtp_config.from_name:
|
||||
return formataddr((smtp_config.from_name, smtp_config.from_address))
|
||||
return smtp_config.from_address
|
||||
@@ -0,0 +1,669 @@
|
||||
"""Billing engine for DSMR 15-minute energy metering periods.
|
||||
|
||||
This module implements the two-layer billing model described in §3.4 of the
|
||||
M6 design document:
|
||||
|
||||
**Layer 1 — per-period metering cost (immutable, price-snapshot)**
|
||||
``compute_period(session, t0)`` computes the import cost, export revenue, and
|
||||
net cost for the 15-minute period ``[t0, t0+15min)``. The result is written
|
||||
to ``energy_cost_period`` with a full pricing snapshot so each row is
|
||||
self-contained and auditable. Existing *successful* rows are never overwritten
|
||||
by the normal tick path; only an explicit ``recompute_range`` call passes
|
||||
``overwrite=True``.
|
||||
|
||||
**Layer 2 — summary (computed at read time, not stored)**
|
||||
``summarize(session, start, end)`` aggregates all non-degraded
|
||||
``energy_cost_period`` rows in ``[start, end)``, then adds the daily
|
||||
standing charges (network_fee + management_fee, apportioned at EUR/month
|
||||
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
|
||||
apportioned at EUR/year ÷ 365 per day).
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- **Decimal arithmetic throughout**: all monetary computations use
|
||||
``decimal.Decimal`` to avoid float binary rounding errors. Only when
|
||||
writing to ``EnergyCostPeriod`` columns (Float) are values converted to
|
||||
float. ``summarize`` converts back to Decimal for summation.
|
||||
- **UTC quarter-hour grid**: period boundaries are aligned to UTC 00/15/30/45
|
||||
minutes (``floor_to_quarter``). NL local time (CET/CEST) is always a whole
|
||||
number of hours from UTC, so the quarter-hour grid is the same in both
|
||||
timezone representations.
|
||||
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
|
||||
for cumulative kWh registers. ``register_at`` converts them to Decimal.
|
||||
- **Degraded vs skip semantics**:
|
||||
- *Missing readings* (``register_at`` returns None for start or end
|
||||
boundary): write a ``degraded=True`` row with costs at 0 so the period is
|
||||
tracked and can be retried by ``compute_closed_periods``.
|
||||
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
|
||||
not write a row); the period will be retried once prices arrive.
|
||||
- *Missing active contract version*: skip (no contract to compute against).
|
||||
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all
|
||||
historical DSMR data on every tick, the function looks back at most 7 days
|
||||
from the current time. This covers typical short outages (no data / no
|
||||
contract) while staying bounded. Periods older than 7 days must be
|
||||
recovered via an explicit ``recompute_range`` call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.pricing.strategies import (
|
||||
PeriodDeltas,
|
||||
TibberPriceNotFoundError,
|
||||
get_strategy,
|
||||
)
|
||||
from app.models.energy import DsmrReading, EnergyCostPeriod
|
||||
from app.services.contracts import active_contract_version_at, active_contract_versions
|
||||
from app.services.timezone import local_date, local_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PERIOD_MINUTES = 15
|
||||
_LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
|
||||
|
||||
# Maximum age a DSMR reading may have relative to the boundary being queried.
|
||||
# Under normal operation DSMR readings arrive every ~10 seconds, so a reading
|
||||
# more than one period (15 minutes) old at the boundary indicates either a data
|
||||
# gap or — critically — a *future* boundary being resolved against the last
|
||||
# historical reading. In both cases the reading is considered stale and
|
||||
# ``register_at`` returns None, letting the period be marked degraded instead of
|
||||
# producing a spurious zero-delta "successful" row.
|
||||
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
|
||||
|
||||
# DSMR payload register keys (cumulative kWh, JSON string values).
|
||||
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
||||
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
||||
_KEY_R1 = "electricity_returned_1" # returned low-tariff
|
||||
_KEY_R2 = "electricity_returned_2" # returned high-tariff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def floor_to_quarter(dt: datetime) -> datetime:
|
||||
"""Return *dt* floored to the nearest UTC quarter-hour boundary.
|
||||
|
||||
The result always has seconds=0 and microseconds=0, and minutes in
|
||||
{0, 15, 30, 45}. Timezone info is preserved if present.
|
||||
"""
|
||||
floored_minute = (dt.minute // _PERIOD_MINUTES) * _PERIOD_MINUTES
|
||||
return dt.replace(minute=floored_minute, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Decimal:
|
||||
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
|
||||
"""Return the EnergyCostPeriod row for period_start=t0, or None."""
|
||||
return session.execute(
|
||||
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == t0)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register_at — boundary reading lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | None:
|
||||
"""Return the four cumulative kWh register values at *boundary*.
|
||||
|
||||
Queries the most recent ``DsmrReading`` with ``recorded_at ≤ boundary``
|
||||
and extracts the four energy registers from ``payload``:
|
||||
|
||||
d1 — electricity_delivered_1 (delivered low-tariff / dal)
|
||||
d2 — electricity_delivered_2 (delivered high-tariff / normal)
|
||||
r1 — electricity_returned_1 (returned low-tariff)
|
||||
r2 — electricity_returned_2 (returned high-tariff)
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
|
||||
when:
|
||||
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary``.
|
||||
- Any of the four register keys is absent from the payload.
|
||||
- Any of the four register values is ``None`` (null in JSON).
|
||||
"""
|
||||
row: DsmrReading | None = (
|
||||
session.execute(
|
||||
select(DsmrReading)
|
||||
.where(DsmrReading.recorded_at <= boundary)
|
||||
.order_by(DsmrReading.recorded_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
)
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Freshness guard: reject readings that are too old relative to *boundary*.
|
||||
# ``recorded_at`` is stored as a naive UTC datetime in SQLite; attach UTC
|
||||
# tzinfo before comparing with *boundary* (which is always tz-aware UTC) to
|
||||
# avoid an "offset-naive vs offset-aware" TypeError.
|
||||
if _as_utc(row.recorded_at) < _as_utc(boundary) - _READING_MAX_STALENESS:
|
||||
return None
|
||||
|
||||
payload = row.payload or {}
|
||||
try:
|
||||
d1_raw = payload[_KEY_D1]
|
||||
d2_raw = payload[_KEY_D2]
|
||||
r1_raw = payload[_KEY_R1]
|
||||
r2_raw = payload[_KEY_R2]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
if any(v is None for v in (d1_raw, d2_raw, r1_raw, r2_raw)):
|
||||
return None
|
||||
|
||||
return {
|
||||
"d1": _to_decimal(d1_raw),
|
||||
"d2": _to_decimal(d2_raw),
|
||||
"r1": _to_decimal(r1_raw),
|
||||
"r2": _to_decimal(r2_raw),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_period — single 15-minute period
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -> bool:
|
||||
"""Compute and upsert the billing record for the period ``[t0, t0+15min)``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session. Caller is responsible for committing.
|
||||
t0:
|
||||
UTC start of the 15-minute period. **Must** lie on a quarter-hour
|
||||
grid boundary (minutes ∈ {0, 15, 30, 45}, seconds=0, microseconds=0).
|
||||
overwrite:
|
||||
If ``True``, overwrite an existing *successful* row (i.e. re-compute
|
||||
even when a non-degraded record already exists). The normal tick path
|
||||
always passes ``False``; only ``recompute_range`` passes ``True``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if a record was written (inserted or updated), ``False`` if
|
||||
the period was skipped (missing contract or missing Tibber price).
|
||||
|
||||
Side-effects
|
||||
------------
|
||||
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
|
||||
- If readings are missing at either boundary: inserts/updates a degraded
|
||||
row (costs=0, degraded=True).
|
||||
- If the active contract version is missing: **skips** (returns False, no write).
|
||||
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
|
||||
(returns False, no write).
|
||||
"""
|
||||
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Immutability guard: skip if a successful record already exists and we
|
||||
# are not in overwrite mode.
|
||||
existing = _existing_period(session, t0)
|
||||
if existing is not None and not existing.degraded and not overwrite:
|
||||
return False
|
||||
|
||||
# --- Active contract version at t0 (checked before readings) ---
|
||||
# If there is no active contract covering t0, skip the period entirely.
|
||||
# We do not write a degraded row — there is no meaningful state to recover
|
||||
# without a contract (we would not know which strategy to apply once data
|
||||
# arrives). The period can be recovered via an explicit recompute_range once
|
||||
# a contract is configured and activated.
|
||||
version = active_contract_version_at(session, t0)
|
||||
if version is None:
|
||||
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
|
||||
return False
|
||||
|
||||
# --- Boundary readings ---
|
||||
start_regs = register_at(session, t0)
|
||||
end_regs = register_at(session, t1)
|
||||
|
||||
if start_regs is None or end_regs is None:
|
||||
# Missing readings → write/update a degraded placeholder so the period
|
||||
# is visible and can be retried by compute_closed_periods once data arrives.
|
||||
_upsert_degraded(session, t0, now, existing)
|
||||
return True # a record was written (degraded)
|
||||
|
||||
# --- Compute deltas (end − start) ---
|
||||
deltas = PeriodDeltas(
|
||||
d1=end_regs["d1"] - start_regs["d1"],
|
||||
d2=end_regs["d2"] - start_regs["d2"],
|
||||
r1=end_regs["r1"] - start_regs["r1"],
|
||||
r2=end_regs["r2"] - start_regs["r2"],
|
||||
)
|
||||
|
||||
# --- Price strategy ---
|
||||
strategy = get_strategy(version.contract.kind)
|
||||
try:
|
||||
result = strategy(deltas, t0, version.values, session)
|
||||
except TibberPriceNotFoundError:
|
||||
# Missing Tibber price → skip the period; it will be retried once the
|
||||
# price arrives (e.g. after the next Tibber refresh job runs).
|
||||
logger.debug(
|
||||
"compute_period(%s): no Tibber price found — skipping.", t0.isoformat()
|
||||
)
|
||||
return False
|
||||
|
||||
# --- Upsert the billing record ---
|
||||
import_cost: Decimal = result["import_cost"]
|
||||
export_revenue: Decimal = result["export_revenue"]
|
||||
net_cost: Decimal = result["net_cost"]
|
||||
pricing: dict = result["pricing"]
|
||||
|
||||
if existing is not None:
|
||||
# Update in-place (overwrite=True or previous record was degraded).
|
||||
existing.d1_kwh = float(deltas.d1)
|
||||
existing.d2_kwh = float(deltas.d2)
|
||||
existing.r1_kwh = float(deltas.r1)
|
||||
existing.r2_kwh = float(deltas.r2)
|
||||
existing.import_cost = float(import_cost)
|
||||
existing.export_revenue = float(export_revenue)
|
||||
existing.net_cost = float(net_cost)
|
||||
existing.currency = version.contract.currency
|
||||
existing.pricing = pricing
|
||||
existing.contract_version_id = version.id
|
||||
existing.degraded = False
|
||||
existing.computed_at = now
|
||||
else:
|
||||
period = EnergyCostPeriod(
|
||||
period_start=t0,
|
||||
d1_kwh=float(deltas.d1),
|
||||
d2_kwh=float(deltas.d2),
|
||||
r1_kwh=float(deltas.r1),
|
||||
r2_kwh=float(deltas.r2),
|
||||
import_cost=float(import_cost),
|
||||
export_revenue=float(export_revenue),
|
||||
net_cost=float(net_cost),
|
||||
currency=version.contract.currency,
|
||||
pricing=pricing,
|
||||
contract_version_id=version.id,
|
||||
degraded=False,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(period)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _upsert_degraded(
|
||||
session: Session,
|
||||
t0: datetime,
|
||||
now: datetime,
|
||||
existing: EnergyCostPeriod | None,
|
||||
) -> None:
|
||||
"""Insert or update a degraded placeholder for period *t0*.
|
||||
|
||||
When *existing* is not None (row was previously written — either degraded
|
||||
or successful), the row is explicitly reset to the standard degraded state.
|
||||
This is required for the ``recompute_range`` (overwrite=True) path: if the
|
||||
row was previously a *successful* computation and the boundary readings have
|
||||
since disappeared, the stale non-zero costs must be cleared so the row
|
||||
accurately reflects the current "missing readings" state rather than
|
||||
masquerading as a valid result.
|
||||
"""
|
||||
if existing is not None:
|
||||
# Explicitly reset to degraded state — identical field values to the
|
||||
# new-row path below. This covers the recompute-over-successful-row
|
||||
# case where old non-zero costs must not survive the downgrade.
|
||||
existing.d1_kwh = 0.0
|
||||
existing.d2_kwh = 0.0
|
||||
existing.r1_kwh = 0.0
|
||||
existing.r2_kwh = 0.0
|
||||
existing.import_cost = 0.0
|
||||
existing.export_revenue = 0.0
|
||||
existing.net_cost = 0.0
|
||||
existing.pricing = {}
|
||||
existing.contract_version_id = None
|
||||
existing.degraded = True
|
||||
existing.computed_at = now
|
||||
else:
|
||||
period = EnergyCostPeriod(
|
||||
period_start=t0,
|
||||
d1_kwh=0.0,
|
||||
d2_kwh=0.0,
|
||||
r1_kwh=0.0,
|
||||
r2_kwh=0.0,
|
||||
import_cost=0.0,
|
||||
export_revenue=0.0,
|
||||
net_cost=0.0,
|
||||
currency="EUR", # placeholder; real currency known after contract lookup
|
||||
pricing={},
|
||||
contract_version_id=None,
|
||||
degraded=True,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(period)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_closed_periods — periodic tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_closed_periods(session: Session) -> int:
|
||||
"""Find and compute all uncalculated closed 15-minute periods.
|
||||
|
||||
A period ``[t0, t1)`` is *closed* when ``t1 ≤ now``. This function:
|
||||
|
||||
1. Determines the lookback window: from ``now − LOOKBACK_DAYS`` to ``now``,
|
||||
floored to the nearest quarter-hour. This avoids an unbounded full
|
||||
historical scan on every tick while still covering the typical recovery
|
||||
window (short outages, missing contract, etc.). Periods older than
|
||||
``LOOKBACK_DAYS`` must be recovered via an explicit ``recompute_range``.
|
||||
2. Iterates over all quarter-hour boundaries in that window where
|
||||
``t1 ≤ now`` (i.e. the period has already closed).
|
||||
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
|
||||
- Skips periods that already have a *successful* (non-degraded) record.
|
||||
- Retries periods that have a *degraded* record.
|
||||
- Skips periods for which no active contract version exists or the
|
||||
Tibber price is unavailable (without writing a degraded row).
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of periods for which a record was written (inserted or updated).
|
||||
Does not count skipped periods.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
# Current period boundary (the one whose t1 has not yet passed).
|
||||
current_t0 = floor_to_quarter(now)
|
||||
# Earliest boundary to consider.
|
||||
earliest_t0 = floor_to_quarter(now - timedelta(days=_LOOKBACK_DAYS))
|
||||
|
||||
written = 0
|
||||
t0 = earliest_t0
|
||||
while t0 < current_t0:
|
||||
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||
if t1 <= now:
|
||||
try:
|
||||
did_write = compute_period(session, t0, overwrite=False)
|
||||
if did_write:
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"compute_closed_periods: unexpected error for t0=%s — continuing.",
|
||||
t0.isoformat(),
|
||||
)
|
||||
t0 += timedelta(minutes=_PERIOD_MINUTES)
|
||||
|
||||
if written:
|
||||
session.commit()
|
||||
logger.info("compute_closed_periods: wrote %d period(s).", written)
|
||||
return written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# recompute_range — explicit full recompute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def recompute_range(session: Session, start: datetime, end: datetime) -> int:
|
||||
"""Recompute (overwrite) all 15-minute periods in ``[start, end)``.
|
||||
|
||||
This is the *explicit opt-in* path for recovering from:
|
||||
- Periods where readings or prices arrived late.
|
||||
- Price corrections (new contract version retroactively applied).
|
||||
- Any other reason to override the immutability guard.
|
||||
|
||||
The function iterates over every UTC quarter-hour boundary in
|
||||
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
|
||||
Existing rows (including successful ones) are overwritten.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session. The function commits after all periods
|
||||
have been processed.
|
||||
start:
|
||||
Inclusive start datetime (floored to the nearest quarter-hour internally).
|
||||
end:
|
||||
Exclusive end datetime.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of periods for which a record was written (inserted or updated).
|
||||
Periods skipped due to missing contract or missing Tibber price are
|
||||
*not* counted.
|
||||
"""
|
||||
t0 = floor_to_quarter(_as_utc(start))
|
||||
end_utc = _as_utc(end)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
written = 0
|
||||
while t0 < end_utc:
|
||||
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||
# Only recompute periods that have already closed (t1 ≤ now). Even
|
||||
# when the caller passes a future *end*, we must not write speculative
|
||||
# "future" rows: register_at would resolve to the latest historical
|
||||
# reading for both boundaries, producing a zero-delta fake-success row
|
||||
# that blocks the real computation once the period actually closes.
|
||||
if t1 <= now:
|
||||
try:
|
||||
did_write = compute_period(session, t0, overwrite=True)
|
||||
if did_write:
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"recompute_range: unexpected error for t0=%s — continuing.",
|
||||
t0.isoformat(),
|
||||
)
|
||||
t0 += timedelta(minutes=_PERIOD_MINUTES)
|
||||
|
||||
session.commit()
|
||||
logger.info(
|
||||
"recompute_range(%s, %s): wrote %d period(s).",
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
written,
|
||||
)
|
||||
return written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# summarize — layer-2 aggregation (read-time, not stored)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
"""Aggregate billing for the interval ``[start, end)``.
|
||||
|
||||
Computes the total payable as:
|
||||
|
||||
total_payable = Σ(net_cost) -- metered electricity
|
||||
+ fixed_costs -- per-day standing charges, cross-version
|
||||
- credits -- per-day heffingskorting, cross-version
|
||||
|
||||
**Fixed-cost / credit counting — Principle C**:
|
||||
Only *already-elapsed* whole local calendar days are counted. For each
|
||||
local calendar date D in the window ``[start_local_date, min(end_local_date,
|
||||
tomorrow_local))``, the contract version whose rate covers D (the one whose
|
||||
effective_from local-date ≤ D < next version's effective_from local-date) is
|
||||
used. Days that have not yet started in local time (D > today_local) are
|
||||
never counted. This is cross-version: if the active contract has V1 from
|
||||
June 1 and V2 from June 25, querying June 1–30 uses V1 for days 1-24 and V2
|
||||
for day 25. Switching versions never resets the counter.
|
||||
|
||||
**Timezone note**: the ``days`` field in the returned dict still represents
|
||||
the window length in calendar days (total_seconds / 86400), for backward
|
||||
compatibility with existing API consumers. The fixed/credit calculation
|
||||
independently counts whole local days as described above.
|
||||
|
||||
All arithmetic uses Decimal; the returned dict contains Python floats for
|
||||
JSON-serialisation convenience.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active read-only SQLAlchemy session.
|
||||
start:
|
||||
Inclusive start of the summary interval (UTC or naive-UTC).
|
||||
end:
|
||||
Exclusive end of the summary interval (UTC or naive-UTC).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with keys:
|
||||
|
||||
currency str ISO 4217 currency (from contract, or "EUR" fallback)
|
||||
metered_import float Σ import_cost from non-degraded periods
|
||||
metered_export float Σ export_revenue from non-degraded periods
|
||||
metered_net float Σ net_cost from non-degraded periods
|
||||
fixed_costs float standing charges for elapsed whole local days
|
||||
credits float energy-tax credit for elapsed whole local days
|
||||
total_payable float metered_net + fixed_costs − credits
|
||||
period_count int number of non-degraded periods in range
|
||||
degraded_count int number of degraded periods in range
|
||||
days float interval length in days (total_seconds / 86400)
|
||||
"""
|
||||
from datetime import timedelta as _td, date as _date
|
||||
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
|
||||
# --- Fetch all EnergyCostPeriod rows in [start, end) ---
|
||||
rows = session.execute(
|
||||
select(EnergyCostPeriod).where(
|
||||
EnergyCostPeriod.period_start >= start_utc,
|
||||
EnergyCostPeriod.period_start < end_utc,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
good_rows = [r for r in rows if not r.degraded]
|
||||
degraded_rows = [r for r in rows if r.degraded]
|
||||
|
||||
# Σ monetary amounts (Decimal arithmetic).
|
||||
sum_import = sum((_to_decimal(r.import_cost) 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"))
|
||||
|
||||
# --- Interval length in days (window, not elapsed — kept for API compat) ---
|
||||
total_seconds = (end_utc - start_utc).total_seconds()
|
||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||
|
||||
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
||||
today_local: _date = local_now().date()
|
||||
|
||||
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
||||
#
|
||||
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
|
||||
# AND D ≤ today_local (only elapsed / today days count as "whole days").
|
||||
#
|
||||
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
|
||||
# AND the local midnight is within the billing window."
|
||||
#
|
||||
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
|
||||
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
|
||||
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
|
||||
#
|
||||
# Implementation: compute the first and last local date whose midnight is in range.
|
||||
from app.services.timezone import local_midnight_utc as _lmu
|
||||
|
||||
local_start_date: _date = local_date(start_utc)
|
||||
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
|
||||
if _lmu(local_start_date) >= start_utc:
|
||||
first_counted: _date = local_start_date
|
||||
else:
|
||||
first_counted = local_start_date + _td(days=1)
|
||||
|
||||
local_end_date: _date = local_date(end_utc)
|
||||
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
||||
if _lmu(local_end_date) < end_utc:
|
||||
last_counted: _date = local_end_date
|
||||
else:
|
||||
last_counted = local_end_date - _td(days=1)
|
||||
|
||||
# Cap: only elapsed or today's days.
|
||||
last_counted = min(last_counted, today_local)
|
||||
|
||||
# If the range is empty (first_counted > last_counted), no days are counted.
|
||||
|
||||
# Fetch all versions of the active contract once (single DB call).
|
||||
versions = active_contract_versions(session)
|
||||
|
||||
fixed_dec = Decimal("0")
|
||||
credits_dec = Decimal("0")
|
||||
currency = "EUR"
|
||||
|
||||
if versions:
|
||||
# Currency comes from the contract regardless of day count.
|
||||
currency = versions[0].contract.currency
|
||||
|
||||
if versions and first_counted <= last_counted:
|
||||
# For each version segment, find its overlap with [first_counted, last_counted]
|
||||
# and accumulate whole days.
|
||||
version_segments: list[tuple[_date, _date | None, dict]] = []
|
||||
for v in versions:
|
||||
v_start_local = local_date(_as_utc(v.effective_from))
|
||||
v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None
|
||||
version_segments.append((v_start_local, v_end_local, v.values or {}))
|
||||
|
||||
for v_start, v_end_excl, v_values in version_segments:
|
||||
# The version covers [v_start, v_end_excl) in local dates,
|
||||
# where v_end_excl=None means open-ended (no upper bound).
|
||||
# Intersection with [first_counted, last_counted]:
|
||||
seg_start = max(first_counted, v_start)
|
||||
if v_end_excl is not None:
|
||||
seg_end_excl = min(last_counted + _td(days=1), v_end_excl)
|
||||
else:
|
||||
seg_end_excl = last_counted + _td(days=1)
|
||||
|
||||
if seg_start >= seg_end_excl:
|
||||
continue # no overlap
|
||||
|
||||
n_days = (seg_end_excl - seg_start).days
|
||||
if n_days <= 0:
|
||||
continue
|
||||
|
||||
standing: dict = v_values.get("standing", {})
|
||||
creds: dict = v_values.get("credits", {})
|
||||
|
||||
network_fee = _to_decimal(standing.get("network_fee", 0))
|
||||
management_fee = _to_decimal(standing.get("management_fee", 0))
|
||||
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
|
||||
|
||||
# Standing charges: EUR/month → EUR/day (÷ 30) × n_days.
|
||||
fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days))
|
||||
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days.
|
||||
credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days))
|
||||
|
||||
total_payable = sum_net + fixed_dec - credits_dec
|
||||
|
||||
return {
|
||||
"currency": currency,
|
||||
"metered_import": float(sum_import),
|
||||
"metered_export": float(sum_export),
|
||||
"metered_net": float(sum_net),
|
||||
"fixed_costs": float(fixed_dec),
|
||||
"credits": float(credits_dec),
|
||||
"total_payable": float(total_payable),
|
||||
"period_count": len(good_rows),
|
||||
"degraded_count": len(degraded_rows),
|
||||
"days": float(days),
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
"""HA MQTT Discovery service — build and publish discovery configs + state.
|
||||
|
||||
This module handles:
|
||||
|
||||
1. Building HA MQTT Discovery payloads for each ``ExposableEntity``.
|
||||
2. Publishing discovery configs (retained) for enabled entities.
|
||||
3. Clearing (empty-payload) discovery configs for disabled entities.
|
||||
4. Publishing current entity state values.
|
||||
5. A per-device helper for use in the Modbus poll loop.
|
||||
|
||||
Design
|
||||
------
|
||||
- **No-op when MQTT / discovery is not configured**: every public function
|
||||
checks ``ha_discovery_enabled`` and ``mqtt_manager.is_connected`` before
|
||||
doing any work. Callers never need to guard these conditions.
|
||||
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
|
||||
from mutable fields like ``friendly_name`` or auto-increment DB ids.
|
||||
- **Discovery topic format**: ``<discovery_prefix>/<component>/<node_id>/<object_id>/config``
|
||||
where ``node_id`` is the device uuid (slugified to be safe) and
|
||||
``object_id`` is a uuid-prefixed stable string. Uses ``ha_discovery_prefix``
|
||||
(default ``"homeassistant"``), which must stay under the HA discovery namespace.
|
||||
- **State topic format**: ``<state_prefix>/<component>/<node_id>/<object_id>/state``
|
||||
Uses ``ha_state_topic_prefix`` (default ``"home_automation"``), separate from
|
||||
the discovery namespace so HA discovery and state/availability topics live under
|
||||
different prefixes.
|
||||
- **Availability topic**: ``<state_prefix>/modbus/<node_id>/availability`` (shared
|
||||
across all entities of the same device; publishes "online"/"offline"). Also uses
|
||||
the state prefix, not the discovery prefix.
|
||||
- **best-effort**: functions catch all exceptions internally so that callers
|
||||
(e.g. the Modbus poll loop) never crash due to MQTT publish errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.expose import ExposableEntity, build_catalog
|
||||
from app.integrations.mqtt import mqtt_manager
|
||||
from app.services.config_page import build_runtime_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _should_publish(settings: Any) -> bool:
|
||||
"""Return True only if MQTT and HA Discovery are both enabled and connected."""
|
||||
return bool(
|
||||
settings.mqtt_enabled
|
||||
and settings.ha_discovery_enabled
|
||||
and mqtt_manager.is_connected
|
||||
)
|
||||
|
||||
|
||||
def _node_id(device_uuid: str) -> str:
|
||||
"""Stable MQTT node_id derived from device uuid (replace hyphens for safety)."""
|
||||
return device_uuid.replace("-", "_")
|
||||
|
||||
|
||||
def _object_id(entity: ExposableEntity) -> str:
|
||||
"""Stable MQTT object_id derived from entity key (dots + hyphens → underscores)."""
|
||||
return entity.key.replace(".", "_").replace("-", "_")
|
||||
|
||||
|
||||
def _discovery_topic(entity: ExposableEntity, prefix: str) -> str:
|
||||
"""Build the HA Discovery config topic for *entity*.
|
||||
|
||||
Format: ``<prefix>/<component>/<node_id>/<object_id>/config``
|
||||
"""
|
||||
node = _node_id(entity.device.identifiers[1]) # device uuid
|
||||
obj = _object_id(entity)
|
||||
return f"{prefix}/{entity.component}/{node}/{obj}/config"
|
||||
|
||||
|
||||
def _state_topic(entity: ExposableEntity, prefix: str) -> str:
|
||||
"""Build the state publish topic for *entity*.
|
||||
|
||||
Format: ``<prefix>/<component>/<node_id>/<object_id>/state``
|
||||
"""
|
||||
node = _node_id(entity.device.identifiers[1])
|
||||
obj = _object_id(entity)
|
||||
return f"{prefix}/{entity.component}/{node}/{obj}/state"
|
||||
|
||||
|
||||
def _availability_topic(device_uuid: str, prefix: str) -> str:
|
||||
"""Shared availability topic for all entities of a device.
|
||||
|
||||
Format: ``<prefix>/modbus/<node_id>/availability``
|
||||
"""
|
||||
node = _node_id(device_uuid)
|
||||
return f"{prefix}/modbus/{node}/availability"
|
||||
|
||||
|
||||
def _unique_id(entity: ExposableEntity) -> str:
|
||||
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
# entity.key is already "modbus.<uuid>.<metric_key>" — use it as the seed
|
||||
return f"{device_uuid}_{entity.key.replace('.', '_')}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: build discovery payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_discovery_payload(
|
||||
entity: ExposableEntity,
|
||||
discovery_prefix: str,
|
||||
state_prefix: str | None = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Build the HA MQTT Discovery config topic and payload dict for *entity*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity:
|
||||
An ``ExposableEntity`` (from ``build_catalog``).
|
||||
discovery_prefix:
|
||||
The HA Discovery config topic prefix (e.g. ``"homeassistant"``).
|
||||
This determines where HA looks for the discovery config topic.
|
||||
state_prefix:
|
||||
The prefix used for state and availability topics (e.g.
|
||||
``"home_automation"``). When omitted or ``None``, falls back to
|
||||
``discovery_prefix`` for backward compatibility (e.g. in tests that
|
||||
pass only one prefix).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str, dict]
|
||||
``(topic, config_dict)`` — the config topic and the HA Discovery
|
||||
payload (not yet JSON-serialised).
|
||||
"""
|
||||
if state_prefix is None:
|
||||
state_prefix = discovery_prefix
|
||||
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
topic = _discovery_topic(entity, discovery_prefix)
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"unique_id": _unique_id(entity),
|
||||
"name": entity.name,
|
||||
"state_topic": state_t,
|
||||
"device": {
|
||||
"identifiers": list(entity.device.identifiers),
|
||||
"name": entity.device.name,
|
||||
},
|
||||
}
|
||||
|
||||
# Only declare an availability topic for devices that actually publish an
|
||||
# online/offline heartbeat (e.g. Modbus, via its "online" binary_sensor).
|
||||
# Devices without a heartbeat (e.g. energy-cost) omit availability so HA
|
||||
# treats their entities as always-available — otherwise HA would mark them
|
||||
# ``unavailable`` forever even though their state is being published.
|
||||
if entity.device.provides_availability:
|
||||
config["availability"] = [{"topic": avail_topic}]
|
||||
config["availability_mode"] = "all"
|
||||
|
||||
# Component-specific fields
|
||||
if entity.component == "binary_sensor":
|
||||
# "online" binary sensor: payload ON/OFF
|
||||
config["payload_on"] = "ON"
|
||||
config["payload_off"] = "OFF"
|
||||
if entity.device_class:
|
||||
config["device_class"] = entity.device_class
|
||||
else:
|
||||
# sensor (and others)
|
||||
if entity.device_class:
|
||||
config["device_class"] = entity.device_class
|
||||
if entity.unit:
|
||||
config["unit_of_measurement"] = entity.unit
|
||||
if entity.state_class:
|
||||
config["state_class"] = entity.state_class
|
||||
|
||||
return topic, config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: publish discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def publish_discovery(session: Session) -> None:
|
||||
"""Publish HA Discovery configs for all entities in the catalog.
|
||||
|
||||
- Enabled entities: publish retained JSON config payload.
|
||||
- Disabled entities: publish empty (b"") payload to clear any previously
|
||||
retained config from HA.
|
||||
|
||||
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||
All MQTT errors are caught internally — this function never raises.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
settings = build_runtime_settings(session, get_settings())
|
||||
if not _should_publish(settings):
|
||||
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
|
||||
return
|
||||
|
||||
discovery_prefix = settings.ha_discovery_prefix
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
|
||||
try:
|
||||
catalog = build_catalog(session)
|
||||
except Exception:
|
||||
logger.exception("publish_discovery: failed to build catalog; aborting")
|
||||
return
|
||||
|
||||
for entry in catalog:
|
||||
entity = entry.entity
|
||||
try:
|
||||
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
||||
if entry.enabled:
|
||||
payload = json.dumps(config)
|
||||
mqtt_manager.publish(topic, payload, retain=True)
|
||||
logger.debug(
|
||||
"publish_discovery: published config for %r → %s", entity.key, topic
|
||||
)
|
||||
else:
|
||||
# Clear retained config for disabled entities.
|
||||
mqtt_manager.publish(topic, b"", retain=True)
|
||||
logger.debug(
|
||||
"publish_discovery: cleared config for disabled entity %r → %s",
|
||||
entity.key,
|
||||
topic,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_discovery: error processing entity %r; continuing", entity.key
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: publish states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def publish_states(session: Session) -> None:
|
||||
"""Publish current state values for all enabled entities.
|
||||
|
||||
Calls each entity's ``value_getter`` to obtain the current value and
|
||||
publishes it to the entity's state topic. Also publishes availability
|
||||
(online/offline) for each device.
|
||||
|
||||
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||
All errors are caught internally — this function never raises.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
settings = build_runtime_settings(session, get_settings())
|
||||
if not _should_publish(settings):
|
||||
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
|
||||
return
|
||||
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
|
||||
try:
|
||||
catalog = build_catalog(session)
|
||||
except Exception:
|
||||
logger.exception("publish_states: failed to build catalog; aborting")
|
||||
return
|
||||
|
||||
# Collect enabled entries and publish
|
||||
for entry in catalog:
|
||||
if not entry.enabled:
|
||||
continue
|
||||
entity = entry.entity
|
||||
try:
|
||||
_publish_entity_state(entity, state_prefix, session)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_states: error publishing state for %r; continuing", entity.key
|
||||
)
|
||||
|
||||
|
||||
def _publish_entity_state(
|
||||
entity: ExposableEntity,
|
||||
prefix: str,
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""Publish one entity's current state value to its state topic.
|
||||
|
||||
Also publishes the availability topic for ``binary_sensor`` "online" entities.
|
||||
"""
|
||||
state_t = _state_topic(entity, prefix)
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
|
||||
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||
# The online sensor represents device availability.
|
||||
# Ask the value_getter for the current ON/OFF string, then also publish
|
||||
# the shared availability topic (online/offline).
|
||||
raw_value = None
|
||||
if entity.value_getter is not None:
|
||||
try:
|
||||
raw_value = entity.value_getter(session)
|
||||
except Exception:
|
||||
logger.exception("value_getter raised for online entity %r", entity.key)
|
||||
# Default to offline when no reading is available.
|
||||
online = (raw_value == "ON")
|
||||
avail_payload = "online" if online else "offline"
|
||||
avail_topic = _availability_topic(device_uuid, prefix)
|
||||
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||
# The state of the binary_sensor itself
|
||||
state_payload = "ON" if online else "OFF"
|
||||
mqtt_manager.publish(state_t, state_payload, retain=False)
|
||||
else:
|
||||
# Regular sensor: call value_getter(session) if available.
|
||||
value = None
|
||||
if entity.value_getter is not None:
|
||||
try:
|
||||
value = entity.value_getter(session)
|
||||
except Exception:
|
||||
logger.exception("value_getter raised for entity %r", entity.key)
|
||||
|
||||
if value is None:
|
||||
logger.debug("publish_states: no value for %r — skipping state publish", entity.key)
|
||||
return
|
||||
|
||||
mqtt_manager.publish(state_t, str(value), retain=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: per-device state push (called by modbus_poll after each poll)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def publish_device_state(session: Session, device: Any) -> None:
|
||||
"""Publish state + availability for all enabled entities of *device*.
|
||||
|
||||
Called by ``modbus_poll.poll_device`` after a successful (or failed) poll.
|
||||
Publishes:
|
||||
- The current availability topic ("online" / "offline") for the device.
|
||||
- The state topic for each **enabled** entity of the device.
|
||||
|
||||
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||
All errors are caught internally — never raises.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
settings = build_runtime_settings(session, get_settings())
|
||||
if not _should_publish(settings):
|
||||
return
|
||||
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
device_uuid = device.uuid
|
||||
online = bool(device.last_poll_ok)
|
||||
|
||||
# Publish availability topic first.
|
||||
try:
|
||||
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||
avail_payload = "online" if online else "offline"
|
||||
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_device_state: failed to publish availability for device uuid=%s", device_uuid
|
||||
)
|
||||
|
||||
if not online:
|
||||
# Device is offline — no point pushing stale state values.
|
||||
return
|
||||
|
||||
# Publish state for each enabled entity of this device.
|
||||
try:
|
||||
catalog = build_catalog(session)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_device_state: failed to build catalog for device uuid=%s", device_uuid
|
||||
)
|
||||
return
|
||||
|
||||
for entry in catalog:
|
||||
if not entry.enabled:
|
||||
continue
|
||||
entity = entry.entity
|
||||
# Only process entities belonging to this device.
|
||||
if entity.device.identifiers[1] != device_uuid:
|
||||
continue
|
||||
# Skip the online binary_sensor itself (availability handled above).
|
||||
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||
# Publish the binary_sensor state too.
|
||||
try:
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
mqtt_manager.publish(state_t, "ON", retain=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_device_state: error publishing online sensor state for %r",
|
||||
entity.key,
|
||||
)
|
||||
continue
|
||||
|
||||
# Regular sensor — call value_getter(session).
|
||||
try:
|
||||
value = None
|
||||
if entity.value_getter is not None:
|
||||
value = entity.value_getter(session)
|
||||
if value is None:
|
||||
continue
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
mqtt_manager.publish(state_t, str(value), retain=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_device_state: error publishing state for entity %r", entity.key
|
||||
)
|
||||
|
||||
|
||||
def clear_device_discovery(session: Session, device_uuid: str) -> None:
|
||||
"""Best-effort: send empty retained payloads to all HA Discovery config topics
|
||||
for the given device, effectively removing the device's entities from HA.
|
||||
|
||||
This must be called **before** the device rows are deleted from the DB, so
|
||||
that ``build_catalog`` can still enumerate the device's entities.
|
||||
|
||||
Behaviour
|
||||
---------
|
||||
- No-op if MQTT / HA Discovery is not enabled or the MQTT client is not
|
||||
connected.
|
||||
- All exceptions are caught internally; this function never raises.
|
||||
The caller proceeds with the DB deletion regardless of MQTT outcome.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session (device must still exist in DB at call time).
|
||||
device_uuid:
|
||||
UUID string of the device being deleted.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
settings = build_runtime_settings(session, get_settings())
|
||||
if not _should_publish(settings):
|
||||
logger.debug(
|
||||
"clear_device_discovery: skipped (MQTT/Discovery not enabled or not connected)"
|
||||
)
|
||||
return
|
||||
|
||||
discovery_prefix = settings.ha_discovery_prefix
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
|
||||
try:
|
||||
catalog = build_catalog(session)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"clear_device_discovery: failed to build catalog for device uuid=%s; skipping HA cleanup",
|
||||
device_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
cleared = 0
|
||||
for entry in catalog:
|
||||
entity = entry.entity
|
||||
# Only clear entities belonging to this device.
|
||||
if entity.device.identifiers[1] != device_uuid:
|
||||
continue
|
||||
try:
|
||||
topic, _config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
||||
mqtt_manager.publish(topic, b"", retain=True)
|
||||
cleared += 1
|
||||
logger.debug(
|
||||
"clear_device_discovery: cleared config topic for entity %r → %s",
|
||||
entity.key,
|
||||
topic,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"clear_device_discovery: error clearing entity %r; continuing", entity.key
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"clear_device_discovery: cleared %d HA discovery topic(s) for device uuid=%s",
|
||||
cleared,
|
||||
device_uuid,
|
||||
)
|
||||
|
||||
|
||||
def publish_device_offline(session: Session, device_uuid: str) -> None:
|
||||
"""Publish an "offline" availability payload for *device_uuid*.
|
||||
|
||||
Called by ``modbus_poll.poll_device`` when a poll fails, to immediately
|
||||
reflect the device going offline in HA (before the next full state sweep).
|
||||
|
||||
No-op if MQTT / discovery is not enabled or the client is not connected.
|
||||
Never raises.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
settings = build_runtime_settings(session, get_settings())
|
||||
if not _should_publish(settings):
|
||||
return
|
||||
|
||||
try:
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||
mqtt_manager.publish(avail_topic, "offline", retain=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_device_offline: failed for device uuid=%s", device_uuid
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.location import Location
|
||||
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
|
||||
def update_location(
|
||||
session: Session,
|
||||
person: str,
|
||||
datetime_pk: str,
|
||||
*,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
altitude: float | None,
|
||||
) -> Location | None:
|
||||
"""Update non-PK fields of a single location row.
|
||||
|
||||
Returns the updated ORM object, or ``None`` if the PK does not exist.
|
||||
The caller must not pass PK fields — they are immutable.
|
||||
Only fields with a non-``None`` value are written; ``altitude`` being
|
||||
``None`` in the request means "leave unchanged", not "clear to NULL".
|
||||
"""
|
||||
row = session.execute(
|
||||
select(Location).where(
|
||||
Location.person == person,
|
||||
Location.datetime == datetime_pk,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
if latitude is not None:
|
||||
row.latitude = latitude
|
||||
if longitude is not None:
|
||||
row.longitude = longitude
|
||||
if altitude is not None:
|
||||
row.altitude = altitude
|
||||
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def delete_location(session: Session, person: str, datetime_pk: str) -> bool:
|
||||
"""Delete the single location row identified by its full composite PK.
|
||||
|
||||
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
|
||||
not exist (caller should raise 404). The DELETE is scoped to the exact PK
|
||||
— no batch/truncate path exists.
|
||||
"""
|
||||
result = session.execute(
|
||||
delete(Location).where(
|
||||
Location.person == person,
|
||||
Location.datetime == datetime_pk,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Exponential back-off throttle service for the login endpoint.
|
||||
|
||||
Design
|
||||
------
|
||||
Failures are tracked per (scope, key) in the ``auth_login_throttle`` table:
|
||||
|
||||
* scope ``'ip'`` → key is the client IP address
|
||||
* scope ``'user'`` → key is the username
|
||||
|
||||
On each login attempt the endpoint checks **both** keys and takes the **maximum**
|
||||
remaining wait (in seconds). A wait > 0 means the caller is still inside a
|
||||
back-off window and should receive 429.
|
||||
|
||||
Exponential formula
|
||||
-------------------
|
||||
The first ``N_FREE`` failures are free (no delay). After that::
|
||||
|
||||
wait = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (failures - N_FREE))
|
||||
|
||||
Constants (module-level so they can be tuned without touching the algorithm):
|
||||
|
||||
* ``N_FREE`` = 3 — failures before any delay starts
|
||||
* ``BACKOFF_BASE_S`` = 1 — base wait in seconds (1st delayed attempt → 2 s)
|
||||
* ``BACKOFF_CAP_S`` = 900 — hard cap (15 min)
|
||||
|
||||
Example delay schedule:
|
||||
failures 1, 2, 3 → 0 s (free)
|
||||
failures 4 → 2 s
|
||||
failures 5 → 4 s
|
||||
failures 6 → 8 s
|
||||
failures 7 → 16 s
|
||||
…
|
||||
failures 13+ → 900 s (capped)
|
||||
|
||||
Timezone notes
|
||||
--------------
|
||||
The model uses ``DateTime(timezone=True)``. SQLite stores timestamps without
|
||||
timezone info, so when rows are read back the column value may be timezone-naive.
|
||||
We normalise every stored value to UTC-aware via ``_as_utc`` before comparing
|
||||
against ``datetime.now(UTC)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunable constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
N_FREE: int = 3 # failures before any backoff starts
|
||||
BACKOFF_BASE_S: int = 1 # seconds — wait after the first delayed failure
|
||||
BACKOFF_CAP_S: int = 900 # seconds — maximum wait (15 min)
|
||||
|
||||
# Internal scope labels (keep in sync with model constraint)
|
||||
_SCOPE_IP = "ip"
|
||||
_SCOPE_USER = "user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_and_get_wait(session: Session, *, ip: str, username: str) -> int:
|
||||
"""Return the number of seconds the caller must still wait, or 0 if allowed.
|
||||
|
||||
Checks both the IP row and the username row; returns the *larger* remaining
|
||||
wait so that both keys must have their back-off window expire before the
|
||||
caller is permitted to try again.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
wait_ip = _remaining_wait(session, scope=_SCOPE_IP, key=ip, now=now)
|
||||
wait_user = _remaining_wait(session, scope=_SCOPE_USER, key=username, now=now)
|
||||
return max(wait_ip, wait_user)
|
||||
|
||||
|
||||
def register_failure(session: Session, *, ip: str, username: str) -> None:
|
||||
"""Record one login failure for both the IP and the username keys.
|
||||
|
||||
Updates (or creates) the throttle row for each key, increments the failure
|
||||
counter, and recomputes ``next_allowed_at`` using the exponential formula.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
_upsert_failure(session, scope=_SCOPE_IP, key=ip, now=now)
|
||||
_upsert_failure(session, scope=_SCOPE_USER, key=username, now=now)
|
||||
session.commit()
|
||||
|
||||
|
||||
def clear(session: Session, *, ip: str, username: str) -> None:
|
||||
"""Delete the throttle rows for the given IP and username (successful login).
|
||||
|
||||
It is safe to call this even if no rows exist for the given keys.
|
||||
"""
|
||||
session.execute(
|
||||
delete(LoginThrottle).where(
|
||||
(LoginThrottle.scope == _SCOPE_IP) & (LoginThrottle.key == ip)
|
||||
| (LoginThrottle.scope == _SCOPE_USER) & (LoginThrottle.key == username)
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _remaining_wait(session: Session, *, scope: str, key: str, now: datetime) -> int:
|
||||
"""Return the number of seconds that ``key`` in ``scope`` must still wait.
|
||||
|
||||
Returns 0 if the key has no throttle row or if the back-off window has
|
||||
already expired.
|
||||
"""
|
||||
row = session.scalar(
|
||||
select(LoginThrottle)
|
||||
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
|
||||
.limit(1)
|
||||
)
|
||||
if row is None or row.next_allowed_at is None:
|
||||
return 0
|
||||
|
||||
next_allowed = _as_utc(row.next_allowed_at)
|
||||
if next_allowed <= now:
|
||||
return 0
|
||||
|
||||
remaining = (next_allowed - now).total_seconds()
|
||||
# Round up so callers are never told "0 seconds" while still blocked.
|
||||
return math.ceil(remaining)
|
||||
|
||||
|
||||
def _compute_next_allowed_at(failures: int, now: datetime) -> datetime | None:
|
||||
"""Compute the earliest time the key should be allowed to try again.
|
||||
|
||||
Returns ``None`` for the first ``N_FREE`` failures (no delay).
|
||||
"""
|
||||
if failures <= N_FREE:
|
||||
return None
|
||||
wait_s = min(BACKOFF_CAP_S, BACKOFF_BASE_S * (2 ** (failures - N_FREE)))
|
||||
return now + timedelta(seconds=wait_s)
|
||||
|
||||
|
||||
def _upsert_failure(session: Session, *, scope: str, key: str, now: datetime) -> None:
|
||||
"""Create or update the throttle row for (scope, key) with one more failure."""
|
||||
row = session.scalar(
|
||||
select(LoginThrottle)
|
||||
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
|
||||
.limit(1)
|
||||
)
|
||||
if row is None:
|
||||
new_failures = 1
|
||||
row = LoginThrottle(
|
||||
scope=scope,
|
||||
key=key,
|
||||
failures=new_failures,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
next_allowed_at=_compute_next_allowed_at(new_failures, now),
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.failures += 1
|
||||
row.last_failed_at = now
|
||||
row.next_allowed_at = _compute_next_allowed_at(row.failures, now)
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime | None:
|
||||
"""Normalise a possibly-naive datetime to UTC-aware."""
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Modbus polling service — periodic read-and-store for enabled Modbus devices.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- ``poll_device`` loads the device's YAML profile, calls the driver to bulk-read
|
||||
all register blocks in a single TCP connection, decodes the raw registers into
|
||||
an engineering-value dict via the profile, and persists one ``ModbusReading``
|
||||
row. It also stamps ``last_poll_at`` / ``last_poll_ok`` on the device.
|
||||
- All exceptions are caught and logged inside ``poll_device``; the function
|
||||
never re-raises. This prevents a single broken device from aborting the
|
||||
sweep for all other devices.
|
||||
- ``poll_all_enabled_devices`` respects per-device ``poll_interval_s``: it skips
|
||||
a device whose ``last_poll_at`` is recent enough. This lets the APScheduler
|
||||
job run on a short base tick (e.g. 5 s) while each device self-regulates its
|
||||
own cadence.
|
||||
- The global ``modbus_polling_enabled`` flag from ``Settings`` (merged with any
|
||||
DB overrides via ``build_runtime_settings``) gates the entire sweep as a
|
||||
no-op. T08 will expose this flag in CONFIG_FIELDS/UI; for now it is
|
||||
controlled by the ``MODBUS_POLLING_ENABLED`` env var (or the default True).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.integrations.modbus import driver, profiles
|
||||
from app.models.modbus import ModbusDevice, ModbusReading
|
||||
from app.services.config_page import build_runtime_settings
|
||||
from app.services.ha_discovery import publish_device_offline, publish_device_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Base polling tick in seconds (used by APScheduler; each device checks its own interval).
|
||||
BASE_POLL_TICK_SECONDS = 5
|
||||
|
||||
|
||||
def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
|
||||
"""Read one enabled Modbus device, decode, and persist a reading row.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Open SQLAlchemy session. The caller is responsible for session
|
||||
lifecycle (open / commit / close).
|
||||
device:
|
||||
``ModbusDevice`` ORM instance to poll.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ModbusReading | None
|
||||
The newly-persisted reading on success, or ``None`` on any failure.
|
||||
Failure is logged and the device's ``last_poll_ok`` is set to ``False``.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
profile = profiles.load_profile(device.profile)
|
||||
registers = driver.read_blocks(
|
||||
device.host,
|
||||
device.port,
|
||||
device.unit_id,
|
||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||
)
|
||||
payload = profiles.decode(profile, registers)
|
||||
|
||||
reading = ModbusReading(
|
||||
device_id=device.id,
|
||||
recorded_at=now,
|
||||
payload=payload,
|
||||
)
|
||||
session.add(reading)
|
||||
|
||||
device.last_poll_at = now
|
||||
device.last_poll_ok = True
|
||||
session.commit()
|
||||
|
||||
logger.debug(
|
||||
"Polled device %r (id=%d): %d metrics recorded at %s",
|
||||
device.friendly_name,
|
||||
device.id,
|
||||
len(payload),
|
||||
now.isoformat(),
|
||||
)
|
||||
|
||||
# Best-effort: push MQTT state after successful poll.
|
||||
# Must never let MQTT errors crash the poll loop.
|
||||
try:
|
||||
publish_device_state(session, device)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"poll_device: MQTT state publish failed for device %r (id=%d); "
|
||||
"continuing (poll itself succeeded)",
|
||||
device.friendly_name,
|
||||
device.id,
|
||||
)
|
||||
|
||||
return reading
|
||||
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Failed to poll device %r (id=%d, host=%s:%d, unit_id=%d)",
|
||||
device.friendly_name,
|
||||
device.id,
|
||||
device.host,
|
||||
device.port,
|
||||
device.unit_id,
|
||||
)
|
||||
# Rollback first — if session.add(reading) already ran in the success
|
||||
# branch before commit() failed, that pending ModbusReading must be
|
||||
# discarded. Without rollback it would remain staged and could be
|
||||
# flushed/committed by a subsequent operation (even for a different
|
||||
# device), producing a spurious reading row for a poll that failed.
|
||||
# Rollback also expires all ORM objects; device fields are re-set below.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"session.rollback() failed for device id=%d; session may be unusable", device.id
|
||||
)
|
||||
try:
|
||||
device.last_poll_at = now
|
||||
device.last_poll_ok = False
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Also failed to persist last_poll_ok=False for device id=%d", device.id
|
||||
)
|
||||
|
||||
# Best-effort: publish "offline" availability after failed poll.
|
||||
try:
|
||||
publish_device_offline(session, device.uuid)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"poll_device: MQTT offline publish failed for device %r (id=%d)",
|
||||
device.friendly_name,
|
||||
device.id,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def poll_all_enabled_devices(
|
||||
session: Session,
|
||||
*,
|
||||
bootstrap_settings: Settings,
|
||||
) -> None:
|
||||
"""Sweep all enabled Modbus devices and poll each one whose interval has elapsed.
|
||||
|
||||
Global kill-switch: if ``build_runtime_settings(...).modbus_polling_enabled``
|
||||
is ``False``, this function returns immediately without touching any device.
|
||||
|
||||
Per-device interval: a device is skipped if its ``last_poll_at`` is not yet
|
||||
``poll_interval_s`` seconds in the past. This allows the APScheduler job to
|
||||
run on a short fixed tick while each device self-regulates its own cadence.
|
||||
|
||||
Exceptions from individual devices are swallowed inside ``poll_device``;
|
||||
this function itself will not raise.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Open SQLAlchemy session.
|
||||
bootstrap_settings:
|
||||
The bootstrap ``Settings`` instance (from ``get_settings()``), used as
|
||||
the base for ``build_runtime_settings`` to pick up any DB overrides.
|
||||
"""
|
||||
try:
|
||||
runtime_settings = build_runtime_settings(session, bootstrap_settings)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to build runtime settings for Modbus poll; aborting sweep")
|
||||
return
|
||||
|
||||
if not runtime_settings.modbus_polling_enabled:
|
||||
logger.debug("Modbus polling is disabled (modbus_polling_enabled=False); skipping sweep")
|
||||
return
|
||||
|
||||
try:
|
||||
devices = session.execute(
|
||||
select(ModbusDevice).where(ModbusDevice.enabled == True) # noqa: E712
|
||||
).scalars().all()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to query enabled Modbus devices; aborting sweep")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for device in devices:
|
||||
# Respect per-device poll interval: skip if last poll was too recent.
|
||||
if device.last_poll_at is not None:
|
||||
# SQLite may return a naive datetime despite DateTime(timezone=True) —
|
||||
# treat naive values as UTC so the comparison is safe.
|
||||
last_poll = device.last_poll_at
|
||||
if last_poll.tzinfo is None:
|
||||
last_poll = last_poll.replace(tzinfo=UTC)
|
||||
elapsed = (now - last_poll).total_seconds()
|
||||
if elapsed < device.poll_interval_s:
|
||||
logger.debug(
|
||||
"Skipping device %r (id=%d): %.1fs elapsed < %ds interval",
|
||||
device.friendly_name,
|
||||
device.id,
|
||||
elapsed,
|
||||
device.poll_interval_s,
|
||||
)
|
||||
continue
|
||||
|
||||
poll_device(session, device)
|
||||
+48
-1
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from sqlalchemy import desc, insert, select
|
||||
from sqlalchemy import delete, desc, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
@@ -74,6 +74,53 @@ def record_poo(
|
||||
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
|
||||
|
||||
|
||||
def update_poo_record(
|
||||
session: Session,
|
||||
timestamp_pk: str,
|
||||
*,
|
||||
status: str | None,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
) -> PooRecord | None:
|
||||
"""Update non-PK fields of a single poo record row.
|
||||
|
||||
Returns the updated ORM object, or ``None`` if the PK does not exist.
|
||||
The ``timestamp`` PK is immutable and must not be passed as an update field.
|
||||
Only fields with a non-``None`` value are written.
|
||||
"""
|
||||
row = session.execute(
|
||||
select(PooRecord).where(PooRecord.timestamp == timestamp_pk)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
row.status = status
|
||||
if latitude is not None:
|
||||
row.latitude = latitude
|
||||
if longitude is not None:
|
||||
row.longitude = longitude
|
||||
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def delete_poo_record(session: Session, timestamp_pk: str) -> bool:
|
||||
"""Delete the single poo record row identified by its PK.
|
||||
|
||||
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
|
||||
not exist (caller should raise 404). The DELETE is scoped to the exact PK
|
||||
— no batch/truncate path exists.
|
||||
"""
|
||||
result = session.execute(
|
||||
delete(PooRecord).where(PooRecord.timestamp == timestamp_pk)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
|
||||
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
||||
record = session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Callable, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
from app.services.config_page import build_runtime_settings
|
||||
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_public_ip_changed_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUBLIC_IP_PROVIDER_NAME = "ipify"
|
||||
PUBLIC_IP_PROVIDER_URL = "https://api.ipify.org"
|
||||
PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
PublicIPResultStatus = Literal["first_seen", "unchanged", "changed", "error"]
|
||||
PublicIPv4Fetcher = Callable[[], str]
|
||||
|
||||
|
||||
class PublicIPCheckError(RuntimeError):
|
||||
"""Raised when the public IPv4 provider cannot return a valid IPv4."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PublicIPCheckResult:
|
||||
status: PublicIPResultStatus
|
||||
checked_at: datetime
|
||||
changed: bool
|
||||
previous_ipv4: str | None = None
|
||||
current_ipv4: str | None = None
|
||||
|
||||
|
||||
def check_public_ipv4(
|
||||
session: Session,
|
||||
*,
|
||||
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
|
||||
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
|
||||
) -> PublicIPCheckResult:
|
||||
checked_at = _utc_now()
|
||||
state = session.scalar(select(PublicIPState).where(PublicIPState.id == 1).limit(1))
|
||||
|
||||
try:
|
||||
raw_ipv4 = (fetch_public_ipv4 or fetch_public_ipv4_from_provider)()
|
||||
current_ipv4 = _validate_ipv4(raw_ipv4)
|
||||
except PublicIPCheckError as exc:
|
||||
logger.warning("Public IPv4 check failed: %s", exc)
|
||||
if state is not None:
|
||||
state.last_checked_at = checked_at
|
||||
state.last_check_status = "error"
|
||||
state.last_check_error = str(exc)
|
||||
state.last_provider = provider_name
|
||||
session.commit()
|
||||
return PublicIPCheckResult(status="error", checked_at=checked_at, changed=False)
|
||||
|
||||
if state is None:
|
||||
state = PublicIPState(
|
||||
id=1,
|
||||
current_ipv4=current_ipv4,
|
||||
previous_ipv4=None,
|
||||
first_seen_at=checked_at,
|
||||
last_checked_at=checked_at,
|
||||
last_changed_at=None,
|
||||
last_check_status="first_seen",
|
||||
last_check_error=None,
|
||||
last_provider=provider_name,
|
||||
)
|
||||
session.add(state)
|
||||
session.add(
|
||||
PublicIPHistory(
|
||||
ipv4=current_ipv4,
|
||||
observed_at=checked_at,
|
||||
change_type="first_seen",
|
||||
provider=provider_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return PublicIPCheckResult(
|
||||
status="first_seen",
|
||||
checked_at=checked_at,
|
||||
changed=False,
|
||||
current_ipv4=current_ipv4,
|
||||
)
|
||||
|
||||
if state.current_ipv4 == current_ipv4:
|
||||
state.last_checked_at = checked_at
|
||||
state.last_check_status = "unchanged"
|
||||
state.last_check_error = None
|
||||
state.last_provider = provider_name
|
||||
session.commit()
|
||||
return PublicIPCheckResult(
|
||||
status="unchanged",
|
||||
checked_at=checked_at,
|
||||
changed=False,
|
||||
current_ipv4=current_ipv4,
|
||||
)
|
||||
|
||||
previous_ipv4 = state.current_ipv4
|
||||
state.previous_ipv4 = previous_ipv4
|
||||
state.current_ipv4 = current_ipv4
|
||||
state.last_checked_at = checked_at
|
||||
state.last_changed_at = checked_at
|
||||
state.last_check_status = "changed"
|
||||
state.last_check_error = None
|
||||
state.last_provider = provider_name
|
||||
session.add(
|
||||
PublicIPHistory(
|
||||
ipv4=current_ipv4,
|
||||
observed_at=checked_at,
|
||||
change_type="changed",
|
||||
provider=provider_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return PublicIPCheckResult(
|
||||
status="changed",
|
||||
checked_at=checked_at,
|
||||
changed=True,
|
||||
previous_ipv4=previous_ipv4,
|
||||
current_ipv4=current_ipv4,
|
||||
)
|
||||
|
||||
|
||||
def check_public_ipv4_and_notify(
|
||||
session: Session,
|
||||
*,
|
||||
bootstrap_settings: Settings,
|
||||
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
|
||||
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
|
||||
) -> PublicIPCheckResult:
|
||||
result = check_public_ipv4(
|
||||
session,
|
||||
fetch_public_ipv4=fetch_public_ipv4,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
|
||||
if result.status != "changed" or result.previous_ipv4 is None or result.current_ipv4 is None:
|
||||
return result
|
||||
|
||||
runtime_settings = build_runtime_settings(session, bootstrap_settings)
|
||||
try:
|
||||
send_public_ip_changed_email(
|
||||
runtime_settings,
|
||||
previous_ipv4=result.previous_ipv4,
|
||||
current_ipv4=result.current_ipv4,
|
||||
detected_at=result.checked_at,
|
||||
)
|
||||
except (EmailConfigurationError, EmailDeliveryError) as exc:
|
||||
logger.warning("Public IPv4 change notification failed: %s", exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def fetch_public_ipv4_from_provider() -> str:
|
||||
try:
|
||||
response = httpx.get(
|
||||
PUBLIC_IP_PROVIDER_URL,
|
||||
params={"format": "text"},
|
||||
timeout=PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise PublicIPCheckError(f"provider request failed: {exc}") from exc
|
||||
|
||||
return response.text.strip()
|
||||
|
||||
|
||||
def _validate_ipv4(raw_value: str) -> str:
|
||||
if not raw_value:
|
||||
raise PublicIPCheckError("provider returned an empty response")
|
||||
|
||||
try:
|
||||
parsed = ipaddress.ip_address(raw_value)
|
||||
except ValueError as exc:
|
||||
raise PublicIPCheckError("provider returned an invalid IPv4 value") from exc
|
||||
|
||||
if parsed.version != 4:
|
||||
raise PublicIPCheckError("provider returned a non-IPv4 value")
|
||||
|
||||
return str(parsed)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Service layer for fetching and persisting Tibber 15-minute electricity prices.
|
||||
|
||||
``refresh_prices`` is the main entry point. It is designed to be called from a
|
||||
scheduled background job (see ``app/main.py``) and from tests.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- **Guard clause**: if no active contract with ``kind="tibber"`` exists, or if
|
||||
the Tibber API token is empty in the runtime settings, the function is a
|
||||
complete no-op (returns 0) and does not raise. This means the scheduler can
|
||||
call ``refresh_prices`` unconditionally; the service itself decides whether to
|
||||
do anything based on current configuration.
|
||||
- **Upsert idempotency**: rows are matched by ``starts_at`` (the unique
|
||||
constraint on ``tibber_price``). If a row already exists, its price fields
|
||||
and ``fetched_at`` are updated in-place; if it does not exist, a new row is
|
||||
inserted. Running ``refresh_prices`` twice in a row must not double-insert.
|
||||
- **No destructive operations**: this service never deletes ``tibber_price``
|
||||
rows. Only INSERT or UPDATE.
|
||||
- **Exception propagation**: exceptions from the Tibber client are *not*
|
||||
swallowed here. The scheduled job wrapper in ``main.py`` is responsible for
|
||||
catching and logging errors so that a single fetch failure does not crash the
|
||||
scheduler.
|
||||
- **SQLite timezone note**: ``DateTime(timezone=True)`` columns come back as
|
||||
timezone-naive UTC datetimes on read. We store UTC-aware datetimes on write
|
||||
(``starts_at`` from ``PricePoint`` is already UTC-aware; ``fetched_at`` is
|
||||
``datetime.now(UTC)``). Reads elsewhere that compare against these values
|
||||
must normalise accordingly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.tibber.client import fetch_price_range
|
||||
from app.models.energy import EnergyContract, TibberPrice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _active_tibber_contract_exists(session: Session) -> bool:
|
||||
"""Return True if there is an active contract with kind='tibber'."""
|
||||
row = session.execute(
|
||||
select(EnergyContract).where(
|
||||
EnergyContract.active.is_(True),
|
||||
EnergyContract.kind == "tibber",
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
return row is not None
|
||||
|
||||
|
||||
def refresh_prices(session: Session, settings: object) -> int:
|
||||
"""Fetch today-and-tomorrow Tibber prices and upsert them into ``tibber_price``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
An active SQLAlchemy session. The caller is responsible for closing it;
|
||||
this function commits each upserted row individually to keep transactions
|
||||
short.
|
||||
settings:
|
||||
A runtime settings object that exposes ``tibber_api_token`` and
|
||||
``tibber_home_id`` attributes (typically a ``Settings`` or merged
|
||||
runtime-settings instance from ``build_runtime_settings``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of rows upserted (inserted or updated). Returns 0 for a no-op.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- No-op (returns 0) when:
|
||||
* No active contract exists with ``kind="tibber"``, OR
|
||||
* ``settings.tibber_api_token`` is empty or whitespace.
|
||||
- Exceptions from the Tibber client are *not* caught here; they propagate to
|
||||
the caller (the scheduled job wrapper swallows them and logs).
|
||||
"""
|
||||
token: str = getattr(settings, "tibber_api_token", "") or ""
|
||||
if not token.strip():
|
||||
logger.debug("refresh_prices: tibber_api_token is empty — no-op")
|
||||
return 0
|
||||
|
||||
if not _active_tibber_contract_exists(session):
|
||||
logger.debug("refresh_prices: no active tibber contract — no-op")
|
||||
return 0
|
||||
|
||||
home_id: str = getattr(settings, "tibber_home_id", "") or ""
|
||||
home_id_or_none: str | None = home_id.strip() or None
|
||||
|
||||
logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none)
|
||||
|
||||
# May raise TibberError or TibberAuthError — let them propagate.
|
||||
price_points = fetch_price_range(token, home_id_or_none)
|
||||
|
||||
if not price_points:
|
||||
logger.info("refresh_prices: API returned zero price points — nothing to upsert")
|
||||
return 0
|
||||
|
||||
fetched_at = datetime.now(UTC)
|
||||
upserted = 0
|
||||
|
||||
for point in price_points:
|
||||
existing = session.execute(
|
||||
select(TibberPrice).where(TibberPrice.starts_at == point.starts_at)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
# Update in-place — price fields may change (e.g. Tibber corrects a
|
||||
# forecast), but starts_at and resolution stay the same.
|
||||
existing.total = point.total
|
||||
existing.energy = point.energy
|
||||
existing.tax = point.tax
|
||||
existing.level = point.level
|
||||
existing.currency = point.currency
|
||||
existing.fetched_at = fetched_at
|
||||
else:
|
||||
session.add(
|
||||
TibberPrice(
|
||||
starts_at=point.starts_at,
|
||||
resolution=point.resolution,
|
||||
total=point.total,
|
||||
energy=point.energy,
|
||||
tax=point.tax,
|
||||
level=point.level,
|
||||
currency=point.currency,
|
||||
fetched_at=fetched_at,
|
||||
)
|
||||
)
|
||||
|
||||
upserted += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info("refresh_prices: upserted %d price points", upserted)
|
||||
return upserted
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Server local-timezone helpers.
|
||||
|
||||
All time is stored as UTC (no change). This module provides a single,
|
||||
monkeypatch-friendly entry point for converting UTC moments to the server's
|
||||
local timezone — used for business-day boundary calculations (standing charges,
|
||||
effective-from date interpretation, etc.).
|
||||
|
||||
Design goals
|
||||
------------
|
||||
- Zero third-party dependencies (no tzlocal, no pytz).
|
||||
- Testable without depending on CI host timezone:
|
||||
monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests.
|
||||
- DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each
|
||||
moment is independently correct even during DST transitions.
|
||||
- Consistent with the existing ``.astimezone()`` pattern already used in
|
||||
``homeassistant_inbound.py`` and ``poo.py``.
|
||||
|
||||
Priority for resolving the local timezone
|
||||
-----------------------------------------
|
||||
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
|
||||
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
|
||||
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``.
|
||||
This matches the behaviour callers already relied on implicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import tzinfo
|
||||
|
||||
|
||||
def local_tz() -> "tzinfo":
|
||||
"""Return the server's local timezone.
|
||||
|
||||
Resolution order:
|
||||
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
|
||||
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
|
||||
2. System local timezone via ``datetime.now().astimezone().tzinfo``.
|
||||
|
||||
**Monkeypatch this function in tests** to get deterministic timezone
|
||||
behaviour regardless of CI host configuration::
|
||||
|
||||
monkeypatch.setattr("app.services.timezone.local_tz",
|
||||
lambda: ZoneInfo("Europe/Amsterdam"))
|
||||
"""
|
||||
tz_env = os.environ.get("TZ", "").strip()
|
||||
if tz_env:
|
||||
return ZoneInfo(tz_env)
|
||||
# System fallback — identical to the .astimezone() pattern already used
|
||||
# in homeassistant_inbound.py and poo.py.
|
||||
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
||||
|
||||
|
||||
def to_local(dt: datetime) -> datetime:
|
||||
"""Convert *dt* to the server local timezone.
|
||||
|
||||
Handles three input cases:
|
||||
- timezone-aware (any zone): converted via ``astimezone``.
|
||||
- timezone-naive: assumed UTC (SQLite read-back convention), then converted.
|
||||
|
||||
Returns a timezone-aware datetime in ``local_tz()``.
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(local_tz())
|
||||
|
||||
|
||||
def local_now() -> datetime:
|
||||
"""Return the current moment as a timezone-aware local datetime."""
|
||||
return datetime.now(local_tz())
|
||||
|
||||
|
||||
def local_date(dt: datetime) -> date:
|
||||
"""Return the local calendar date for *dt*.
|
||||
|
||||
Equivalent to ``to_local(dt).date()``. A distinct helper to make
|
||||
call sites readable.
|
||||
"""
|
||||
return to_local(dt).date()
|
||||
|
||||
|
||||
def local_midnight_utc(d: date) -> datetime:
|
||||
"""Return the UTC instant that corresponds to local midnight on *d*.
|
||||
|
||||
Example (Europe/Amsterdam, CEST=UTC+2):
|
||||
local_midnight_utc(date(2026, 6, 25))
|
||||
→ datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
|
||||
|
||||
This is used to convert a local calendar date back to a UTC boundary, e.g.
|
||||
to compute the start/end of a local day for DB queries.
|
||||
"""
|
||||
tz = local_tz()
|
||||
# Build a naive local datetime at midnight, then localize and convert to UTC.
|
||||
local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
|
||||
return local_midnight.astimezone(timezone.utc)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""TOTP service: secret management, recovery-code lifecycle, setup/enable/disable (M4-T05).
|
||||
|
||||
State-machine summary
|
||||
---------------------
|
||||
DISABLED (default)
|
||||
totp_secret=None, totp_enabled=False, no recovery codes
|
||||
|
||||
PENDING (after setup, before enable)
|
||||
totp_secret=<base32>, totp_enabled=False, recovery codes stored as hashes
|
||||
- Re-calling setup replaces the pending secret and regenerates recovery codes.
|
||||
|
||||
ENABLED (after enable)
|
||||
totp_secret=<base32>, totp_enabled=True, recovery codes stored as hashes
|
||||
|
||||
After disable:
|
||||
totp_secret=None, totp_enabled=False, recovery codes deleted → back to DISABLED
|
||||
|
||||
|
||||
Recovery-code timing
|
||||
--------------------
|
||||
1. setup → generate 10 plaintext codes, persist their Argon2 hashes immediately,
|
||||
return plaintext to caller (ONLY time).
|
||||
2. enable → verify 6-digit TOTP code; if ok, flip totp_enabled=True.
|
||||
Recovery codes are already in the DB from step 1.
|
||||
3. disable → clear secret, clear enabled flag, delete all recovery codes.
|
||||
|
||||
Security note: ``totp_secret`` is stored as plaintext (same posture as other
|
||||
secrets in this project: protected by file-system permissions on the SQLite
|
||||
database). Recovery codes are stored as Argon2 hashes only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth import AuthUser, RecoveryCode
|
||||
from app.services.auth import hash_password, verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Number of recovery codes to generate per setup
|
||||
_RECOVERY_CODE_COUNT = 10
|
||||
# Alphabet for each 4-character segment: lowercase letters + digits, no ambiguous chars
|
||||
_CODE_ALPHABET = string.ascii_lowercase + string.digits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _generate_recovery_code() -> str:
|
||||
"""Return a single recovery code in ``xxxx-xxxx`` format."""
|
||||
segment = lambda: "".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) # noqa: E731
|
||||
return f"{segment()}-{segment()}"
|
||||
|
||||
|
||||
def _verify_totp_code(secret: str, code: str) -> bool:
|
||||
"""Verify a 6-digit TOTP code against the given base-32 secret (±1 window)."""
|
||||
return pyotp.TOTP(secret).verify(code, valid_window=1)
|
||||
|
||||
|
||||
def _delete_recovery_codes(db: Session, *, user_id: int) -> None:
|
||||
"""Delete ALL recovery codes for a user (called on setup re-run and disable)."""
|
||||
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def setup(
|
||||
db: Session,
|
||||
*,
|
||||
user: AuthUser,
|
||||
issuer: str,
|
||||
) -> tuple[str, str, list[str]]:
|
||||
"""Generate a new pending TOTP secret and recovery codes.
|
||||
|
||||
The secret is stored in ``user.totp_secret`` (``totp_enabled`` stays
|
||||
``False``). Any pre-existing pending secret and recovery codes are
|
||||
replaced atomically.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(secret, otpauth_uri, plaintext_recovery_codes)
|
||||
|
||||
``plaintext_recovery_codes`` are returned **once** here and MUST NOT be
|
||||
stored or returned elsewhere.
|
||||
"""
|
||||
# Generate new TOTP secret
|
||||
new_secret = pyotp.random_base32()
|
||||
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
|
||||
name=user.username,
|
||||
issuer_name=issuer,
|
||||
)
|
||||
|
||||
# Generate plaintext recovery codes
|
||||
plaintext_codes = [_generate_recovery_code() for _ in range(_RECOVERY_CODE_COUNT)]
|
||||
|
||||
# --- Persist atomically ---
|
||||
# 1. Delete any old pending recovery codes (idempotent on re-setup)
|
||||
assert user.id is not None # mypy guard; always set after DB insertion
|
||||
_delete_recovery_codes(db, user_id=user.id)
|
||||
|
||||
# 2. Update the secret (pending — totp_enabled stays False)
|
||||
user.totp_secret = new_secret
|
||||
|
||||
# 3. Persist new recovery codes as Argon2 hashes
|
||||
for plaintext in plaintext_codes:
|
||||
db.add(RecoveryCode(user_id=user.id, code_hash=hash_password(plaintext)))
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info("TOTP setup (pending) for user '%s'; %d recovery codes generated.", user.username, _RECOVERY_CODE_COUNT)
|
||||
return new_secret, otpauth_uri, plaintext_codes
|
||||
|
||||
|
||||
def enable(db: Session, *, user: AuthUser, code: str) -> bool:
|
||||
"""Enable TOTP after the user proves they can generate the correct code.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
code:
|
||||
The current 6-digit TOTP code from the user's authenticator app.
|
||||
|
||||
Returns
|
||||
-------
|
||||
True on success; False if the code is invalid or no secret is pending.
|
||||
"""
|
||||
if not user.totp_secret:
|
||||
logger.info("TOTP enable rejected for '%s': no pending secret.", user.username)
|
||||
return False
|
||||
|
||||
if not _verify_totp_code(user.totp_secret, code):
|
||||
logger.info("TOTP enable rejected for '%s': wrong code.", user.username)
|
||||
return False
|
||||
|
||||
user.totp_enabled = True
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("TOTP enabled for user '%s'.", user.username)
|
||||
return True
|
||||
|
||||
|
||||
def disable(
|
||||
db: Session,
|
||||
*,
|
||||
user: AuthUser,
|
||||
password: str | None = None,
|
||||
code: str | None = None,
|
||||
) -> bool:
|
||||
"""Disable TOTP. Caller must supply exactly one of ``password`` or ``code``.
|
||||
|
||||
On success: ``totp_enabled=False``, ``totp_secret=None``, all recovery
|
||||
codes deleted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
True on success; False if neither credential is valid.
|
||||
"""
|
||||
if not password and not code:
|
||||
logger.info("TOTP disable rejected for '%s': no credential provided.", user.username)
|
||||
return False
|
||||
|
||||
if password:
|
||||
if not verify_password(password, user.password_hash):
|
||||
logger.info("TOTP disable rejected for '%s': wrong password.", user.username)
|
||||
return False
|
||||
elif code:
|
||||
if not user.totp_secret or not _verify_totp_code(user.totp_secret, code):
|
||||
logger.info("TOTP disable rejected for '%s': wrong TOTP code.", user.username)
|
||||
return False
|
||||
|
||||
# Clear TOTP state
|
||||
assert user.id is not None
|
||||
_delete_recovery_codes(db, user_id=user.id)
|
||||
user.totp_enabled = False
|
||||
user.totp_secret = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("TOTP disabled for user '%s'.", user.username)
|
||||
return True
|
||||
|
||||
|
||||
def verify_totp_code(user: AuthUser, code: str) -> bool:
|
||||
"""Verify a 6-digit TOTP code for a user.
|
||||
|
||||
Returns True if the code is valid (±1 time window), False otherwise.
|
||||
The user must have a ``totp_secret`` set; returns False if not.
|
||||
|
||||
This is the public entry-point for T06 login two-factor verification.
|
||||
"""
|
||||
if not user.totp_secret:
|
||||
return False
|
||||
return _verify_totp_code(user.totp_secret, code)
|
||||
|
||||
|
||||
def verify_recovery_code(db: Session, *, user: AuthUser, code: str) -> bool:
|
||||
"""Verify and consume a one-time recovery code.
|
||||
|
||||
Finds the first unused recovery code whose hash matches ``code``, marks it
|
||||
as consumed (sets ``used_at``), and returns ``True``. Returns ``False`` if
|
||||
no matching unused code exists.
|
||||
|
||||
This function is provided for T06 (login two-factor) but lives here so the
|
||||
TOTP service owns all recovery-code logic.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
unused = db.execute(
|
||||
select(RecoveryCode).where(
|
||||
RecoveryCode.user_id == user.id,
|
||||
RecoveryCode.used_at.is_(None),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for rc in unused:
|
||||
if verify_password(code, rc.code_hash):
|
||||
rc.used_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Recovery code consumed for user '%s' (id=%d).", user.username, rc.id
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Config · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>Config</h1>
|
||||
|
||||
{% if force_password_change %}
|
||||
<div class="alert">
|
||||
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if password_change_error %}
|
||||
<div class="alert">{{ password_change_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config_error %}
|
||||
<div class="alert">{{ config_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config_saved %}
|
||||
<div class="notice">config saved to the app database. Some changes may require an app restart.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ticktick_oauth_error %}
|
||||
<div class="alert">{{ ticktick_oauth_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ticktick_oauth_notice %}
|
||||
<div class="notice">{{ ticktick_oauth_notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta single-column">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
<dd>admin</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="config-block">
|
||||
<h2>Change Password</h2>
|
||||
<form class="auth-form" method="post" action="/config/change-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Current Password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>New Password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="config-block">
|
||||
<h2>Config</h2>
|
||||
<form class="config-form" method="post" action="/config">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{% for section in config_sections %}
|
||||
<fieldset class="config-section">
|
||||
<legend>{{ section.name }}</legend>
|
||||
{% for field in section.fields %}
|
||||
<label>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.secret %}
|
||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
|
||||
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
|
||||
{% else %}
|
||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
{% if section.name == "TickTick" %}
|
||||
<div class="integration-action-row">
|
||||
<div>
|
||||
<p class="integration-action-title">TickTick OAuth</p>
|
||||
<p class="integration-action-copy">Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}</p>
|
||||
{% if ticktick_oauth_ready %}
|
||||
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
|
||||
{% else %}
|
||||
<p class="integration-action-copy">Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ticktick_oauth_ready %}
|
||||
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
|
||||
{% else %}
|
||||
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit">Save Config</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,36 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Python Rewrite Skeleton</p>
|
||||
<h1>{{ app_name }}</h1>
|
||||
<p class="lead">
|
||||
这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、
|
||||
测试、模板和容器化基础,不包含业务逻辑迁移。
|
||||
</p>
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>运行环境</dt>
|
||||
<dd>{{ app_env }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>健康检查</dt>
|
||||
<dd><a href="/status">/status</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>OpenAPI</dt>
|
||||
<dd><a href="/docs">/docs</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>登录</dt>
|
||||
<dd><a href="/login">/login</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Notion</dt>
|
||||
<dd>{{ notion_status }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}登录 · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel auth-panel">
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1>登录</h1>
|
||||
<p class="lead">
|
||||
登录成功后会进入受保护的 config 页面。
|
||||
</p>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="alert">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+20
-12
@@ -8,15 +8,17 @@ alembic==1.18.4
|
||||
# via -r requirements.in
|
||||
annotated-types==0.7.0
|
||||
# via pydantic
|
||||
argon2-cffi==25.1.0
|
||||
# via -r requirements.in
|
||||
argon2-cffi-bindings==25.1.0
|
||||
# via argon2-cffi
|
||||
anyio==4.13.0
|
||||
# via
|
||||
# httpx
|
||||
# starlette
|
||||
# watchfiles
|
||||
apscheduler==3.11.2
|
||||
# via -r requirements.in
|
||||
argon2-cffi==25.1.0
|
||||
# via -r requirements.in
|
||||
argon2-cffi-bindings==25.1.0
|
||||
# via argon2-cffi
|
||||
build==1.4.3
|
||||
# via pip-tools
|
||||
certifi==2026.2.25
|
||||
@@ -42,30 +44,32 @@ httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
# via uvicorn
|
||||
httpx==0.28.1
|
||||
# via -r dev-requirements.in
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# -r requirements.in
|
||||
idna==3.11
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
iniconfig==2.3.0
|
||||
# via pytest
|
||||
jinja2==3.1.6
|
||||
# via -r requirements.in
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
markupsafe==3.0.3
|
||||
# via
|
||||
# jinja2
|
||||
# mako
|
||||
# via mako
|
||||
packaging==26.1
|
||||
# via
|
||||
# build
|
||||
# pytest
|
||||
# wheel
|
||||
paho-mqtt==2.1.0
|
||||
# via -r requirements.in
|
||||
pip-tools==7.5.3
|
||||
# via -r dev-requirements.in
|
||||
pluggy==1.6.0
|
||||
# via pytest
|
||||
pycparser==2.23
|
||||
# via cffi
|
||||
pydantic==2.13.2
|
||||
# via
|
||||
# fastapi
|
||||
@@ -76,6 +80,10 @@ pydantic-settings==2.13.1
|
||||
# via -r requirements.in
|
||||
pygments==2.20.0
|
||||
# via pytest
|
||||
pymodbus==3.13.1
|
||||
# via -r requirements.in
|
||||
pyotp==2.10.0
|
||||
# via -r requirements.in
|
||||
pyproject-hooks==1.2.0
|
||||
# via
|
||||
# build
|
||||
@@ -88,8 +96,6 @@ python-dotenv==1.2.2
|
||||
# uvicorn
|
||||
python-multipart==0.0.26
|
||||
# via -r requirements.in
|
||||
pycparser==2.23
|
||||
# via cffi
|
||||
pyyaml==6.0.3
|
||||
# via
|
||||
# -r requirements.in
|
||||
@@ -112,6 +118,8 @@ typing-inspection==0.4.2
|
||||
# via
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
tzlocal==5.3.1
|
||||
# via apscheduler
|
||||
uvicorn[standard]==0.44.0
|
||||
# via -r requirements.in
|
||||
uvloop==0.22.1
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Local dev override — use explicitly:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||
# Isolated from the production stack so both can run on this host at once:
|
||||
# - distinct compose project name (separate network/grouping)
|
||||
# - distinct container names (-dev suffix; Docker rejects duplicate names)
|
||||
# - distinct image tag (local build doesn't clobber the prod :latest tag)
|
||||
name: home-automation-dev
|
||||
|
||||
services:
|
||||
migration:
|
||||
build: .
|
||||
image: home-automation:dev
|
||||
container_name: home-automation-migration-dev
|
||||
environment:
|
||||
# In-container path for the mounted ./data volume (./data -> /app/data).
|
||||
# Overrides the host-absolute APP_DATABASE_URL in .env for local compose runs.
|
||||
APP_DATABASE_URL: "sqlite:////app/data/app.db"
|
||||
|
||||
app:
|
||||
build: .
|
||||
image: home-automation:dev
|
||||
container_name: home-automation-app-dev
|
||||
# Publish on 8002 for dev. `!override` REPLACES the base ports list instead of
|
||||
# appending to it, so the dev stack does NOT also bind the production 8881.
|
||||
ports: !override
|
||||
- "127.0.0.1:8002:8000"
|
||||
environment:
|
||||
APP_DATABASE_URL: "sqlite:////app/data/app.db"
|
||||
@@ -1,6 +0,0 @@
|
||||
services:
|
||||
migration:
|
||||
build: .
|
||||
|
||||
app:
|
||||
build: .
|
||||
+2
-16
@@ -9,6 +9,7 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./.env:/app/.env:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
app:
|
||||
container_name: home-automation-app
|
||||
@@ -24,20 +25,5 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./.env:/app/.env:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: home-automation-grafana
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10.238.75.70:8882:3000"
|
||||
environment:
|
||||
GF_PLUGINS_PREINSTALL: frser-sqlite-datasource
|
||||
volumes:
|
||||
- ./data:/data/home-automation:ro
|
||||
- homeautomation_grafana_storage:/var/lib/grafana
|
||||
|
||||
volumes:
|
||||
homeautomation_grafana_storage:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user