- README: M6 section + feature list + new tables/config items. - roadmap: M6 row (graduated) + detail section; M6 in milestone index. - architecture-overview: M6 models/services/integrations/routes/jobs/config. - design/README: index m6, drop stale '三个里程碑' wording. - OpenAPI already in sync (no diff).
183 lines
12 KiB
Markdown
183 lines
12 KiB
Markdown
# Python 骨架架构概览
|
||
|
||
本文档说明当前 Python skeleton 的职责边界与目录组织。它描述的是“后续迁移承载体”,不是完整业务实现。
|
||
|
||
## 当前目标
|
||
|
||
这一轮的目标是提供一个稳定、轻量、可持续扩展的基础工程,使后续可以逐步迁移:
|
||
|
||
- TickTick integration
|
||
- Home Assistant integration
|
||
- poo records
|
||
- location / life trajectory
|
||
|
||
## 目录设计
|
||
|
||
### `app/`
|
||
|
||
应用核心代码目录。
|
||
|
||
- `main.py`
|
||
- FastAPI app factory
|
||
- lifespan(APScheduler 启停、MQTT 客户端起停、连接后触发 HA Discovery 发布;M6 新增 `tibber-refresh` 抓价 job + `energy-cost` 1 分钟计费 tick job;M6 启用时注册 DSMR MQTT 订阅)
|
||
- 基础路由注册
|
||
- `config.py`
|
||
- 环境变量驱动的 settings(含 M5 新增的 MQTT/HA Discovery/Modbus 配置项;M6 新增 `dsmr_ingest_enabled`、`dsmr_mqtt_topic`、`dsmr_sample_interval_s`、`tibber_api_token`(secret)、`tibber_home_id`)
|
||
- `db.py`
|
||
- 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engine(SQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
|
||
- `dependencies.py`
|
||
- 通用依赖注入
|
||
- `api/`
|
||
- HTTP routes
|
||
- `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD、Modbus 设备 CRUD + readings + metrics + test(`/api/modbus/*`)、Expose 勾选 + 重发 discovery(`/api/expose`)、MQTT 测试连接(`/api/config/mqtt/test`)、M6 新增合同 CRUD + 版本(`/api/energy/contracts*`)、pricing profile 列表(`/api/energy/profiles`)、价格/费用/汇总/DSMR 最新/重算/Tibber 测试(`/api/energy/prices`、`/api/energy/costs`、`/api/energy/costs/summary`、`/api/energy/dsmr/latest`、`/api/energy/costs/recompute`、`/api/energy/tibber/test`)
|
||
- 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等
|
||
- `models/`
|
||
- SQLAlchemy models
|
||
- 所有模型(auth / config / public_ip / location / poo / modbus / expose / energy)共用同一个 `Base`,均落在单一 `app.db` 中
|
||
- M5 新增:`ModbusDevice`(设备部署层)、`ModbusReading`(通用遥测,JSON payload)、`ExposedEntityToggle`(HA 实体暴露开关)
|
||
- M6 新增:`DsmrReading`(整帧 DSMR telegram,10s 降采样)、`EnergyContract`(合同头,含 active 标记)、`EnergyContractVersion`(版本/时段,values JSON,只增不改)、`TibberPrice`(15 分钟价缓存,不可变)、`EnergyCostPeriod`(每 15 分钟计量电费,快照价,不可变)
|
||
- `schemas/`
|
||
- Pydantic schemas(M5 新增 `modbus.py`、`expose.py`;M6 新增 `energy_contract.py`、`energy.py`)
|
||
- `services/`
|
||
- 业务服务层
|
||
- 当前已迁入 config page 的 DB 持久化逻辑
|
||
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
|
||
- 当前已迁入 SMTP 发信与测试发信逻辑
|
||
- M5 新增:`modbus_poll.py`(采集 service,逐设备 poll + 落库 + 推 MQTT state)、`ha_discovery.py`(构建 HA Discovery payload、发布 retained config、发布 state)
|
||
- M6 新增:`tibber_prices.py`(httpx GraphQL 抓 15 分钟价,upsert `tibber_price`,幂等;仅 active=tibber 且 token 存在时运行)、`dsmr_ingest.py`(MQTT handler,整帧 JSON blob + 10s 降采样落库,`source_id` 幂等)、`energy_cost.py`(计费引擎:每 15 分钟寄存器差 × strategy 出价 → `energy_cost_period` 不可变快照;汇总 Σnet + 固定费 − heffingskorting;重算显式 opt-in)
|
||
- `integrations/`
|
||
- 外部系统适配层
|
||
- Home Assistant outbound adapter(REST 通道,原有)
|
||
- M5 新增:`modbus/`(pymodbus 薄封装:`driver.py` 块读 + float32 解码;`profiles.py` YAML profile 加载/校验/解码;`profiles/sdm120.yaml` SDM120 协议声明)
|
||
- M5 新增:`mqtt.py`(paho-mqtt 长连接 `MqttManager`:lifespan 起/停、配置变更重连、`publish(topic, payload, retain)`)
|
||
- M5 新增:`expose.py`(通用 expose 框架:`ExposableEntity`、provider 注册表、`build_catalog`;Modbus provider 从 YAML profile 派生 sensor/binary_sensor 实体目录)
|
||
- M6 新增:`pricing/`(通用电价层:`profiles.py` pydantic 加载/校验 YAML profile + `validate_values`;`strategies.py` manual/tibber 出价策略注册表;`profiles/manual.yaml` 固定/双费率结构;`profiles/tibber.yaml` 动态电价结构)
|
||
- M6 新增:`tibber/`(`client.py`:httpx GraphQL 客户端,`priceInfoRange(QUARTER_HOURLY)` 查询,按 `starts_at` 解析,不假设固定节点数)
|
||
- M6 扩展:`mqtt.py` 新增订阅端(`subscribe(topic, handler)`,`on_connect` 里 subscribe,`on_message` 按 topic 分发,handler 异常吞掉不崩连接)
|
||
- M6 扩展:`expose.py` 新增 `_energy_cost_provider`(`buy_price_now`、`sell_price_now`、`import_cost_total`/`export_revenue_total` 均 `total_increasing`,反哺 HA Energy)
|
||
- `static/`
|
||
- 极简静态资源
|
||
|
||
### `alembic_app/`
|
||
|
||
App DB 的唯一 Alembic migration 链,同时管理 `location` / `poo_records` 表。M1 将三个独立 DB 合并进 `app.db` 后,`alembic_location/` 与 `alembic_poo/` 已退役,全部由此链统一管理。
|
||
|
||
### `tests/`
|
||
|
||
pytest 测试目录。后续可以在这里自然扩展:
|
||
|
||
- unit tests
|
||
- mock tests
|
||
- integration tests
|
||
|
||
### `frontend/`
|
||
|
||
React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 FastAPI 同源托管。
|
||
|
||
- `src/`:React 源码
|
||
- `src/api/`:由 `openapi/openapi.json` 生成的类型化 client(`schema.d.ts`)+ fetch 封装
|
||
- `dist/`:`npm run build` 产物,由 FastAPI 的 `SPA_DIST_DIR` 挂载并对非 `/api` 路径做 fallback
|
||
|
||
### `scripts/`
|
||
|
||
辅助脚本目录。当前包含:
|
||
|
||
- `export_openapi.py`:导出 OpenAPI schema 静态产物
|
||
- `run_migrations.py`:运行 Alembic migration
|
||
- `app_db_adopt.py`:App DB 接管 / 初始化
|
||
- `migrate_legacy_data.py`:一次性历史数据搬迁脚本
|
||
- `admin_cli.py`:Admin CLI 逃生通道(M4),见下方"登录加固"说明
|
||
- `modbus_cli.py`(M5):Modbus 手工试读 CLI(`read`/`probe` 两个只读子命令),不依赖 DB,供受控手工验证链路连通性
|
||
|
||
### `openapi/`
|
||
|
||
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
|
||
|
||
## 登录加固(M4)
|
||
|
||
M4 在基础 Argon2 + server-side session 鉴权之上叠加了三层防御:
|
||
|
||
**防爆破 / 指数退避**:`app/services/login_throttle.py` 按 client IP 与 username 双键记失败计数,失败超过 3 次后指数增长等待时间(最长 15 分钟),`POST /api/auth/login` 在退避窗口内直接返回 `429 + Retry-After`,不执行 Argon2 验证。成功登录后清零。退避是延迟而非永久封号;全局开关 `AUTH_LOGIN_THROTTLE_ENABLED`(CONFIG_FIELDS,默认开);反代后需设 `AUTH_TRUST_FORWARDED_FOR=true`(`.env` 部署级,默认 false)。
|
||
|
||
**CLI 逃生通道**:`scripts/admin_cli.py`(入口 `python -m scripts.admin_cli`)直连本地 DB,**无需 HTTP 服务运行、无需任何已存凭据**,支持:重置密码(`reset-password`)、解锁退避(`unlock`)、关停 TOTP(`disable-totp`,零凭据最终逃生)、重新发放 TOTP secret(`reissue-totp`)、查看用户列表(`list-admin`)。CLI 只动 auth 行,不触碰用户数据表。
|
||
|
||
**可选 TOTP 二次验证**:admin 可在设置页自选启用 RFC 6238 TOTP。启用后登录为两步(密码 → 6 位动态码或一次性恢复码);不启用维持纯密码。TOTP secret 明文存库(与其他 secret 一致,靠文件权限保护);恢复码以 Argon2 哈希存储,使用后消费(一次性)。后端使用 `pyotp`,二维码由前端 `qrcode.react` 渲染。issuer 标签由 `AUTH_TOTP_ISSUER`(`.env` 部署级)配置,默认回退 `app_name`。
|
||
|
||
详细说明:[`docs/auth.md`](./auth.md)
|
||
|
||
## M5 — 通用 Modbus 采集链路与 MQTT 通道
|
||
|
||
### Modbus 采集链路
|
||
|
||
```
|
||
YAML profile(协议知识) modbus_device 行(部署信息,DB)
|
||
- 寄存器块/地址/类型 - host / port(网关 IP)
|
||
- 每量:key/unit/device_class - unit_id(Modbus slave 地址)
|
||
- ha_component - friendly_name / profile / poll_interval_s / enabled
|
||
│ │
|
||
└────────────┬───────────────────────┘
|
||
│ APScheduler job(轮询所有 enabled 设备)
|
||
│ driver.py: ModbusTcpClient → FC04 块读 → 大端 float32 解码
|
||
│ profiles.py: decode(profile, registers) → dict[key → value]
|
||
▼
|
||
modbus_reading 行
|
||
device_id · recorded_at · payload(JSON)
|
||
{"voltage": 230.2, "current": 1.3, "active_power": 295.0, ...}
|
||
```
|
||
|
||
**关键设计决策**:
|
||
- 命名分层:存储/采集/API 全部通用 `modbus_*`(`/api/modbus/devices`);面向用户的领域视图叫 **Energy**(第一个)。接入新设备型号只需新增 YAML profile,不改表/不改 API。
|
||
- 读数为 JSON `payload`(无固定列),SQLite `json_extract` 在 DB 端做 AVG/GROUP BY(被 `(device_id, recorded_at)` 索引圈住)。
|
||
- FK `ON DELETE RESTRICT`:有读数的设备拒删,引导改为 `enabled=false`。
|
||
- 设备 `uuid`(uuid4 内部生成)是**稳定身份锚点**:API 路径键、HA Discovery `unique_id` 来源,不随 friendly_name 改变。
|
||
|
||
### 第二条 MQTT 通道(HA Discovery 发布)
|
||
|
||
与已有 REST 通道(`app/integrations/homeassistant.py`、`POST /homeassistant/publish`)**并行、不冲突**:
|
||
|
||
```
|
||
MQTT 通道(M5 新增):
|
||
paho-mqtt MqttManager(lifespan 长连接,配置变更可重连)
|
||
│
|
||
├─► Discovery config(retained)
|
||
│ topic: <prefix>/<component>/<node>/<object>/config
|
||
│ 内容:device 块(identifiers=uuid)、state_topic、unique_id(uuid+key)、
|
||
│ name(friendly_name)、device_class、unit_of_measurement、availability
|
||
│ 时机:连接成功时 / 目录或勾选变更时(全量重发);
|
||
│ 取消勾选时发空 payload(清除 entity)
|
||
│
|
||
└─► State / Availability(非 retained)
|
||
时机:每次轮询成功后推该设备所有 enabled entity 的最新值;
|
||
周期兜底 job 重推所有 enabled entity + online topic
|
||
|
||
expose 框架:
|
||
provider 动态产出 ExposableEntity 目录(元数据从 YAML profile 派生)
|
||
ExposedEntityToggle 表:只存逐 key 开关(default=disabled)
|
||
build_catalog(session) → 目录 + 勾选状态(合并所有 provider)
|
||
```
|
||
|
||
**HA entity 身份模型(Z2M 语义)**:
|
||
- `unique_id` = `f"{device.uuid}_{metric.key}"`(稳定,改名不变)
|
||
- `name` = `friendly_name`(改名重发 discovery,HA 显示名跟着变、历史不丢)
|
||
- 每设备除各 sensor entity 外,另有 `binary_sensor` `online`(取 `last_poll_ok`)——这是"不止 sensor"的体现
|
||
|
||
## 当前约束
|
||
|
||
- 当前数据库继续使用 SQLite
|
||
- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
|
||
- 当前不设计 Notion 模块
|
||
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
||
- Modbus 当前**仅 TCP**(Waveshare RTU↔TCP 网关),**只读**(FC03/04),不写设备寄存器
|
||
|
||
## 关于 Notion
|
||
|
||
Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确属于 removed scope。
|
||
|
||
因此当前 Python skeleton:
|
||
|
||
- 不提供 Notion integration 模块
|
||
- 不提供 Notion schema
|
||
- 不预留 Notion 相关业务流
|
||
|
||
如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。
|