Files
home-automation/docs/roadmap.md
T
2026-06-27 22:16:02 +02:00

24 KiB
Raw Permalink Blame History

Roadmap

本文档记录 home-automationv1.0.3 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。

每个里程碑的可执行原子任务展开在 docs/design/M1 m1-db-consolidation.md、M2 m2-frontend-v2.md、M3 m3-token-mobile.md、M4 m4-login-hardening.md、M5 m5-iot-energy.md、M6 m6-tibber-dynamic-energy.md、M7 m7-meter-epochs-archival.md。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。

当前基线(v1.0.3

  • FastAPI + 服务端 Jinja 模板页面(目前只有 /login/config
  • 三个独立 SQLite 库:
    • App DBsqlite:///./data/app.db
    • Location DBsqlite:///./data/locationRecorder.db
    • Poo DBsqlite:///./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。

里程碑总览

里程碑 主题 一句话
M1 单库化地基 把三库合并成单一 app.db,清理散落数据层,删掉 Grafana
M2 前端 v2 React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改
M4 登录加固 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(先于 M5
M5 IoT / 能耗采集 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图
M6 通用电价层 + DSMR 接入 + 实时电费计算 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图
M7 电表生命周期 / 换表归档 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI
M3 开放与移动端(远期试水) token 鉴权 + React Native 移动端

排序原则:先清地基,再在干净结构上盖楼。 M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。


M1 — 单库化地基( 已完成)

目标

把 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. 旧文件只读归档、绝不自动删除,删除是事后人工动作。

M2 — 前端 v2React SPA 已完成

目标

用 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 衔接)

  • 现在那个”裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)维持现状到 M3
  • M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。

M2 已完成M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。


M4 — 登录加固( 已完成,排在 M5 之前)

目标

给暴露在公网的单 admin 登录做纵深防御,先于 M5 IoT 集成关闭暴力枚举和单因子风险。

范围

  • 防爆破 / 指数退避:失败登录按双键(IP + username)指数增长延迟,成功即清零。退避是延迟(429 + Retry-After),不是永久封号。全局开关 AUTH_LOGIN_THROTTLE_ENABLEDCONFIG_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


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 采集pymodbusAPScheduler 后台 job 轮询所有 enabled 设备;per-device last_poll_at/last_poll_ok;全局开关 MODBUS_POLLING_ENABLEDCONFIG_FIELDS)。首个 profileSDM120 单相电表。
  • 手工 CLI 试读scripts/modbus_cliread/probe 两个只读子命令),供受控手工验证,不进自动化。
  • MQTT + HA Discoverypaho-mqtt 长连接;通用 expose 框架(provider 动态产出可暴露实体目录,元数据从 YAML profile 派生);exposed_entity_toggle 表存逐 key 开关(默认不暴露);每设备 = 一个 HA device,各量 = sensor entity + online binary_sensorunique_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


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/2returned_1/2)× 当时合同的买/卖价算计量电费(不可变、快照价)。买价:tibber = API total(含税全包),manual = energy_buy_档 + energy_tax;卖价:tibber = total energy_tax sell_adjustmanual = sell_档(无能源税)。双费率:_1=dal/低、_2=normal/高(NL 惯例)。日/月/年汇总再加固定费(按月→天)减 heffingskorting(按年→天)。不做净计量。卖价残差(sell_adjust)及能源税等当前数值在真实账单确认后钉死。
  • 反哺 Home Assistant_energy_cost_providerbuy_price_nowsell_price_nowimport_cost_totaltotal_increasing)、export_revenue_totaltotal_increasing)—— 直接挂 HA Energy 仪表盘。
  • Tibber 动态电价httpx GraphQL 客户端抓 QUARTER_HOURLY 15 分钟价(已 demo 验证 total=energy+tax);启动 + 每小时抓取今明两天价格、幂等 upsert tibber_pricehourly trigger,确保每日刷新且可补重试);仅当 active 合同 kind=tibber 且 token 存在时运行。
  • 前端:Energy 视图新增三个 Tab——Contracts(合同管理,表单按 profile 结构渲染)、Prices15 分钟价格曲线)、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 blob10 秒降采样
energy_contract namekindactivecurrency、时间戳 合同头;一次一个 active
energy_contract_version contract_id(FK)、effective_fromeffective_to(null)、values(JSON)、created_at 版本/时段;改价加新版本、旧版本保留
tibber_price starts_at(unique)、resolutionenergy/tax/totallevelcurrencyfetched_at 15 分钟价缓存,不可变
energy_cost_period period_start(unique)、d1/d2/r1/r2_kwhimport_cost/export_revenue/net_costcurrencypricing(JSON 快照)、contract_version_id(FK)、degradedcomputed_at 每 15 分钟计量电费,不可变(快照价+版本)

不新增依赖

httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,不新增任何 Python 包。

详细设计与任务卡:docs/design/m6-tibber-dynamic-energy.md


M7 — 电表生命周期 / 换表归档( 已完成)

目标

让计费系统正确处理电表更换这一必然事件:荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类。引入显式的 Meter(电表)epoch 概念,标记"某时刻起属于哪一块物理表",使引擎永不跨表算 delta,并让累计量按表归零、历史可追溯可查。

关键能力

  • Meter epoch 数据模型:一条 meter 记录 = 一块物理电表的一段安装期 [started_at, ended_at)。换表 = 关闭旧 active 表(ended_at=T+ 新建 started_at=T 的表。每 commodity 至多一个 active 表;commodity 字段预留 gas/heating
  • 计费永不跨表compute_period 先查 t0/t1 两端的 meter_at;无表覆盖或跨表边界 → 降级(成本置 0),绝不产负成本或巨额假成本。
  • delta 护栏:任一寄存器 delta < 0> _MAX_DELTA_KWH100 kWh/15min,远超住宅用量)→ 降级。兜底表重置 / DSMR 回绕 / 数据毛刺,与换表无关的异常也一并防住。
  • 累计按当前表归零(D2expose.py 累计 import_cost_total/export_revenue_total 锚点 = 当前 active electricity meter 的 started_at;换表后累计从零起新序列,不维护跨表偏移。
  • 追溯换表可重算started_at 可在过去;PATCH 修正日期后,触发受影响窗口 recompute_range 重判跨表周期归属。
  • Meter CRUD API + 前端管理 UIGET/POST/PATCH /api/energy/meters+ 追溯 recompute);Energy 页新增 Meters tab,展示电表时间线,支持"换表/继承"表单与编辑。

新增表与列(单库 app 链,migration 20260625_13_meter_table

表/列 关键设计
meter idlabelcommodity(默认 electricity)、started_atended_atnull=active)、reasoninitial/meter_swap/home_move/other)、notecreated_at
energy_cost_period.meter_id nullable FK → meter.id;记录每周期归属,便于审计/按表查询/归档

迁移含回填:有历史数据时自动创建一条 reason="initial" 的初始表,并回填现有 energy_cost_period.meter_id;幂等 + 对账(非降级周期回填后 meter_id IS NULL 数必须为 0)。

已锁定决策摘要

  • D1Meter 不绑定 homelabel 编址 + commodity 区分品类。
  • D2:换表后累计量归零(锚当前表起点),不维护跨表偏移。
  • D3:合同与电表是独立时间线,合同不引用电表;同址换表时合同自动沿用。
  • D4started_at 可在过去;有效计费起点 = max(started_at, 数据起点)
  • D5:跨表边界周期判 degraded(换表常伴随长时间无数据,可接受)。
  • D6:负/异常大 delta → degraded,通用兜底。
  • D7gas/heating 计费不在本里程碑;commodity 字段为未来扩展预留。

已知行为(可接受):累计 _totalstate_class: total,换表归零时 HA 长期统计可能在换表那一刻记一次性负 blip。已与用户确认先这样、观察后按需处理(last_reset 信号见设计文档 §9 留痕,本里程碑不做)。

详细设计与任务卡:docs/design/m7-meter-epochs-archival.md

模块概念说明:docs/meter-epochs.md


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。

下一阶段:已确定要做(尚未拆解为任务卡)

这些是 M5 之后已经定下来要做的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定做什么、为什么;具体排期、依赖与原子任务,等动手时再展开成 docs/design/ 的任务卡。先后顺序未定,具体排期等动手时再定。

1. 前端优化

动机M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项确定要做,但具体优化什么还没定

范围(待定):方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2/M5 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。

2. 设置页生成 Long-lived Token(供 API 调用)

动机:浏览器端走 session cookie 即可,但脚本 / 设备 / 外部程序调用 API 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin 手动签发 long-lived token,之后用它来调 API。

本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权2026-06-27 与用户确认):

  • POST /location/recordapp/api/routes/location.py:18)——位置记录上报。目前无任何鉴权。当前数据经 Home Assistant 转发进来,上 token 后 HA 侧需携带该 token;也可由其他客户端直接上报。
  • POST /poo/recordapp/api/routes/poo.py:21+ GET /poo/latestpoo.py:57)——小狗排便记录上报 / 最新查询。目前无任何鉴权
  • 这些是设备 / 脚本(非浏览器)端点,session cookie 不适用,正是 long-lived token 的用武之地。(浏览器 CRUD /api/data/* 已由 session 保护,不在此列。)

范围(粗略,待细化)

  • 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在生成时展示一次,此后只存哈希。
  • 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 token 鉴权依赖。
  • M3 的 token 主题相关,但这条是 Web 设置页手动签发的 PAT 风格,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
  • 与下面第 3 条「Session 滑动续期」同属 Authentication 主题(一个是设备/脚本的长期凭据,一个是浏览器短会话体验),实现时鉴权层可一并梳理。

3. Session 滑动自动续期(Authentication

动机2026-06-27 与用户确认):当前 session 是绝对过期——登录即定死、活动不续期,满 TTL 必须重新登录,体验割裂。希望改成滑动续期(sliding / rolling:只要用户还在活动就自动延长,提供"在用就不掉线"的体验。

现状(实现起点,便于快速拾起)

  • TTL 默认 12 小时auth_session_ttl_hoursapp/config.py:38;配置页 app/services/config_page.py:45 可运行时改)。
  • 登录时一次性写死create_sessionexpires_at = now + ttlapp/services/auth.py:94+ cookie max_age = ttlapp/api/routes/api/session.py:153)。
  • 每请求只读校验、从不延长get_authenticated_sessionapp/services/auth.py:103)只判断 expires_at <= now,过期时仅顺手标 revokedset_cookie 只在登录路由调用一次,无 per-request 中间件。→ 所以是绝对过期,不是滑动。

设计要点(待写设计文档时展开)

  • 校验通过时 bump expires_at = now + ttl重发 cookie(滑动窗口)。
  • 写节流:不要每个请求都写 DB——仅当剩余寿命已过半(或距上次续期 > N 分钟)才续期,避免高频写放大。
  • 绝对寿命硬顶:除滑动 TTL 外再设 created_at + max_lifetime 上限,防止"永不过期"的会话(安全考量)。
  • 新增配置项:滑动 TTL、绝对寿命上限、续期节流阈值。
  • 注意:改动只对新逻辑生效,已存在 session 的 expires_at 行为按新校验路径走即可;上线前过校验闸门。

Future Ideas(暂不排期,想到先记下)

这里收集还没排进里程碑、也还没决定要不要做的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来——先升进上面的「下一阶段」,再细化成 docs/design/ 的任务卡。

(暂无条目。)