- README: M6 section + feature list + new tables/config items. - roadmap: M6 row (graduated) + detail section; M6 in milestone index. - architecture-overview: M6 models/services/integrations/routes/jobs/config. - design/README: index m6, drop stale '三个里程碑' wording. - OpenAPI already in sync (no diff).
668 lines
27 KiB
Markdown
668 lines
27 KiB
Markdown
# Home Automation Backend
|
||
|
||
这是当前 `home-automation` 项目的首个 Python 版本。
|
||
|
||
当前系统已经包含:
|
||
|
||
- 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(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 部署入口
|
||
|
||
当前明确不包含:
|
||
|
||
- Notion 模块
|
||
|
||
## 当前配置现实
|
||
|
||
当前系统使用单一 SQLite 数据库文件(`app.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` 表,快照价,不可变)
|
||
|
||
配置层只保留一个数据库环境变量:
|
||
|
||
- `APP_DATABASE_URL`
|
||
|
||
`app.db` 不会在应用启动时自动创建,需要先运行:
|
||
|
||
```bash
|
||
python -m scripts.run_migrations
|
||
```
|
||
|
||
该命令会通过 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 应用代码(包含 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`),纳入版本控制
|
||
|
||
## 依赖管理
|
||
|
||
项目现在采用 `pip-tools` 管理依赖:
|
||
|
||
- 生产依赖源文件:`requirements.in`
|
||
- 开发依赖源文件:`dev-requirements.in`
|
||
- 编译产物:
|
||
- `requirements.txt`
|
||
- `dev-requirements.txt`
|
||
|
||
更新依赖时建议使用:
|
||
|
||
```bash
|
||
python -m venv .venv
|
||
source .venv/bin/activate
|
||
pip install pip-tools
|
||
pip-compile requirements.in
|
||
pip-compile dev-requirements.in
|
||
```
|
||
|
||
如果要升级某个依赖,可以用:
|
||
|
||
```bash
|
||
pip-compile --upgrade-package fastapi requirements.in
|
||
pip-compile dev-requirements.in
|
||
```
|
||
|
||
## 本地启动
|
||
|
||
建议使用 Python 3.11 或以上版本。
|
||
|
||
1. 创建虚拟环境并安装依赖
|
||
|
||
```bash
|
||
python -m venv .venv
|
||
source .venv/bin/activate
|
||
pip install -r dev-requirements.txt
|
||
```
|
||
|
||
2. 准备环境变量
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
3. 初始化数据库
|
||
|
||
```bash
|
||
python -m scripts.run_migrations
|
||
```
|
||
|
||
4. 启动服务
|
||
|
||
```bash
|
||
uvicorn app.main:app --reload --host 0.0.0.0 --port 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 数据库文件:
|
||
|
||
- App DB:`sqlite:///./data/app.db`
|
||
- 数据目录:`./data/`
|
||
|
||
所有模型(auth / config / public_ip / location / poo / modbus / expose)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
||
|
||
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||
- 统一 migration job:`python -m scripts.run_migrations`
|
||
- App DB 接管 / 初始化:`python scripts/app_db_adopt.py`
|
||
|
||
历史 location / poo 数据(旧版本遗留的独立 DB 文件)已通过以下脚本一次性迁移至 `app.db`(幂等,不删除旧文件):
|
||
|
||
```bash
|
||
python -m scripts.migrate_legacy_data
|
||
```
|
||
|
||
## 基础鉴权
|
||
|
||
当前项目提供一个单用户 admin 鉴权层,用于保护配置页面与管理能力。
|
||
|
||
- 认证模型:`username/password`
|
||
- 会话模型:server-side session + cookie
|
||
- 当前受保护入口:React SPA(`/` 等客户端路由)调用 `/api/*` JSON 端点
|
||
- 当前公开页面:`/login`(SPA 登录页)
|
||
- 当前公开 API:裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
|
||
|
||
安全实现的当前边界:
|
||
|
||
- 密码使用 Argon2 做哈希存储
|
||
- session cookie 使用 `HttpOnly`
|
||
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
|
||
- `SameSite=Lax`
|
||
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
|
||
|
||
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
|
||
|
||
- `AUTH_BOOTSTRAP_USERNAME`
|
||
- `AUTH_BOOTSTRAP_PASSWORD`
|
||
|
||
创建初始 admin 用户。当前默认就是:
|
||
|
||
- username: `admin`
|
||
- password: `admin`
|
||
|
||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
||
|
||
React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`):
|
||
|
||
- `/login`:登录页
|
||
- `/`:首页(地图热力图主视图)
|
||
- `/config`:配置页(取代原 Jinja `/config`)
|
||
- `/records`:记录管理列表页
|
||
|
||
无论是本地 `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 持久化
|
||
|
||
当前 config 页面不会把修改写回 `.env`。
|
||
|
||
当前原则是:
|
||
|
||
- `.env` 只负责 bootstrap / fallback
|
||
- app 启动先从 `.env` 读取数据库地址等基础配置
|
||
- 请求期读取配置时,优先使用 app DB 中的 `app_config` 表
|
||
- 如果数据库里没有对应值,再 fallback 到 `.env`
|
||
|
||
这意味着:
|
||
|
||
- 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 定义:
|
||
|
||
```bash
|
||
python scripts/export_openapi.py
|
||
```
|
||
|
||
导出结果会写入:
|
||
|
||
- `openapi/openapi.json`
|
||
- `openapi/openapi.yaml`
|
||
|
||
## Docker Compose
|
||
|
||
当前默认 Compose 服务名为 `app`,容器名固定为 `home-automation-app`。
|
||
|
||
当前 Compose 分成两层:
|
||
|
||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
|
||
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
|
||
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
|
||
|
||
本地开发启动方式(显式叠加 dev 层):
|
||
|
||
```bash
|
||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||
```
|
||
|
||
dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
|
||
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
|
||
|
||
如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
|
||
|
||
```bash
|
||
docker compose -f docker-compose.yml pull
|
||
docker compose -f docker-compose.yml up -d
|
||
```
|
||
|
||
持续查看日志:
|
||
|
||
```bash
|
||
docker compose logs -f app
|
||
```
|
||
|
||
## Container Image CI
|
||
|
||
项目提供了一个 release image workflow:
|
||
|
||
- workflow 文件:`.github/workflows/docker-image.yml`
|
||
- 触发条件:push 匹配 `v*` 的 tag,例如 `v1.0.0`
|
||
- registry:`code.wanderingbadger.dev`
|
||
- image:`code.wanderingbadger.dev/<owner>/<repo>`
|
||
|
||
`docker-compose.yml` 中生产默认使用的 app image 当前为:
|
||
|
||
- `code.wanderingbadger.dev/tliu93/home-automation:latest`
|
||
|
||
当前 workflow 不再把 image name 硬编码到特定 user package 路径,而是直接使用当前仓库标识生成镜像路径:
|
||
|
||
- `code.wanderingbadger.dev/${github.repository}:${tag}`
|
||
|
||
在 Gitea 这里,package 更贴近 repo 归属的语义,主要体现在镜像命名路径本身,而不是额外的“绑定”动作。也就是说,当前发布方式是按仓库路径约定来对齐 repo/package 语义。
|
||
|
||
这个 workflow 会构建并推送 multi-arch image:
|
||
|
||
- `linux/amd64`
|
||
- `linux/arm64`
|
||
|
||
推送的 tag:
|
||
|
||
- release tag 本身,例如 `v1.0.0`
|
||
- `latest`
|
||
|
||
workflow 依赖以下 secrets:
|
||
|
||
- `REGISTRY_USERNAME`
|
||
- `REGISTRY_TOKEN`
|
||
|
||
CI 产出的 image 是给部署机直接 `docker pull` 使用的。部署机不需要 checkout 本仓库,也不需要本地执行 `docker build`。
|
||
|
||
## 运行测试
|
||
|
||
```bash
|
||
pytest
|
||
```
|
||
|
||
当前测试包含:
|
||
|
||
- app 启动与 `/status` 检查
|
||
- 登录 / session / 鉴权流程
|
||
- runtime config 读写
|
||
- public IPv4 monitor
|
||
- SMTP 配置与测试发信
|
||
- location / poo recorder 端点
|
||
- Home Assistant inbound 集成
|
||
- TickTick OAuth
|
||
- 部署与迁移(`run_migrations`)
|
||
- legacy 数据迁移脚本(`migrate_legacy_data`)
|
||
|
||
## OpenAPI 导出
|
||
|
||
FastAPI 默认会暴露 OpenAPI。若需要导出静态 schema 文件,可运行:
|
||
|
||
```bash
|
||
python scripts/export_openapi.py
|
||
```
|
||
|
||
输出文件会写到:
|
||
|
||
- `openapi/openapi.json`
|
||
- `openapi/openapi.yaml`
|
||
|
||
`openapi/` 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。
|
||
|
||
## 容器启动
|
||
|
||
1. 准备环境变量文件
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
2. 启动容器
|
||
|
||
```bash
|
||
docker compose up --build
|
||
```
|
||
|
||
默认端口:
|
||
|
||
- `8000:8000`
|
||
|
||
SQLite 持久化目录:
|
||
|
||
- 本地 `./data`
|
||
- 容器内 `/app/data`
|