- mqtt.py: subscription registry, on_message dispatch (swallows handler errors), re-subscribe on (re)connect; publish/connect/disconnect/reconnect unchanged. - app/services/dsmr_ingest.py: handle_message stores the full telegram frame (no allowlist; gas/phases/null preserved), 10s downsample by timestamp.second, source_id idempotency (select + IntegrityError rollback). Net-thread safe. - main.py registers the DSMR subscription only when dsmr_ingest_enabled.
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 可暴露实体
- 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表)
配置层只保留一个数据库环境变量:
APP_DATABASE_URL
app.db 不会在应用启动时自动创建,需要先运行:
python -m scripts.run_migrations
该命令会通过 Alembic 将 app.db 初始化或升级到最新 head(含全部表,包括 M5 新增的 modbus_device、modbus_reading、exposed_entity_toggle)。
当前目录
主要目录如下:
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)tests/: pytest 测试docs/: 当前系统说明文档scripts/: 辅助脚本,例如 OpenAPI 导出openapi/: OpenAPI schema 静态产物(openapi.json/openapi.yaml),纳入版本控制
依赖管理
项目现在采用 pip-tools 管理依赖:
- 生产依赖源文件:
requirements.in - 开发依赖源文件:
dev-requirements.in - 编译产物:
requirements.txtdev-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 或以上版本。
- 创建虚拟环境并安装依赖
python -m venv .venv
source .venv/bin/activate
pip install -r dev-requirements.txt
- 准备环境变量
cp .env.example .env
- 初始化数据库
python -m scripts.run_migrations
- 启动服务
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-Tokenheader(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
首次启动时,如果 APP_DATABASE_URL 对应的 auth DB 里还没有用户,应用会使用:
AUTH_BOOTSTRAP_USERNAMEAUTH_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:
- 设置页点「启用 TOTP」→ 后端生成
otpauth://URI,前端渲染二维码(qrcode.react) - 用 Authenticator App(如 Google Authenticator、Authy)扫码
- 输入当前 6 位动态码确认 → TOTP 启用
- 妥善保存一次性展示的 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 slaveunit_id(电表 Meter ID,设备面板可改故落 DB)、选用哪个 profile、采样周期、是否启用。modbus_reading行:device_id FK、recorded_at、payload(JSON,如{"voltage": 230.2, "current": 1.3, ...})。- 多设备可共享同一 profile(如两块 SDM120 共用
sdm120profile,各自独立 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 验证链路)
Config 页配置流程:
- 在
/config的「MQTT」section 填写 broker host/port/username/password,启用MQTT_ENABLED - 点「发送测试消息」确认 broker 链路通
- 启用
HA_DISCOVERY_ENABLED - 在「Home Assistant Expose」面板勾选要暴露的实体,点「重新发布 discovery」
- 在 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。
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)
其中 SMTP password 与其他 secret 字段一致:
- 页面不明文回显
- 留空提交时保留旧值
- 用于测试发信与自动通知时不会写入响应
Public IPv4 Monitor
当前系统已经提供最小可用的 public IPv4 monitor:
- 使用单一 provider 检查当前公网 IPv4
- 将状态与变化历史持久化到 app DB
- 提供受保护的手动检查入口:
GET /public-ip/check - 启动时注册 APScheduler job,默认每 4 小时检查一次
当前 app DB 中与此功能相关的新表:
public_ip_statepublic_ip_history
状态语义如下:
first_seen:首次发现当前公网 IPv4unchanged:与上次状态一致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_ENABLEDSMTP_HOSTSMTP_PORTSMTP_USERNAMESMTP_PASSWORDSMTP_FROM_NAMESMTP_FROM_ADDRESSSMTP_TO_ADDRESSSMTP_USE_STARTTLS
当前 public IPv4 monitor 已与 SMTP sender 接通,但只处理一个很小的通知场景:
- 当 public IPv4 check 结果为
changed时,自动发送一封英文纯文本邮件
以下情况不会发邮件:
first_seenunchangederror
当前通知邮件内容固定,不提供模板系统,正文会包含:
- 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.jsonopenapi/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 层):
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 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/amd64linux/arm64
推送的 tag:
- release tag 本身,例如
v1.0.0 latest
workflow 依赖以下 secrets:
REGISTRY_USERNAMEREGISTRY_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.jsonopenapi/openapi.yaml
openapi/ 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。
容器启动
- 准备环境变量文件
cp .env.example .env
- 启动容器
docker compose up --build
默认端口:
8000:8000
SQLite 持久化目录:
- 本地
./data - 容器内
/app/data