# 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 骨架中保留它。
