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
2026-04-19 20:19:58 +02:00
2026-04-22 13:28:00 +02:00

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 不会在应用启动时自动创建,需要先运行:

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

更新依赖时建议使用:

python -m venv .venv
source .venv/bin/activate
pip install pip-tools
pip-compile requirements.in
pip-compile dev-requirements.in

如果要升级某个依赖,可以用:

pip-compile --upgrade-package fastapi requirements.in
pip-compile dev-requirements.in

本地启动

建议使用 Python 3.11 或以上版本。

  1. 创建虚拟环境并安装依赖
python -m venv .venv
source .venv/bin/activate
pip install -r dev-requirements.txt
  1. 准备环境变量
cp .env.example .env
  1. 初始化数据库
python -m scripts.run_migrations
  1. 启动服务
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)。

cd frontend
npm install
npm run dev          # 启动 Vite dev server(默认 :5173),代理后端

构建

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 自动生成:

cd frontend
npm run codegen      # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts

生成物(src/api/schema.d.ts)已提交入库,CI 会校验它与 openapi/openapi.json 保持同步。

前端校验闸门

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(幂等,不删除旧文件):

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。

防爆破 / 指数退避

登录失败超过 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:

# 重置密码(不加 --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 容器内执行时:

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,最快验证网关连通性):

# 按 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 定义:

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):

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 层):

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 文件:

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 下:

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,将其记录并加上受控本地标签, 然后固定这个标签。命令示例:

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 展开结果:

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> 应替换为已核验的版本:

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 与原有部署设置:

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 重新打回该标签),并继续使用该文件:

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 日志、备份或其他生产文件。

持续查看日志:

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。

运行测试

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 文件,可运行:

python scripts/export_openapi.py

输出文件会写到:

  • openapi/openapi.json
  • openapi/openapi.yaml

openapi/ 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。

容器启动

  1. 准备环境变量文件
cp .env.example .env
  1. 启动容器
docker compose up --build

默认端口:

  • 8000:8000

SQLite 持久化目录:

  • 本地 ./data
  • 容器内 /app/data
S
Description
No description provided
Readme
3.5 MiB
0 Stars 1 Watchers 0 Forks
Languages
Python 74.6%
TypeScript 25.2%
CSS 0.1%