Files
home-automation/README.md
T
tliu93 f6ef99e34f
frontend / frontend (push) Successful in 1m12s
pytest / test (push) Successful in 4m31s
docker-image / build-and-push (push) Successful in 1m51s
MQTT-FIX-T02: bound Docker logs and document production rollout
2026-10-11 23:59:38 +02:00

836 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 仪表盘
- **多数据源 Meter 与 WarmteLink**:DSMR MQTT 与只读 WarmteLink P1 serial source 统一为 Source → Channel → Binding → Meter;WarmteLink 提供 heating `GJ` 与 hot-water `m³` 的 Decimal history、质量与重连
- **热力合同与成本**:electricity / thermal scope 可各有一个 active 合同;热力按 15 分钟账本计算 variable、fixed 与 all-in 成本,并可按需暴露给 HA
- 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` 表,快照价,不可变)
- meter source、channel 与 binding(`meter_source`、`meter_source_channel`、`meter_source_binding`)
- WarmteLink scalar 历史(`warmtelink_reading`)与热力 15 分钟成本账本(`meter_cost_period`)
配置层只保留一个数据库环境变量:
- `APP_DATABASE_URL`
`app.db` 不会在应用启动时自动创建,需要先运行:
```bash
python -m scripts.run_migrations
```
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(包括 Modbus、DSMR、Source/Channel/Binding、WarmteLink、electricity/thermal 合同与成本账本)。
## 当前目录
主要目录如下:
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
- `alembic_app/`: App DB 的唯一 Alembic migration 环境(管理所有 app 表,包括 Modbus、DSMR、Meter source、WarmteLink、合同与成本账本)
- `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 验证链路)
主 MQTT 客户端由应用 lifespan 持有。相同配置下重复调用 connect 不会创建新客户端;Paho
网络循环在短暂断线后负责自动重连,并在重连成功时恢复订阅。每分钟的 HA 发布任务会幂等地
确保客户端存在。配置变更或应用关闭时,旧客户端先从管理器解绑,再断开并停止网络循环;旧
客户端的迟到回调不会改变新连接状态或分发消息。DSMR source 使用各自独立的客户端生命周期。
**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` 副本,可与生产栈在同一台机器上并存
WarmteLink serial access is configured directly by both Compose combinations. Before starting either
one, set these host-specific values in your uncommitted local `.env` (use a stable `/dev/serial/by-id/...`
path, never a transient `/dev/ttyUSB*` name):
```dotenv
WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name>
WARMTELINK_SERIAL_GID=<host-serial-gid>
```
Only `app` receives the device as `/dev/warmtelink:rw` and the serial group; `migration` does not.
The app remains non-root, non-privileged, and has no added capabilities. One physical serial port may
have only one owner: stop the app before running the Pre-M8 P1 probe. Never remove `./data`, databases,
or volumes while changing this configuration.
本地开发启动方式(显式叠加 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
```
### Docker 日志轮转与生产应用
源码 Compose 为 `app` 和一次性 `migration` 服务配置 Docker `json-file` 日志轮转:单个
日志文件最多约 10 MiB,最多保留 3 个文件。开发 override 继承这项设置。Docker 管理这些
文件,通常位于宿主机
`/var/lib/docker/containers/<完整容器ID>/<完整容器ID>-json.log`;路径取决于 Docker data
root,`docker logs` / `docker compose logs` 是查看容器日志的接口。轮转会限制每个容器的
日志保留量,较旧内容会被 Docker 轮转淘汰。不要手动删除或编辑 Docker 管理的日志文件。
`docker restart` 只重启现有容器,不会更新容器创建时保存的 logging 配置;须重建容器。
本机开发可重新执行上面的 Compose 命令。生产设备的 Compose 位于
`/home/tianyu/.local/share/homeautomation`,与本仓库源码 Compose 分离。部署时只把两个
服务的 `logging` 块合并到生产 Compose 对应服务中,并保留生产端已有的 `.env`、设备映射、
`group_add`、volume、端口和其他部署设置;不要用完整源码 Compose 覆盖生产文件。
要同步的配置块如下,分别放在生产文件的 `services.app` 和 `services.migration` 下:
```yaml
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
```
生产变更只供人工维护窗口执行,需在生产目录操作。先备份生产 Compose(以及已有的环境文件),
并且只把 `app`、`migration` 的 `logging` 块同步到生产文件;不要复制或覆盖整份生产 Compose。
保留原有设备映射、`group_add`、volume、端口、环境变量和其他部署设置。编辑后用生产实际使用的
Compose 文件组合及合成环境值运行 `docker compose ... config --quiet` 检查;后续所有 Compose 命令都要带上相同生产文件组合和
`.mqtt-fix-image-pin.yml`,任何重建命令都带 `--pull never --no-build`;不要把展开后的配置
(可能含凭据)打印或贴入报告。以下命令在生产目录执行,示例假设生产基础文件名为
`docker-compose.yml`;若实际启动时显式使用其他 `-f` 文件,应在每条命令中原样保留该文件组合。
先确定本次要走的镜像路径,并创建仅用于固定镜像的持久 override。该文件只设置两个服务的
`image`,生产 Compose 仍负责其余设置。只应用日志配置时,不能假设本地 `latest` 标签仍指向
正在运行的镜像:先从运行中的 app 容器读取不可变 image ID,将其记录并加上受控本地标签,
然后固定这个标签。命令示例:
```bash
cd /home/tianyu/.local/share/homeautomation
MQTT_FIX_BACKUP="compose.before-mqtt-fix-$(date +%Y%m%d-%H%M%S).yml"
MQTT_FIX_ENV_BACKUP="env.before-mqtt-fix-$(date +%Y%m%d-%H%M%S).bak"
MQTT_FIX_RECORD="mqtt-fix-maintenance-record.txt"
MQTT_FIX_PIN_BACKUP="image-pin.before-mqtt-fix.yml"
MQTT_FIX_APP_ID="$(docker inspect -f '{{.Image}}' home-automation-app)"
MQTT_FIX_OLD_REPODIGESTS="$(docker image inspect -f '{{json .RepoDigests}}' "$MQTT_FIX_APP_ID")"
printf 'previous app image ID: %s\nprevious app RepoDigests: %s\n' "$MQTT_FIX_APP_ID" "$MQTT_FIX_OLD_REPODIGESTS" | tee -a "$MQTT_FIX_RECORD"
MQTT_FIX_APP_IMAGE="home-automation:prod-before-mqtt-fix"
docker image tag "$MQTT_FIX_APP_ID" "$MQTT_FIX_APP_IMAGE"
printf 'previous app local pin: %s\n' "$MQTT_FIX_APP_IMAGE" | tee -a "$MQTT_FIX_RECORD"
cp docker-compose.yml "$MQTT_FIX_BACKUP"
if [ -f .env ]; then cp .env "$MQTT_FIX_ENV_BACKUP"; fi
if [ -f .mqtt-fix-image-pin.yml ]; then cp .mqtt-fix-image-pin.yml "$MQTT_FIX_PIN_BACKUP"; fi
printf 'Compose backup: %s\n.env backup (if present): %s\nprevious image pin backup (if present): %s\n' "$MQTT_FIX_BACKUP" "$MQTT_FIX_ENV_BACKUP" "$MQTT_FIX_PIN_BACKUP" | tee -a "$MQTT_FIX_RECORD"
cat > .mqtt-fix-image-pin.yml <<YAML
services:
app:
image: ${MQTT_FIX_APP_IMAGE}
migration:
image: ${MQTT_FIX_APP_IMAGE}
YAML
```
`.mqtt-fix-image-pin.yml` 保存已核验的镜像选择;不要删除它或在后续 Compose 操作中省略它。
只有明确部署另一个已核验的镜像时才更新该文件。
在编辑生产文件的 `logging` 块后,用同一镜像 pin 和合成环境变量静态检查配置,并通过显式
override 固定镜像身份。`--env-file /dev/null` 可避免从部署 `.env` 取值;命令行变量只用于
Compose 插值。不要输出 `config` 展开结果:
```bash
WARMTELINK_DEVICE_PATH=/dev/synthetic-device WARMTELINK_SERIAL_GID=12345 TZ=Europe/Amsterdam \
docker compose --env-file /dev/null -f docker-compose.yml -f .mqtt-fix-image-pin.yml config --quiet
```
若同时部署 MQTT 源码修复,须先确认修复已合入并发布。显式拉取已发布的版本 tag(不要用
可变 `latest`),从本地镜像记录 registry digest,并在 override 中用 digest 固定目标;先核验
该 tag/digest 对应包含修复的发布,再继续。示例中的 `<published-tag>` 应替换为已核验的版本:
```bash
MQTT_FIX_RELEASE_TAG='code.wanderingbadger.dev/tliu93/home-automation:REPLACE_WITH_VERIFIED_TAG'
docker pull "$MQTT_FIX_RELEASE_TAG"
MQTT_FIX_NEW_REPODIGESTS="$(docker image inspect -f '{{json .RepoDigests}}' "$MQTT_FIX_RELEASE_TAG")"
printf 'target release tag: %s\ntarget RepoDigests: %s\n' "$MQTT_FIX_RELEASE_TAG" "$MQTT_FIX_NEW_REPODIGESTS" | tee -a "$MQTT_FIX_RECORD"
MQTT_FIX_APP_IMAGE='code.wanderingbadger.dev/tliu93/home-automation@sha256:REPLACE_WITH_VERIFIED_DIGEST'
cat > .mqtt-fix-image-pin.yml <<YAML
services:
app:
image: ${MQTT_FIX_APP_IMAGE}
migration:
image: ${MQTT_FIX_APP_IMAGE}
YAML
printf 'selected fixed app image: %s\n' "$MQTT_FIX_APP_IMAGE" | tee -a "$MQTT_FIX_RECORD"
```
将镜像 digest 与发布记录中的目标源码提交对应关系核验后,把确认过的
`MQTT_FIX_APP_IMAGE` 用于上述 override 和 `config --quiet` 检查。源码修复尚未发布时,
只走前述运行中 image ID 固定路径,不执行 `docker pull`。纯应用代码修复不需要启动 migration;
配置更新时可用 `create` 重建 migration 容器但不启动一次性迁移任务。
容器必须重建才能采用新日志策略。重建 app 会造成短暂采集与 API 中断;安排维护窗口并确认
设备采集可恢复。重建前如需留存旧 Docker 日志,可将 `docker logs --timestamps
home-automation-app` 输出重定向到受控备份文件,并确认文件可读;此步骤只导出日志,不删除
Docker 文件。Docker 可能在移除旧容器时一并移除其 json-file 日志,因此不要假设重建后旧
日志仍可从新容器读取。
确认配置、logging 块和固定镜像后,在生产目录应用容器配置(避免启动 migration)。每条重建
命令都显式使用 image pin 并禁止隐式拉取,且保留生产基础 Compose 与原有部署设置:
```bash
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml create --pull never --no-build --force-recreate migration
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml up -d --no-deps --pull never --no-build --force-recreate app
docker inspect -f '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' home-automation-app
docker inspect -f '{{.Image}}' home-automation-app
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml ps
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml logs --tail=100 app
```
检查 `inspect` 结果为 `json-file`、`max-size` 为 `10m`、`max-file` 为 `3`,app 为运行状态,
近期日志无启动错误,之后确认 MQTT 与采集恢复。回退时恢复备份的生产 Compose(或恢复旧
logging 块)。两种镜像路径都可用维护记录中的旧 app image ID 回退:确认本地旧镜像仍在后,
将持久 pin 文件恢复为记录的旧本地 pin(或由记录的 image ID 重新打回该标签),并继续使用该文件:
```bash
docker image inspect home-automation:prod-before-mqtt-fix >/dev/null
cat > .mqtt-fix-image-pin.yml <<'YAML'
services:
app:
image: home-automation:prod-before-mqtt-fix
migration:
image: home-automation:prod-before-mqtt-fix
YAML
WARMTELINK_DEVICE_PATH=/dev/synthetic-device WARMTELINK_SERIAL_GID=12345 TZ=Europe/Amsterdam \
docker compose --env-file /dev/null -f docker-compose.yml -f .mqtt-fix-image-pin.yml config --quiet
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml create --pull never --no-build --force-recreate migration
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml up -d --no-deps --pull never --no-build --force-recreate app
```
源码更新路径也使用开头记录的旧 app image ID/pin 回退,无需依赖已变化的 `latest`。若本地
旧 image ID 已不存在,则用维护记录中保存的旧 RepoDigest 显式拉取旧镜像,核对身份后打回原 pin
标签,再按上面的回退步骤操作。记录本次和回退使用的 image ID/digest、固定 pin 文件内容、生产
Compose 及 `.env` 备份文件名、logging inspect 结果及验证时间。回退同样会重建容器、造成短暂
中断;重建前需另行导出想保留的当前容器日志。保留 `.mqtt-fix-image-pin.yml` 供后续 Compose
操作继续使用;不要删除 Docker 日志、备份或其他生产文件。
持续查看日志:
```bash
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml 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`