# 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`
