2026-06-12 15:37:17 +02:00
# Roadmap
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
2026-06-23 23:52:07 +02:00
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md): M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
2026-06-12 15:37:17 +02:00
## 当前基线(v1.0.3)
- FastAPI + 服务端 Jinja 模板页面(目前只有 `/login` 、`/config` )
- 三个独立 SQLite 库:
- App DB: `sqlite:///./data/app.db`
- Location DB: `sqlite:///./data/locationRecorder.db`
- Poo DB: `sqlite:///./data/pooRecorder.db`
- 三条独立 Alembic 链:`alembic_app/` 、`alembic_location/` 、`alembic_poo/`
- 单 admin 鉴权(Argon2 + server-side session cookie)
- Public IPv4 monitor、SMTP 通知、Location / Poo recorder、Home Assistant in/out、TickTick OAuth
- 数据可视化目前由 Grafana provisioning 承担(仅 location / poo dashboard)
- 已有 OpenAPI 导出脚本:`scripts/export_openapi.py`
## 本阶段正式退役的架构约束
`docs/architecture-overview.md` 里有几条当时刻意写死的约束,这一阶段明确退役:
- **“不引入前后端分离”** → 退役。本阶段改为 React SPA(仍由 FastAPI 同源托管,但渲染移到客户端)。
- **“三个独立 DB 不合并”** → 退役。本阶段把 location / poo 合并进 `app.db` 。
- **Grafana 作为可视化方案** → 退役。可视化由 React 前端自己承担(热力图、地图等)。
保持不变的约束:
- 继续使用 **SQLite** ,本阶段不上 Postgres。
- 不引入 Notion。
## 里程碑总览
| 里程碑 | 主题 | 一句话 |
| --- | --- | --- |
2026-06-12 17:13:28 +02:00
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db` ,清理散落数据层,删掉 Grafana |
2026-06-13 12:01:34 +02:00
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
2026-06-21 23:01:50 +02:00
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
2026-06-22 16:10:11 +02:00
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings) + MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
2026-06-23 23:52:07 +02:00
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
2026-06-12 15:37:17 +02:00
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
2026-06-21 23:01:50 +02:00
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
2026-06-12 15:37:17 +02:00
---
2026-06-12 17:13:28 +02:00
## M1 — 单库化地基(✅ 已完成)
2026-06-12 15:37:17 +02:00
### 目标
把 location / poo 两个独立库合并进 `app.db` ,借机清理项目早期散落各处的数据访问代码,并移除 Grafana。
### 范围
- **Alembic 收敛为单链(app 链)**: location / poo 的表此后纳入 app 链管理;`alembic_location/` 、`alembic_poo/` 退出活跃使用(保留在 git 历史)。
- **新建表(schema only) **:在 app 链上加一条 upgrade revision,把原来两个旧库里的表**原样**建到 `app.db` 中。Alembic **不需要知道任何旧数据** ——它只负责把 app DB 往上升一个版本、建出这两张新表。
- **数据搬迁交给独立脚本**: `scripts/migrate_legacy_data.py` (见下方“迁移策略”),手动跑一次。
- **配置层收敛**:去掉 `LOCATION_DATABASE_URL` / `POO_DATABASE_URL` ,统一到 `APP_DATABASE_URL` 。
- **开启 SQLite WAL**:单文件 + Web + APScheduler 并发写入,开 WAL 更稳。
- **删除 Grafana**:移除 compose 中的 grafana service、`grafana/provisioning/` 、`grafana/dashboards/` 。直接删除,不再 re-point datasource。
- **更新文档**: README、architecture-overview 同步反映单库现实。
### 注意
- **可视化空窗可接受**:M1 删掉 Grafana 后、到 M2 React 可视化落地之前会有一段没有可视化面板的时间。已确认可以接受。
- **历史数据是第一优先级,绝不能丢**(见“数据安全原则”)。
---
## 迁移策略(M1 核心)
职责拆分得很清楚:**Alembic 管 schema,脚本管数据。**
### Alembic revision(只建结构)
- 一条 app 链上的 upgrade revision,建出与旧库**完全相同**的表结构。
- 确定性、与环境无关:在生产机、CI、全新部署上都一样地建空表,不依赖任何旧文件是否存在。
- 本步**只原样挪表,不顺手改 schema**。任何表结构清理留到之后一条单独的 migration 去做——不可替代的历史数据,一次只承担一种风险。
### 数据搬迁脚本(`scripts/migrate_legacy_data.py`)
- 把旧 `locationRecorder.db` / `pooRecorder.db` 里的行,拷进 `app.db` 的新表(SQLite `ATTACH DATABASE` 或单独连接均可)。
- **幂等**:重复运行不会重复插入。
- **搬完对账**:逐表核对源 / 目标行数,对不上就报错中止。
- 只在生产机上**手动跑一次**,不进 Alembic 永久链路(避免把一次性历史搬迁焊死进每次全新建库都要跑的链路里)。
### 旧库的“撤掉”
- “撤掉旧库” = ① 配置不再指向它们 + ② 文件**归档保留**。
- **绝不**在任何脚本 / migration 里 `os.remove` 旧文件——那不可逆,且踩数据安全红线。
- 真正的删除是**人工、最后、确认无误之后**单独的一步。
---
## 数据安全原则
历史数据(location / poo 记录)是这个项目里最不可替代的东西,迁移期间一律按以下原则:
1. **迁移前先归档**旧 `.db` 文件一份。
2. **先在副本上演练** :把每日备份恢复到一个 scratch 目录,在副本上跑完整迁移、核对行数无误,再对真实库动手。
3. **脚本幂等 + 行数对账** ,对不上立即中止。
4. **旧文件只读归档、绝不自动删除** ,删除是事后人工动作。
---
2026-06-13 12:01:34 +02:00
## M2 — 前端 v2( React SPA)✅ 已完成
2026-06-12 15:37:17 +02:00
### 目标
用 React SPA 取代现有 Jinja 页面,由 FastAPI 同源托管(同一容器、同一 origin)。这一步合并了“前端重写为 React”和“前端做厚”两件原本分开的事——它们本质是同一坨活。
> 备注:React 是一次 agentic programming 试水。之前只手写过 Vue、没手写过 React,这一轮想全程靠 agent、尽量不读代码地把它做出来。OpenAPI 导出 → 生成类型化 TS client 作为 agent 的契约护栏,正好服务这个目标。
### 范围
- **React SPA**, FastAPI 挂载打包后的静态产物(同源,省掉 CORS)。
- **Config 界面**:取代现有 Jinja config 页。
- **数据可视化**:热力图、地图等,接管原先 Grafana 干的事。
- **按需展示 DB 数据**(例如 poo 记录)。
- **记录的小幅增删改**:用于修正不准确的记录。
### 后端配套
- **补一套 JSON API**:SPA 是客户端渲染,需要后端提供 config 读写、数据查询、记录 CRUD 等 JSON 端点。(同源不等于不需要 API——API 是“客户端怎么拿数据”,与文件托管在哪无关。)
- **鉴权**:浏览器面向的新端点(含记录 CRUD)复用现有 session cookie 保护。
- **类型化 client**:用 `scripts/export_openapi.py` 的输出生成 TS client。
### 鉴权边界(与 M3 衔接)
2026-06-13 12:01:34 +02:00
- 现在那个”裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
2026-06-12 15:37:17 +02:00
- M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。
2026-06-13 12:01:34 +02:00
> **M2 已完成**( M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。
2026-06-12 15:37:17 +02:00
---
2026-06-21 23:01:50 +02:00
## M4 — 登录加固(✅ 已完成,排在 M5 之前)
### 目标
给暴露在公网的单 admin 登录做纵深防御,先于 M5 IoT 集成关闭暴力枚举和单因子风险。
### 范围
- **防爆破 / 指数退避**:失败登录按双键(IP + username)指数增长延迟,成功即清零。退避是延迟(429 + Retry-After),不是永久封号。全局开关 `AUTH_LOGIN_THROTTLE_ENABLED` ( CONFIG_FIELDS);反代后需开 `AUTH_TRUST_FORWARDED_FOR` ( `.env` 部署级)。
- **CLI 逃生通道**: `python -m scripts.admin_cli` 直连本地 DB,无需 HTTP 服务、无需任何已存凭据;支持重置密码、解锁退避、关停/重发 TOTP、查看用户。
- **可选 TOTP 二次验证**:admin 可自选启用;启用后两步登录(密码 + 6 位动态码或一次性恢复码);不启用维持纯密码。CLI `disable-totp` 是连恢复码都丢了时的最终逃生口。
> 详细设计与任务卡:[`docs/design/m4-login-hardening.md`](./design/m4-login-hardening.md)
---
2026-06-22 16:10:11 +02:00
## M5 — IoT 集成与能耗采集(✅ 已完成)
### 目标
给后端接入家庭 IoT 生态,建立通用 Modbus 设备采集链路(首个领域:能耗),接入 MQTT + Home Assistant Discovery,并重构前端为侧边导航并新增 Energy 视图。
### 范围
- **两层数据模型(协议与部署分离)**:YAML profile(随代码走,声明协议知识:寄存器/解码/key/unit/ha_component) + `modbus_device` (部署层 DB 行:friendly_name/host/port/unit_id/profile/poll_interval/enabled) + `modbus_reading` (通用遥测:device_id + recorded_at + JSON payload)。多设备可共享同一 profile。
- **通用 Modbus-TCP 采集**: pymodbus, APScheduler 后台 job 轮询所有 enabled 设备;per-device `last_poll_at` /`last_poll_ok` ;全局开关 `MODBUS_POLLING_ENABLED` ( CONFIG_FIELDS)。首个 profile: SDM120 单相电表。
- **手工 CLI 试读**: `scripts/modbus_cli` ( `read` /`probe` 两个只读子命令),供受控手工验证,不进自动化。
- **MQTT + HA Discovery**: paho-mqtt 长连接;通用 expose 框架(provider 动态产出可暴露实体目录,元数据从 YAML profile 派生);`exposed_entity_toggle` 表存逐 key 开关(默认不暴露);每设备 = 一个 HA device,各量 = sensor entity + online binary_sensor; `unique_id` 锚定于设备 `uuid` (稳定,不随改名变);配置变更可重连重发 discovery。
- **前端侧边栏**: `AppShell` 侧边导航(Home / Records / Energy / Config + 主题切换 + 注销),移动端可折叠,当前路由高亮。
- **Energy 视图**( `/energy` ):设备 CRUD;最新读数卡片(标签/单位取自 profile metrics 端点);Recharts 走势图(时间范围 + limit)。
- **Config 页 Accordion**:各大 section 折叠/展开;「Home Assistant Expose」面板勾选可暴露实体 + 连接状态 + 重新发布按钮。
- **API**( `/api/modbus/*` + `/api/expose` + `/api/config/mqtt/test` ):完整 CRUD + readings + metrics + test + expose 勾选 + 重发 discovery。
- `pytest` /`ruff` /`export_openapi` + 前端 `lint/typecheck/test/build` 全绿,`openapi/` 已入库。
### 命名决策(已锁定)
存储/采集/API 全部使用通用 `modbus_*` ( `/api/modbus/devices` ),**不锁死"电表"**;面向用户的领域呈现叫 **Energy** 。接入新 Modbus 设备型号只需新增 YAML profile,无需改表/改 API。
> 详细设计与任务卡:[`docs/design/m5-iot-energy.md`](./design/m5-iot-energy.md)
---
2026-06-23 23:52:07 +02:00
## M6 — 通用电价层 + DSMR 实时数据 + 实时买卖电费计算(✅ 已完成)
### 目标
在 M5 IoT 基建之上,把"电价合同"与"DSMR 实时电表数据"接起来,按 **15 分钟**周期算出**实际买电支出 / 卖电收入** ,自己落库留底(不可变、可审计),并通过 MQTT + HA Discovery 反哺 Home Assistant 的 Energy 仪表盘。
### 关键能力
- **通用电价层(两层模型)**:仓库内 YAML profile 定结构(`manual` 固定/双费率、`tibber` 动态电价);`EnergyContract` (+ 版本)存 UI 可改的数值;price strategy 按 `kind` 出价。一次激活一个合同。改价 = 加新版本,旧版本保留(审计链)。
- **DSMR 实时数据接入**:扩 `MqttManager` 订阅 `dsmr/json` (每秒一帧),整帧 JSON blob 按 10 秒降采样落库(电、气、各相全存;字段 allowlist 不做,blob 天然容纳未来字段)。
- **实时买卖电费计算(两层)**:每 15 分钟用电表累计寄存器差值(`delivered_1/2` 、`returned_1/2` )× 当时合同的买/卖价算计量电费(不可变、快照价)。买价:tibber = API `total` (含税全包),manual = `energy_buy_档 + energy_tax` ;卖价:tibber = `total − energy_tax − sell_adjust` , manual = `sell_档` (无能源税)。双费率:`_1` =dal/低、`_2` =normal/高(NL 惯例)。日/月/年汇总再加固定费(按月→天)减 heffingskorting(按年→天)。不做净计量。卖价残差(sell_adjust)及能源税等当前数值在真实账单确认后钉死。
- **反哺 Home Assistant**: `_energy_cost_provider` 发 `buy_price_now` 、`sell_price_now` 、`import_cost_total` ( `total_increasing` )、`export_revenue_total` ( `total_increasing` )—— 直接挂 HA Energy 仪表盘。
- **Tibber 动态电价**: httpx GraphQL 客户端抓 `QUARTER_HOURLY` 15 分钟价(已 demo 验证 `total=energy+tax` );启动 + 每小时抓取今明两天价格、幂等 upsert `tibber_price` ( hourly trigger,确保每日刷新且可补重试);仅当 active 合同 kind=tibber 且 token 存在时运行。
- **前端**: Energy 视图新增三个 Tab——Contracts(合同管理,表单按 profile 结构渲染)、Prices( 15 分钟价格曲线)、Costs(费用走势/明细/汇总卡片);Config 页 Tibber 测试三态入口。
### 命名分层(延续 M5)
存储/采集/计算层中性命名(`dsmr_*` / `tibber_price` / `energy_cost_period` / `energy_contract` );面向用户并入既有 **Energy** 视图。
### 新增 5 张表(单库 app 链,migration `20260623_11_energy_tables`)
| 表 | 关键列 | 说明 |
| --- | --- | --- |
| `dsmr_reading` | `recorded_at` (idx)、`source_id` (unique)、`payload` (JSON) | 整帧 DSMR telegram blob, 10 秒降采样 |
| `energy_contract` | `name` 、`kind` 、`active` 、`currency` 、时间戳 | 合同头;一次一个 active |
| `energy_contract_version` | `contract_id` (FK)、`effective_from` 、`effective_to` (null)、`values` (JSON)、`created_at` | 版本/时段;改价加新版本、旧版本保留 |
| `tibber_price` | `starts_at` (unique)、`resolution` 、`energy/tax/total` 、`level` 、`currency` 、`fetched_at` | 15 分钟价缓存,不可变 |
| `energy_cost_period` | `period_start` (unique)、`d1/d2/r1/r2_kwh` 、`import_cost/export_revenue/net_cost` 、`currency` 、`pricing` (JSON 快照)、`contract_version_id` (FK)、`degraded` 、`computed_at` | 每 15 分钟计量电费,不可变(快照价+版本) |
### 不新增依赖
httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,不新增任何 Python 包。
> 详细设计与任务卡:[`docs/design/m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)
---
2026-06-12 15:37:17 +02:00
## M3 — 开放与移动端(远期试水)
### 目标
引入 token 鉴权并做一个 React Native 移动端。**明确是很远期、低投入的试水**——先把 React 前端做出来,之后才会碰移动端,且主要是想试试没做过的 React / React Native。
### 范围
- **OAuth-lite token 签发**:移动端在内置浏览器里用账号密码登录,走一遍类 OAuth 流程,服务端签发一个 bearer token 给 app 存起来使用。(本质是没有第三方的 Authorization Code 简化版。)
- **React Native 移动端**:试水性质。
- **给 ingestion 端点上 token**:把 M2 暂时维持裸奔的设备端点收口到 token 鉴权下。
### 为什么放最后
- 移动端是这一阶段最远期、最不确定的部分。
- token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。
2026-06-13 15:29:20 +02:00
2026-06-13 17:38:50 +02:00
## 下一阶段:已确定要做(尚未拆解为任务卡)
2026-06-13 15:29:20 +02:00
2026-06-22 16:10:11 +02:00
> 这些是 M5 之后**已经定下来要做**的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定**做什么、为什么**;具体排期、依赖与原子任务,等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**,具体排期等动手时再定。
2026-06-13 15:29:20 +02:00
2026-06-21 23:01:50 +02:00
### 1. 前端优化
2026-06-13 17:38:50 +02:00
**动机** : M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项**确定要做,但具体优化什么还没定**。
2026-06-22 16:10:11 +02:00
**范围(待定)** :方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2/M5 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。
2026-06-13 17:38:50 +02:00
2026-06-21 23:01:50 +02:00
### 2. 设置页生成 Long-lived Token(供 API 调用)
2026-06-13 17:38:50 +02:00
**动机** :浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token** ,之后用它来调 API。
**范围(粗略,待细化)** :
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
- 与 [M3 ](#m3--开放与移动端远期试水 ) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
## Future Ideas(暂不排期,想到先记下)
> 这里收集**还没排进里程碑、也还没决定要不要做**的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来——先升进上面的「下一阶段」,再细化成 `docs/design/` 的任务卡。
2026-06-21 23:01:50 +02:00
_(暂无条目。)_