43 Commits

Author SHA1 Message Date
tliu93 c1a5d7a425 fix(docker): stop COPYing removed alembic_location/alembic_poo into the image
pytest / test (push) Successful in 50s
docker-image / build-and-push (push) Successful in 3m52s
M1-T04 deleted the alembic_location / alembic_poo chains and their .ini files,
but the Dockerfile still COPYed those four paths, so the release image build
failed at 'COPY alembic_poo.ini ./' (path not found). Drop the four stale COPY
lines (only alembic_app remains). Add test_dockerfile_copy_sources_exist, which
asserts every Dockerfile COPY source exists in the build context, so this class
of breakage fails pytest instead of only surfacing in the image CI.

Verified with a real local 'docker build' (succeeds) and pytest (98 passed).
2026-06-12 20:48:34 +02:00
tliu93 1e0b235cef Merge pull request 'Feature/m1 db consolidation' (#7) from feature/m1-db-consolidation into main
pytest / test (push) Has been cancelled
docker-image / build-and-push (push) Has been cancelled
Reviewed-on: #7
2026-06-12 20:33:34 +02:00
tliu93 a337b06c94 M1-rework: rename leftover pk_cols param in reconciliation test stubs
pytest / test (push) Successful in 48s
pytest / test (pull_request) Failing after 10m49s
Round-2 audit nit (review-notes/M1-full-review-2.md): two _always_fail
monkeypatch stubs still named their (ignored) third positional parameter
pk_cols after _reconcile switched to a full columns list. Rename to columns
for consistency. Test-only, no behavior change.

pytest 97 passed; ruff clean.
2026-06-12 19:11:13 +02:00
tliu93 1cbe6c46d2 M1-rework: harden legacy-migration reconciliation to full-row equality
Audit finding (review-notes/M1-full-review-1.md, FINDING 1): _reconcile only
checked primary-key presence, so a source row skipped by INSERT OR IGNORE due
to a value difference against a pre-existing same-PK target row would
false-pass. Compare ALL columns with SQLite's NULL-safe IS operator instead,
so reconciliation is a true full-row guarantee (idempotent re-runs still pass
because the rows match column-for-column). Add tests for the value-mismatch
abort and for idempotency under full-row reconciliation. Remove the now-unused
pk_cols parameter.

pytest 97 passed; ruff clean (pre-existing only); data-safety grep still empty.
2026-06-12 19:05:56 +02:00
tliu93 2f634006d2 M1-T07: align docs to single-DB reality and re-export OpenAPI
Rewrite README (single app.db + one alembic_app chain, legacy data moved
once via scripts.migrate_legacy_data, accurate test list) and remove the
Grafana Provisioning section. Update architecture-overview to the unified
data layer (one Base, app-DB engine with WAL) and retire the
alembic_location / alembic_poo sections. Mark M1 done in the roadmap.
Re-export openapi/, which catches the spec up to the already-existing
/config/smtp/test and /public-ip/check endpoints (purely additive; M1's
DB-session dependency swap produced no schema change).

pytest 95 passed; ruff clean (pre-existing only); OpenAPI export idempotent.
2026-06-12 17:13:28 +02:00
tliu93 dc624bb7e5 M1-T06: remove Grafana from compose and delete provisioning
Drop the grafana service and the homeautomation_grafana_storage volume
declaration from docker-compose.yml (migration and app services unchanged),
and delete the grafana/ provisioning + dashboards directory. Visualization
moves to the M2 React frontend; the named volume's actual data is retired
manually as an ops step, never by automation.

docker compose config -q passes; pytest 95 passed; ruff clean.
2026-06-12 17:02:49 +02:00
tliu93 af8c602988 M1-T05: drop location/poo database config from Settings and tests
Remove the dead location_database_url / poo_database_url fields and the
location_sqlite_path / poo_sqlite_path computed properties from Settings;
drop them from the config-page payload and from .env.example. Update the
test hardcodes (test_config, test_public_ip, test_smtp) and reduce the
conftest test_database_urls fixture to the single app DB. The one-time
migration script keeps reading legacy URLs from env/CLI, independent of
Settings.

pytest 95 passed; ruff clean (pre-existing only).
2026-06-12 16:57:54 +02:00
tliu93 0d898e09f2 M1-T04: converge startup chain onto the single app DB
run_all_migrations() now adopts/initializes only the app DB and returns
{'app': ...}. app/main.py drops the location/poo readiness checks
(ensure_location_db_ready / ensure_poo_db_ready) and their imports;
ensure_runtime_dirs only provisions the app DB path; lifespan still
fail-closes on a missing/unmanaged app DB. Delete the retired
location/poo adopt scripts and the alembic_location / alembic_poo
chains. Update tests to single-DB expectations and drop the obsolete
location/poo adoption + readiness tests.

pytest 95 passed; ruff clean (pre-existing only); a fresh app DB
initialized via scripts.run_migrations contains location + poo_records.
2026-06-12 16:50:05 +02:00
tliu93 3d3c2bcc57 M1-T03: unify data layer, models, deps and routes onto single app DB
Collapse the three data layers into one. app/db.py now exposes a single
Base, a cached engine bound to app_database_url with SQLite WAL enabled, and
get_engine/get_session_local/reset_db_caches/get_db_session. Delete
app/auth_db.py, app/poo_db.py and app/models/base.py. All models (auth,
config, public_ip, location, poo) inherit the one Base and register on a
single metadata. Dependencies converge to a single get_db; all routes use it.

Also update the alembic env.py files (app/location/poo) and tests that
imported the removed modules so the suite stays green, and drop the obsolete
test_legacy_style_location_db test whose flow (app reading a separate location
DB) no longer exists. Location/poo Alembic chains, adopt scripts and adoption
tests remain for M1-T04; config fields remain for M1-T05.

pytest 109 passed; ruff clean (pre-existing only); WAL verified; single
Base.metadata holds all seven tables.
2026-06-12 16:35:07 +02:00
tliu93 bc8dd062d5 M1-T02: add idempotent legacy data migration script
scripts/migrate_legacy_data.py copies rows from the legacy locationRecorder.db
/ pooRecorder.db into the unified app DB's location / poo_records tables using
ATTACH + INSERT OR IGNORE (idempotent via PK-conflict skip; explicit columns,
never SELECT *). After copy it reconciles every source row against the target
and raises / exits non-zero on any shortfall. Missing legacy files are a safe
no-op (skipped); --dry-run writes nothing. Not part of the Alembic chain; run
manually once at cut-over. Never deletes or overwrites any file.

Validated end-to-end on copies of the real production DBs: dry-run reported
75103 location + 874 poo rows and wrote nothing; the real run copied all rows
with reconciliation passing; a second run copied 0 (idempotent).
2026-06-12 16:13:55 +02:00
tliu93 427a491380 M1-T01: add app-chain revision creating location + poo_records tables
Add alembic_app revision 20260611_06_merge_location_poo_tables that builds
empty location and poo_records tables (REAL float columns, matching the
production schema and the adopt scripts' EXPECTED_*_TABLE_INFO constants).
Update APP_BASELINE_REVISION to the new head. Schema-only; data migration
is handled separately by scripts/migrate_legacy_data.py (M1-T02).
2026-06-12 16:02:46 +02:00
tliu93 b359bbe3bf docs: add next-phase roadmap, milestone design docs, and CLAUDE.md
pytest / test (push) Successful in 54s
- roadmap.md: M1 (DB consolidation) -> M2 (React SPA) -> M3 (token/mobile)
- docs/design/: agent-pipeline design docs with atomic tasks for M1-M3
- CLAUDE.md: workflow, doc map, commit conventions, review-notes briefing flow
- .gitignore: ignore local review-notes/
2026-06-12 15:37:17 +02:00
tliu93 636bb2b80b Merge pull request 'add get public and storage feature' (#6) from feature/public_ip into main
pytest / test (push) Successful in 53s
docker-image / build-and-push (push) Successful in 3m59s
Reviewed-on: #6
2026-04-29 13:16:58 +02:00
tliu93 eda49489e0 update reademe and docs
pytest / test (push) Successful in 56s
pytest / test (pull_request) Successful in 59s
2026-04-29 13:07:59 +02:00
tliu93 779e160b95 add ip change notification and refine sender display
pytest / test (push) Successful in 57s
pytest / test (pull_request) Successful in 54s
2026-04-29 13:03:12 +02:00
tliu93 3ea3498e58 add smtp module and testing 2026-04-29 12:11:10 +02:00
tliu93 5a420bd37b add get public and storage feature 2026-04-29 11:45:49 +02:00
tliu93 a24e402d47 add grafana provisioning
pytest / test (push) Successful in 46s
2026-04-23 00:12:51 +02:00
tliu93 8565534b73 Merge pull request 'fix ci test' (#5) from feature/add_separate_migration_container into main
pytest / test (push) Successful in 45s
docker-image / build-and-push (push) Successful in 3m40s
Reviewed-on: #5
2026-04-22 13:35:40 +02:00
tliu93 4acdd2dc60 fix ci test
pytest / test (push) Successful in 45s
pytest / test (pull_request) Successful in 44s
2026-04-22 13:31:26 +02:00
tliu93 c9af7530e5 Merge pull request 'change adoption to separate step' (#4) from feature/add_separate_migration_container into main
pytest / test (push) Failing after 44s
docker-image / build-and-push (push) Successful in 3m40s
Reviewed-on: #4
2026-04-22 13:28:30 +02:00
tliu93 a76d6bfb71 change adoption to separate step
pytest / test (push) Failing after 46s
pytest / test (pull_request) Failing after 45s
2026-04-22 13:28:00 +02:00
tliu93 35aee79d93 Restore legacy poo inbound dispatch
pytest / test (push) Successful in 43s
docker-image / build-and-push (push) Successful in 3m38s
2026-04-20 23:33:57 +02:00
tliu93 b9e7f51d51 Split compose dev build from registry deploy
pytest / test (push) Successful in 44s
2026-04-20 23:16:13 +02:00
tliu93 94747c75dd Align image publishing with repository path
pytest / test (push) Successful in 43s
docker-image / build-and-push (push) Successful in 3m37s
2026-04-20 23:05:27 +02:00
tliu93 7978a7e1e1 Add release Docker image workflow
pytest / test (push) Successful in 42s
docker-image / build-and-push (push) Successful in 3m42s
2026-04-20 22:18:54 +02:00
tliu93 e9e2034d30 Add Grafana to deployment compose
pytest / test (push) Successful in 40s
2026-04-20 20:50:46 +02:00
tliu93 aae8ca3b87 Merge pull request 'refactoring/new_python' (#3) from refactoring/new_python into main
pytest / test (push) Successful in 41s
Reviewed-on: #3
2026-04-20 20:41:01 +02:00
tliu93 1805d5d8ea Finalize first Python release
pytest / test (push) Successful in 40s
pytest / test (pull_request) Successful in 41s
2026-04-20 20:40:04 +02:00
tliu93 795c84f177 Stabilize auth tests in CI
pytest / test (push) Successful in 43s
2026-04-20 17:43:24 +02:00
tliu93 1ff426d2e9 Add pytest workflow
pytest / test (push) Failing after 2m18s
2026-04-20 17:38:32 +02:00
tliu93 fe0409dafe Refine runtime config and redirect settings 2026-04-20 17:36:05 +02:00
tliu93 982af62f4f Migrate TickTick OAuth and action tasks 2026-04-20 17:06:03 +02:00
tliu93 179aae264e Persist runtime config in app db and seed from env 2026-04-20 15:56:10 +02:00
tliu93 3f7c9e43d9 Switch auth password hashing to Argon2 2026-04-20 15:26:36 +02:00
tliu93 e1aad408ab Add auth foundation and app DB management 2026-04-20 15:16:47 +02:00
tliu93 044b47c573 Migrate poo recorder and align Alembic naming 2026-04-20 11:48:48 +02:00
tliu93 e334df992f Add Home Assistant inbound gateway 2026-04-20 10:42:35 +02:00
tliu93 151ad46275 Add Home Assistant outbound adapter 2026-04-20 10:11:02 +02:00
tliu93 eb487ccb46 Track exported OpenAPI schema 2026-04-19 23:25:13 +02:00
tliu93 d0dc8e893a Tighten location request validation 2026-04-19 23:18:20 +02:00
tliu93 1a2f9c75d9 Harden location db startup validation 2026-04-19 23:02:43 +02:00
tliu93 8aeb0723c1 Add location db adoption runbook 2026-04-19 21:57:31 +02:00
124 changed files with 9417 additions and 3887 deletions
+25 -9
View File
@@ -1,14 +1,30 @@
# Required: bootstrap and core app settings.
# These values should be set before the container starts.
APP_NAME=Home Automation Backend (Python) APP_NAME=Home Automation Backend (Python)
APP_ENV=development APP_ENV=production
APP_DEBUG=true APP_HOSTNAME=home-automation.example.com
APP_HOST=0.0.0.0 APP_DATABASE_URL=sqlite:////app/data/app.db
APP_PORT=8000 AUTH_BOOTSTRAP_USERNAME=admin
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db AUTH_BOOTSTRAP_PASSWORD=change-me
POO_DATABASE_URL=sqlite:///./data/pooRecorder.db
# Optional: runtime overrides.
# Leave these commented out to use the application's built-in defaults.
# APP_DEBUG=
# AUTH_SESSION_COOKIE_NAME=
# AUTH_SESSION_TTL_HOURS=
# AUTH_COOKIE_SECURE_OVERRIDE=
# Optional: Home Assistant integration.
# Leave these empty when Home Assistant integration is not needed.
HOME_ASSISTANT_BASE_URL=
HOME_ASSISTANT_AUTH_TOKEN=
POO_WEBHOOK_ID=
POO_SENSOR_ENTITY_NAME=
POO_SENSOR_FRIENDLY_NAME=
# Optional: TickTick integration.
# APP_HOSTNAME is used to derive the OAuth callback URI automatically.
TICKTICK_CLIENT_ID= TICKTICK_CLIENT_ID=
TICKTICK_CLIENT_SECRET= TICKTICK_CLIENT_SECRET=
TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback
TICKTICK_TOKEN= TICKTICK_TOKEN=
HOME_ASSISTANT_BASE_URL=http://localhost:8123
HOME_ASSISTANT_AUTH_TOKEN=
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
+43
View File
@@ -0,0 +1,43 @@
name: docker-image
on:
push:
tags:
- "v*"
env:
REGISTRY_HOST: code.wanderingbadger.dev
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
+31
View File
@@ -0,0 +1,31 @@
name: pytest
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: |
requirements.txt
dev-requirements.txt
- name: Install dependencies
run: python -m pip install -r dev-requirements.txt
- name: Run pytest
run: python -m pytest
+1 -1
View File
@@ -5,4 +5,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
data/ data/
openapi/ review-notes/
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.md — Home Automation Backend
本文件每次会话自动加载。它定义本项目的**工作流程、文档位置、commit 规范**。请在动手前先读完。
## 项目速览
- 个人用 home-automation 后端:**FastAPI + SQLite + SQLAlchemy + Alembic**,服务端模板(JinjaM2 将换成 React SPA)。
- 单 admin 鉴权(Argon2 + server-side session cookie),runtime config 落 `app_config` 表。
- 模块:public IPv4 monitor、SMTP 通知、location recorder、poo recorder、Home Assistant in/out、TickTick OAuth。
- 已发布 `v1.0.3`。下一阶段方向:**M1 单库化 → M2 React 前端 → M3 token/移动端(远期,M2 后再说)**。
- **当前现实**:在 M1 完成前仍是**三个独立 SQLite 库**app / location / poo),三套 DeclarativeBase、三条 Alembic 链。不要假设已经单库——以代码现状为准。
- 明确不做:Notion 模块。
## 文档地图与「开工前必读」
文档都在 `docs/`
| 路径 | 作用 |
| --- | --- |
| `docs/roadmap.md` | 全局规划与里程碑总览 |
| `docs/design/README.md` | **协作契约**:任务卡格式、原子任务定义、校验闸门、数据安全红线 |
| `docs/design/m1-db-consolidation.md` | M1 原子任务(含真实代码现状盘点 + 人工 runbook) |
| `docs/design/m2-frontend-v2.md` | M2 原子任务 + API 契约 + 前端校验闸门 |
| `docs/design/m3-token-mobile.md` | M3(远期,暂缓) |
| `docs/*.md`auth / public-ip-monitor / location-recorder …) | 各模块说明,按需读 |
**开工时读取顺序**
1. `docs/design/README.md`(每轮都读,它是流程与验收的共同契约)。
2. 本轮对应的 milestone 文档(如 `docs/design/m1-db-consolidation.md`),定位要做的任务卡。
3. 任务卡 `Files` 列出的源文件 + 该模块的 `docs/*.md`(按需)。
4. `docs/roadmap.md` 仅在需要全局视角时读。
## 工作流程
### 实现模式(由用户的提示词决定)
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent 并指定模型**时,才用指定模型起 implementer sub-agent,按任务依赖顺序跑完整条链。
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时用用户**指定的模型**Agent 工具的 `model` 覆盖)。
### 角色(Orchestrator → Implementer → Reviewer
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
### 校验闸门(每个任务结束都要全绿)
根目录、激活 `.venv` 后:
```bash
pytest # 权威闸门(CI 跑的就是它)
ruff check . # line-length=100
python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路由/schema 才需要,且产物须入库
```
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
## 每轮简报(`review-notes/`
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
- **实现 / 返工简报**:每轮实现完成后(无论首次实现还是返工),写一份。文件名建议 `<task-id>-impl-<n>.md` / `<task-id>-rework-<n>.md`(如 `M1-T03-impl-1.md``M1-T03-rework-1.md`)。至少包含:
1. **本轮修改的具体内容**(改了哪些文件、做了什么、为什么)。
2. **自动化测试结果**`pytest` / `ruff` / 前端闸门的实际输出或结论,通过/失败逐项写清)。
3. **若需人工 walkthrough**:写明具体步骤(怎么启动、点哪里、预期看到什么);若无需人工验证,明确写"无需人工 walkthrough"。
- **review 简报**:每轮 review 后写一份,文件名建议 `<task-id>-review-<n>.md`(如 `M1-T03-review-1.md`)。至少包含:评审结论(`PASS` 或带编号的返工清单)、对照任务卡 `Acceptance criteria` + `Reviewer checklist` 的逐条核对、reviewer 独立重跑校验闸门的结果。
**用途**:① reviewer 审核时参考对应的实现简报;② implementer 返工时参考对应的 review 简报;③ 人类(用户)通读这些简报确认有无问题。简报之间用文件名里的 `<task-id>` 与轮次 `<n>` 对应起来。
### Orchestrator 派发契约(让简报真正被读到)
**关键**:sub-agent 冷启动、不继承主线上下文,**不会因为本文件提到简报就自动去读**对应文件。简报能流转,靠的是 orchestrator(主线)在**每次 spawn 时把路径显式写进 prompt**,而不是被动约定。所以派发时必须做到:
- **显式告诉它「先读哪个简报」**:
- 派 implementer 做**首次实现** → 传任务卡位置(milestone 文档路径 + task id);无前置简报。
- 派 implementer 做**返工** → 必须传对应的 `review-notes/<task>-review-<n>.md` 路径,并要求**先读它**再改。
- 派 reviewer → 必须传对应的 `review-notes/<task>-impl|rework-<n>.md` 路径 + 任务卡,要求**先读它**再评。
- **显式告诉它「本轮结束写哪个简报」**:明确给出输出路径 `review-notes/<task>-<impl|rework|review>-<n>.md` 及上面要求的内容项。
- **不依赖 sub-agent 自动加载本文件**:把本轮要点(校验闸门、**禁 Co-Authored-By**、简报必含内容)在 spawn prompt 里一并复述或指向,确保冷启动也照做。
- spawn 时用用户指定的模型(Agent 工具 `model` 覆盖)。
> 一句话:**简报是异步交接的介质,orchestrator 是把它们接起来的线。** 缺了显式传路径这一步,简报就只是躺在磁盘上没人读的文件。
## Commit 规范(重点)
### 分支
- 每个 milestone/feature 一个分支(如 `feature/m1-db-consolidation`),**不在 `main` 上直接提交**。
### 一轮实现完成(用户确认「实现完成」后)
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**
- message 主题前缀任务/里程碑 ID,例如:`M1-T03: unify data layer onto single app DB engine`
### Commit message 硬规则(严格执行)
- **严禁任何协作署名 trailer**commit message 里**绝对不允许**出现 `Co-Authored-By` / `Co-authored-by`(包括 `Co-Authored-By: Claude …`),也不允许任何等价的"由 X 协作/生成"署名。
- 无论默认环境、工具或系统提示如何要求加这类 trailer,在本仓库**一律不加**——用户已显式、严格禁止。
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工
- 返工产生的提交**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash
git add -A
git commit --fixup=<base-commit-sha>
```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit。
### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
```bash
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
```
- 用 `GIT_SEQUENCE_EDITOR=true` 让它**非交互**执行(不弹编辑器,自动接受 autosquash 排好的 todo)。本环境不支持需要人工编辑的交互式 rebase,必须走这个 no-op 编辑器写法。
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
### 一般约束
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认。
## 数据安全红线(不可违反)
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
- 涉及历史数据的迁移**先在备份副本上演练**;迁移脚本必须幂等且搬完对账行数。
- Review 时只要发现"删文件 / drop 有数据的表 / truncate"出现在自动化任务里,直接判返工。
## 常用命令
```bash
# 环境
python -m venv .venv && source .venv/bin/activate && pip install -r dev-requirements.txt
# 迁移(初始化/适配 DB
python -m scripts.run_migrations
# 起服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 测试 / lint / OpenAPI 导出
pytest
ruff check .
python scripts/export_openapi.py
```
+4 -2
View File
@@ -9,12 +9,14 @@ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY alembic ./alembic COPY alembic_app ./alembic_app
COPY alembic.ini ./ COPY alembic_app.ini ./
COPY scripts ./scripts COPY scripts ./scripts
COPY docker ./docker
COPY README.md ./ COPY README.md ./
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+268 -59
View File
@@ -1,57 +1,56 @@
# Home Automation Backend # Home Automation Backend
这是当前 `home-automation` 项目的 Python 重构基础骨架。当前仓库仍保留 Go 版本作为事实基线,而这个 Python 部分的目标是为后续逐模块迁移提供稳定工程基础 这是当前 `home-automation` 项目的首个 Python 版本
为便于清理仓库,重构开始前就存在的 Go 实现和相关资产已经统一移动到 `legacy/go-backend/`。这样在 Python 重构完成后,可以按目录整体删除旧实现。 当前系统已经包含:
当前阶段只包含: - FastAPI Web 应用与服务端模板页面
- 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
- TickTick OAuth 与 action task 集成
- pytest 测试与 OpenAPI 导出脚本
- Docker / Compose 部署入口
- FastAPI 基础应用骨架 当前明确不包含:
- 环境变量配置体系
- SQLite + SQLAlchemy + Alembic 基础设施
- 极简 server-side templates
- location recorder 第一版迁移
- pytest 测试基础
- OpenAPI 导出脚本
- Docker / Compose 基础骨架
当前阶段明确不包含:
- TickTick 业务逻辑迁移
- Home Assistant 业务逻辑迁移
- poo records 业务迁移
- Notion 模块 - Notion 模块
Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。
旧 Go 代码位置:
- `legacy/go-backend/src/`
- `legacy/go-backend/helper/`
- `legacy/go-backend/.github/workflows/`
## 当前配置现实 ## 当前配置现实
当前系统仍然是两个独立的 SQLite 数据库文件,而不是单一数据库 当前系统使用单一 SQLite 数据库文件`app.db`),所有数据表都在其中
- `location` 模块使用自己的 DB 文件 - auth(单个 admin 用户、server-side session
- `poo` 模块未来也将使用自己的 DB 文件 - runtime config 持久化(`app_config` 表)
- public IPv4 当前状态与变化历史
- location 记录(`location` 表)
- poo 记录(`poo_records` 表)
当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点 配置层只保留一个数据库环境变量
- `LOCATION_DATABASE_URL` - `APP_DATABASE_URL`
- `POO_DATABASE_URL`
目前真正接入的是 `location` 对应的数据库;`poo` 先保留配置占位,等模块迁入时再接上。 `app.db` 不会在应用启动时自动创建,需要先运行:
```bash
python -m scripts.run_migrations
```
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含 `location` / `poo_records` 表)。
## 当前目录 ## 当前目录
Python 骨架的主要目录如下: 主要目录如下:
- `app/`: FastAPI 应用代码 - `app/`: FastAPI 应用代码
- `alembic/`: Alembic migration 环境 - `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表)
- `tests/`: pytest 测试 - `tests/`: pytest 测试
- `docs/`: 架构说明与迁移文档 - `docs/`: 当前系统说明文档
- `scripts/`: 辅助脚本,例如 OpenAPI 导出 - `scripts/`: 辅助脚本,例如 OpenAPI 导出
## 依赖管理 ## 依赖管理
@@ -99,7 +98,13 @@ pip install -r dev-requirements.txt
cp .env.example .env cp .env.example .env
``` ```
3. 启动服务 3. 初始化数据库
```bash
python -m scripts.run_migrations
```
4. 启动服务
```bash ```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
@@ -114,20 +119,229 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 数据库与 Alembic ## 数据库与 Alembic
当前默认仍使用 SQLite,但要明确区分两个数据库文件: 当前使用单一 SQLite 数据库文件:
- Location DB`sqlite:///./data/locationRecorder.db` - App DB`sqlite:///./data/app.db`
- Poo DB`sqlite:///./data/pooRecorder.db`
- 数据目录:`./data/` - 数据目录:`./data/`
初始化 migration 环境后,可继续添加模型并生成迁移 所有模型(auth / config / public_ip / location / poo)共用同一个 `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 ```bash
alembic revision --autogenerate -m "init tables" python -m scripts.migrate_legacy_data
alembic upgrade head
``` ```
当前 Alembic 只接管 `location` 这条链路;`poo` 相关数据库与 migration 还没有迁入。 ## 基础鉴权
当前项目提供一个单用户 admin 鉴权层,用于保护配置页面与管理能力。
- 认证模型:`username/password`
- 会话模型:server-side session + cookie
- 当前主要受保护页面:`/config`
- 当前公开页面:`/login`
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
安全实现的当前边界:
- 密码使用 Argon2 做哈希存储
- session cookie 使用 `HttpOnly`
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
- `SameSite=Lax`
- 登录表单和登出表单都有基础 CSRF 防护
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
- `AUTH_BOOTSTRAP_USERNAME`
- `AUTH_BOOTSTRAP_PASSWORD`
创建初始 admin 用户。当前默认就是:
- username: `admin`
- password: `admin`
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
当前前端主要有两条页面路径:
- `/login`
- `/config`
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`
## 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 配置
其中 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 配置可在 `/config` 页面填写并保存到 `app_config`
- 可通过 config 页面发送测试邮件
- 邮件 `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,适合部署 / 生产拉取
- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
本地开发启动方式:
```bash
docker compose up -d --build
```
上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。
如果要按生产方式直接从 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`
## 运行测试 ## 运行测试
@@ -137,8 +351,16 @@ pytest
当前测试包含: 当前测试包含:
- app 基本启动测试 - app 启动与 `/status` 检查
- `/status` endpoint 测试 - 登录 / session / 鉴权流程
- runtime config 读写
- public IPv4 monitor
- SMTP 配置与测试发信
- location / poo recorder 端点
- Home Assistant inbound 集成
- TickTick OAuth
- 部署与迁移(`run_migrations`
- legacy 数据迁移脚本(`migrate_legacy_data`
## OpenAPI 导出 ## OpenAPI 导出
@@ -153,6 +375,8 @@ python scripts/export_openapi.py
- `openapi/openapi.json` - `openapi/openapi.json`
- `openapi/openapi.yaml` - `openapi/openapi.yaml`
`openapi/` 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。
## 容器启动 ## 容器启动
1. 准备环境变量文件 1. 准备环境变量文件
@@ -175,18 +399,3 @@ SQLite 持久化目录:
- 本地 `./data` - 本地 `./data`
- 容器内 `/app/data` - 容器内 `/app/data`
## 后续迁移建议
后续可以在当前骨架上继续迁移这些模块:
- TickTick integration
- Home Assistant integration
- poo records
建议继续参考:
- [当前系统盘点](docs/current-system-inventory.md)
- [Python 重构方案](docs/python-rewrite-plan.md)
- [迁移风险清单](docs/migration-risks.md)
- [Location Recorder 接管说明](docs/location-recorder.md)
-2
View File
@@ -1,2 +0,0 @@
This directory contains the Alembic migration environment for the Python rewrite skeleton.
-1
View File
@@ -1 +0,0 @@
@@ -1,33 +0,0 @@
"""location baseline
Revision ID: 20260419_01_location_baseline
Revises:
Create Date: 2026-04-19 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260419_01_location_baseline"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"location",
sa.Column("person", sa.Text(), nullable=False),
sa.Column("datetime", sa.Text(), nullable=False),
sa.Column("latitude", sa.Float(), nullable=False),
sa.Column("longitude", sa.Float(), nullable=False),
sa.Column("altitude", sa.Float(), nullable=True),
sa.PrimaryKeyConstraint("person", "datetime"),
)
def downgrade() -> None:
op.drop_table("location")
+2 -2
View File
@@ -1,8 +1,8 @@
[alembic] [alembic]
script_location = alembic script_location = alembic_app
prepend_sys_path = . prepend_sys_path = .
path_separator = os path_separator = os
sqlalchemy.url = sqlite:///./data/locationRecorder.db sqlalchemy.url = sqlite:///./data/app.db
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic
+8 -4
View File
@@ -4,8 +4,12 @@ from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from app.config import get_settings from app.config import get_settings
from app.models import Location # noqa: F401 from app.db import Base
from app.models.base import Base from app.models.config import AppConfigEntry # noqa: F401
from app.models.auth import AuthSession, AuthUser # noqa: F401
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
from app.models.location import Location # noqa: F401
from app.models.poo import PooRecord # noqa: F401
config = context.config config = context.config
@@ -14,8 +18,8 @@ if config.config_file_name is not None:
settings = get_settings() settings = get_settings()
configured_url = config.get_main_option("sqlalchemy.url") configured_url = config.get_main_option("sqlalchemy.url")
if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db": if not configured_url or configured_url == "sqlite:///./data/app.db":
config.set_main_option("sqlalchemy.url", settings.location_database_url) config.set_main_option("sqlalchemy.url", settings.app_database_url)
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -23,4 +23,3 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
${downgrades if downgrades else "pass"} ${downgrades if downgrades else "pass"}
@@ -0,0 +1,56 @@
"""app auth baseline
Revision ID: 20260420_03_app_auth_baseline
Revises:
Create Date: 2026-04-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260420_03_app_auth_baseline"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"auth_users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("force_password_change", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_users_username"), "auth_users", ["username"], unique=True)
op.create_table(
"auth_sessions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("csrf_token", sa.String(length=128), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"], unique=False)
op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"], unique=True)
op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions")
op.drop_table("auth_sessions")
op.drop_index(op.f("ix_auth_users_username"), table_name="auth_users")
op.drop_table("auth_users")
@@ -0,0 +1,34 @@
"""app config table
Revision ID: 20260420_04_app_config_table
Revises: 20260420_03_app_auth_baseline
Create Date: 2026-04-20 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260420_04_app_config_table"
down_revision: Union[str, None] = "20260420_03_app_auth_baseline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_config",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("value", sa.String(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_app_config_key"), "app_config", ["key"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_app_config_key"), table_name="app_config")
op.drop_table("app_config")
@@ -0,0 +1,55 @@
"""public ip monitor tables
Revision ID: 20260429_05_public_ip_monitor
Revises: 20260420_04_app_config_table
Create Date: 2026-04-29 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260429_05_public_ip_monitor"
down_revision: Union[str, None] = "20260420_04_app_config_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"public_ip_history",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("ipv4", sa.String(length=45), nullable=False),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("change_type", sa.String(length=32), nullable=False),
sa.Column("provider", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_public_ip_history_observed_at",
"public_ip_history",
["observed_at"],
unique=False,
)
op.create_table(
"public_ip_state",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("current_ipv4", sa.String(length=45), nullable=False),
sa.Column("previous_ipv4", sa.String(length=45), nullable=True),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_changed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_check_status", sa.String(length=32), nullable=False),
sa.Column("last_check_error", sa.String(length=255), nullable=True),
sa.Column("last_provider", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("public_ip_state")
op.drop_index("ix_public_ip_history_observed_at", table_name="public_ip_history")
op.drop_table("public_ip_history")
@@ -0,0 +1,43 @@
"""merge location and poo_records tables into app chain
Revision ID: 20260611_06_merge_location_poo_tables
Revises: 20260429_05_public_ip_monitor
Create Date: 2026-06-11 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260611_06_merge_location_poo_tables"
down_revision: Union[str, None] = "20260429_05_public_ip_monitor"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"location",
sa.Column("person", sa.Text(), nullable=False),
sa.Column("datetime", sa.Text(), nullable=False),
sa.Column("latitude", sa.REAL(), nullable=False),
sa.Column("longitude", sa.REAL(), nullable=False),
sa.Column("altitude", sa.REAL(), nullable=True),
sa.PrimaryKeyConstraint("person", "datetime"),
)
op.create_table(
"poo_records",
sa.Column("timestamp", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("latitude", sa.REAL(), nullable=False),
sa.Column("longitude", sa.REAL(), nullable=False),
sa.PrimaryKeyConstraint("timestamp"),
)
def downgrade() -> None:
op.drop_table("poo_records")
op.drop_table("location")
+1 -1
View File
@@ -1,2 +1,2 @@
"""Application package for the Python rewrite skeleton.""" """Application package for the home automation backend."""
+234
View File
@@ -0,0 +1,234 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import get_app_settings, get_db, get_current_auth_session
from app.services.auth import (
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
AuthPasswordChangeError,
issue_login_csrf_token,
revoke_session,
validate_csrf_token,
)
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["auth"])
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
@router.get("/login", response_class=HTMLResponse)
def login_page(
request: Request,
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is not None:
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": None,
},
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
@router.post("/login", response_class=HTMLResponse)
def login_submit(
request: Request,
username: str = Form(),
password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> Response:
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
logger.warning("Rejected login attempt due to CSRF validation failure")
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_400_BAD_REQUEST,
error_message="invalid login request",
)
user = authenticate_user(session, username=username, password=password)
if user is None:
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_401_UNAUTHORIZED,
error_message="invalid username or password",
)
auth_session, raw_token = create_session(session, user=user, settings=settings)
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
logger.info("Created authenticated session for user '%s'", user.username)
return response
@router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit(
request: Request,
current_password: str = Form(),
new_password: str = Form(),
confirm_password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure")
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request",
)
try:
change_password(
session,
user=current_auth.user,
current_password=current_password,
new_password=new_password,
confirm_password=confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
current_auth.user.username,
exc,
)
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed",
)
logger.info("Password updated for user '%s'", current_auth.user.username)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
def logout(
request: Request,
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is not None and validate_csrf_token(
expected=current_auth.session.csrf_token, actual=csrf_token
):
revoke_session(session, auth_session=current_auth.session)
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
else:
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(settings.auth_session_cookie_name, path="/")
return response
def _render_login_error(
request: Request,
*,
settings: Settings,
status_code: int,
error_message: str,
) -> HTMLResponse:
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": error_message,
},
status_code=status_code,
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
response.set_cookie(
key=LOGIN_CSRF_COOKIE_NAME,
value=token,
max_age=1800,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/login",
)
def _render_config_page(
request: Request,
*,
settings: Settings,
auth_db_session: Session,
current_auth: AuthenticatedSession,
status_code: int,
password_change_error: str | None,
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"config.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": None,
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
},
status_code=status_code,
)
+84
View File
@@ -0,0 +1,84 @@
import json
import logging
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import (
get_app_settings,
get_db,
get_homeassistant_client,
get_ticktick_client,
)
from app.integrations.homeassistant import (
HomeAssistantClient,
HomeAssistantConfigError,
HomeAssistantRequestError,
)
from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError
from app.schemas.homeassistant import HomeAssistantPublishEnvelope
from app.services.homeassistant_inbound import (
UnsupportedHomeAssistantMessage,
handle_homeassistant_message,
)
router = APIRouter(tags=["homeassistant"])
logger = logging.getLogger(__name__)
BAD_REQUEST_MESSAGE = "bad request"
INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
@router.post("/homeassistant/publish")
async def publish_from_homeassistant(
request: Request,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
try:
raw_payload = await request.body()
data = json.loads(raw_payload)
envelope = HomeAssistantPublishEnvelope.model_validate(data)
handle_homeassistant_message(
db,
envelope,
ticktick_client=ticktick_client,
poo_session=db,
settings=settings,
homeassistant_client=homeassistant_client,
)
except json.JSONDecodeError as exc:
logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except ValidationError as exc:
logger.warning(
"Rejected Home Assistant publish request due to validation failure: %s", exc
)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except UnsupportedHomeAssistantMessage as exc:
logger.warning("Home Assistant publish target/action unsupported: %s", exc)
return PlainTextResponse(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except (
TickTickConfigError,
TickTickRequestError,
HomeAssistantConfigError,
HomeAssistantRequestError,
RuntimeError,
) as exc:
logger.warning("Home Assistant publish request failed during integration handling: %s", exc)
return PlainTextResponse(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except ValueError as exc:
logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
return Response(status_code=status.HTTP_200_OK)
+14 -7
View File
@@ -1,6 +1,7 @@
import json import json
import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, Response from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,6 +11,8 @@ from app.schemas.location import LocationRecordRequest
from app.services.location import record_location from app.services.location import record_location
router = APIRouter(tags=["location"]) router = APIRouter(tags=["location"])
logger = logging.getLogger(__name__)
BAD_REQUEST_MESSAGE = "bad request"
@router.post("/location/record") @router.post("/location/record")
@@ -18,11 +21,15 @@ async def create_location_record(request: Request, db: Session = Depends(get_db)
raw_payload = await request.body() raw_payload = await request.body()
data = json.loads(raw_payload) data = json.loads(raw_payload)
payload = LocationRecordRequest.model_validate(data) payload = LocationRecordRequest.model_validate(data)
except json.JSONDecodeError as exc:
return PlainTextResponse(str(exc), status_code=400)
except ValidationError as exc:
return PlainTextResponse(str(exc), status_code=400)
record_location(db, payload) record_location(db, payload)
return Response(status_code=200) except json.JSONDecodeError as exc:
logger.warning("Rejected location request due to invalid JSON: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except ValidationError as exc:
logger.warning("Rejected location request due to payload validation failure: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except ValueError as exc:
logger.warning("Rejected location request due to invalid numeric input: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
return Response(status_code=status.HTTP_200_OK)
+230 -11
View File
@@ -1,21 +1,240 @@
import logging
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.config import Settings from app.config import Settings, get_settings
from app.dependencies import get_app_settings from app.dependencies import get_app_settings, get_db, get_current_auth_session
from app.services.auth import AuthenticatedSession
from app.services.config_page import (
ConfigSaveError,
build_config_sections,
is_ticktick_oauth_ready,
save_config_updates,
)
from app.services.email import EmailConfigurationError, EmailDeliveryError, is_smtp_ready, send_smtp_test_email
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"]) router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__)
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "TickTick authorization completed successfully.", None
if status_value == "invalid-state":
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
if status_value == "invalid-callback":
return None, "TickTick authorization callback was missing required parameters."
if status_value == "failed":
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
return None, None
def _smtp_test_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "SMTP test email sent successfully.", None
if status_value == "config-error":
return None, "SMTP test failed. Check required SMTP settings before sending a test email."
if status_value == "failed":
return None, "SMTP test failed. Check saved SMTP settings and server reachability."
return None, None
def _build_config_context(
*,
auth_db_session: Session,
settings: Settings,
current_auth: AuthenticatedSession,
config_saved: bool,
config_error: str | None,
password_change_error: str | None,
ticktick_oauth_notice: str | None,
ticktick_oauth_error: str | None,
smtp_test_notice: str | None,
smtp_test_error: str | None,
) -> dict[str, object]:
return {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": config_error,
"config_saved": config_saved,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": ticktick_oauth_notice,
"ticktick_oauth_error": ticktick_oauth_error,
"smtp_test_ready": is_smtp_ready(settings),
"smtp_test_notice": smtp_test_notice,
"smtp_test_error": smtp_test_error,
}
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse: def home(
context = { request: Request,
"app_name": settings.app_name, current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
"app_env": settings.app_env, ) -> RedirectResponse:
"notion_status": "Legacy scope, removed from the Python rewrite target.", if current_auth is None:
} return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(request, "home.html", context) return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse)
def admin_redirect(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/config", response_class=HTMLResponse)
def config_page(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
request.query_params.get("ticktick_oauth")
)
smtp_test_notice, smtp_test_error = _smtp_test_notice(request.query_params.get("smtp_test"))
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=request.query_params.get("saved") == "1",
config_error=None,
password_change_error=None,
ticktick_oauth_notice=ticktick_oauth_notice,
ticktick_oauth_error=ticktick_oauth_error,
smtp_test_notice=smtp_test_notice,
smtp_test_error=smtp_test_error,
)
return templates.TemplateResponse(request, "config.html", context)
@router.post("/config", response_class=HTMLResponse)
async def config_submit(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected config update due to CSRF validation failure")
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=False,
config_error="invalid config update request",
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error=None,
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = get_settings()
context = _build_config_context(
auth_db_session=auth_db_session,
settings=refreshed_settings,
current_auth=current_auth,
config_saved=False,
config_error="invalid config submission",
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error=None,
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/config/smtp/test", response_class=HTMLResponse)
async def smtp_test_submit(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected SMTP test due to CSRF validation failure")
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=False,
config_error=None,
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error="invalid SMTP test request",
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test email rejected due to configuration: %s", exc)
return RedirectResponse(
url="/config?smtp_test=config-error",
status_code=status.HTTP_303_SEE_OTHER,
)
except EmailDeliveryError as exc:
logger.warning("SMTP test email failed: %s", exc)
return RedirectResponse(
url="/config?smtp_test=failed",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
url="/config?smtp_test=success",
status_code=status.HTTP_303_SEE_OTHER,
)
+76
View File
@@ -0,0 +1,76 @@
import json
import logging
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import get_app_settings, get_homeassistant_client, get_db
from app.integrations.homeassistant import HomeAssistantClient
from app.schemas.poo import PooRecordRequest
from app.services.poo import publish_latest_poo_status, record_poo
router = APIRouter(tags=["poo"])
logger = logging.getLogger(__name__)
BAD_REQUEST_MESSAGE = "bad request"
INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
@router.post("/poo/record")
async def create_poo_record(
request: Request,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
) -> Response:
try:
raw_payload = await request.body()
data = json.loads(raw_payload)
payload = PooRecordRequest.model_validate(data)
record_poo(
db,
payload,
settings=settings,
homeassistant_client=homeassistant_client,
)
except json.JSONDecodeError as exc:
logger.warning("Rejected poo record request due to invalid JSON: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except ValidationError as exc:
logger.warning("Rejected poo record request due to validation failure: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except ValueError as exc:
logger.warning("Rejected poo record request due to invalid numeric input: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
logger.warning("Failed to store poo record: %s", exc)
return PlainTextResponse(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(status_code=status.HTTP_200_OK)
@router.get("/poo/latest")
def notify_latest_poo(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
) -> Response:
try:
publish_latest_poo_status(
session=db,
settings=settings,
homeassistant_client=homeassistant_client,
)
except Exception as exc:
logger.warning("Failed to publish latest poo status: %s", exc)
return PlainTextResponse(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(status_code=status.HTTP_200_OK)
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_current_auth_session
from app.schemas.public_ip import PublicIPCheckResponse
from app.config import get_settings
from app.services.auth import AuthenticatedSession
from app.services.public_ip import check_public_ipv4_and_notify
router = APIRouter(tags=["public-ip"])
@router.get("/public-ip/check", response_model=PublicIPCheckResponse)
def run_public_ip_check(
session: Session = Depends(get_db),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> PublicIPCheckResponse:
if current_auth is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required")
result = check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
return PublicIPCheckResponse(
status=result.status,
checked_at=result.checked_at,
changed=result.changed,
)
+79
View File
@@ -0,0 +1,79 @@
import logging
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import (
get_app_settings,
get_db,
get_current_auth_session,
get_ticktick_client,
)
from app.integrations.ticktick import TickTickAuthError, TickTickClient, TickTickConfigError, TickTickRequestError
from app.services.auth import AuthenticatedSession
from app.services.config_page import save_config_value
router = APIRouter(tags=["ticktick"])
logger = logging.getLogger(__name__)
@router.get("/ticktick/auth/start")
def start_ticktick_auth(
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
try:
authorization_url = ticktick_client.build_authorization_url()
except TickTickConfigError as exc:
logger.warning("Rejected TickTick OAuth start due to incomplete configuration: %s", exc)
return PlainTextResponse("TickTick integration is not configured", status_code=400)
return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/ticktick/auth/code")
def handle_ticktick_auth_code(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
code = request.query_params.get("code", "")
state = request.query_params.get("state", "")
if not code or not state:
return RedirectResponse(
url="/config?ticktick_oauth=invalid-callback",
status_code=status.HTTP_303_SEE_OTHER,
)
try:
token = ticktick_client.exchange_authorization_code(code=code, state=state)
save_config_value(
auth_db_session,
env_name="TICKTICK_TOKEN",
value=token,
bootstrap_settings=settings,
)
except TickTickAuthError as exc:
logger.warning("Rejected TickTick OAuth callback due to invalid state: %s", exc)
return RedirectResponse(
url="/config?ticktick_oauth=invalid-state",
status_code=status.HTTP_303_SEE_OTHER,
)
except (TickTickConfigError, TickTickRequestError, ValueError) as exc:
logger.warning("TickTick OAuth callback failed: %s", exc)
return RedirectResponse(
url="/config?ticktick_oauth=failed",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
url="/config?ticktick_oauth=success",
status_code=status.HTTP_303_SEE_OTHER,
)
+44 -11
View File
@@ -7,27 +7,42 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "Home Automation Backend (Python)" app_name: str = "Home Automation Backend (Python)"
app_env: str = "development" app_env: str = "production"
app_debug: bool = False app_debug: bool = False
app_host: str = "0.0.0.0" app_hostname: str = "localhost:8000"
app_port: int = 8000 app_database_url: str = "sqlite:///./data/app.db"
location_database_url: str = "sqlite:///./data/locationRecorder.db"
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
ticktick_client_id: str = "" ticktick_client_id: str = ""
ticktick_client_secret: str = "" ticktick_client_secret: str = ""
ticktick_redirect_uri: str = ""
ticktick_token: str = "" ticktick_token: str = ""
home_assistant_base_url: str = "" home_assistant_base_url: str = ""
home_assistant_auth_token: str = "" home_assistant_auth_token: str = ""
home_assistant_timeout_seconds: float = 1.0
home_assistant_action_task_project_id: str = "" home_assistant_action_task_project_id: str = ""
smtp_enabled: bool = False
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from_name: str = ""
smtp_from_address: str = ""
smtp_to_address: str = ""
smtp_use_starttls: bool = True
poo_webhook_id: str = ""
poo_sensor_entity_name: str = "sensor.test_poo_status"
poo_sensor_friendly_name: str = "Poo Status"
auth_bootstrap_username: str = "admin"
auth_bootstrap_password: str = "admin"
auth_session_cookie_name: str = "home_automation_session"
auth_session_ttl_hours: int = 12
auth_cookie_secure_override: bool | None = True
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
case_sensitive=False, case_sensitive=False,
extra="ignore",
) )
@computed_field @computed_field
@@ -35,6 +50,22 @@ class Settings(BaseSettings):
def is_development(self) -> bool: def is_development(self) -> bool:
return self.app_env.lower() == "development" return self.app_env.lower() == "development"
@computed_field
@property
def app_base_url(self) -> str:
hostname = self.app_hostname.strip().rstrip("/")
if not hostname:
return ""
scheme = "http" if self.is_development else "https"
return f"{scheme}://{hostname}"
@computed_field
@property
def ticktick_redirect_uri(self) -> str:
if not self.app_base_url:
return ""
return f"{self.app_base_url}/ticktick/auth/code"
@staticmethod @staticmethod
def _sqlite_path_from_url(database_url: str) -> Path | None: def _sqlite_path_from_url(database_url: str) -> Path | None:
prefix = "sqlite:///" prefix = "sqlite:///"
@@ -45,13 +76,15 @@ class Settings(BaseSettings):
@computed_field @computed_field
@property @property
def location_sqlite_path(self) -> Path | None: def app_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.location_database_url) return self._sqlite_path_from_url(self.app_database_url)
@computed_field @computed_field
@property @property
def poo_sqlite_path(self) -> Path | None: def auth_cookie_secure(self) -> bool:
return self._sqlite_path_from_url(self.poo_database_url) if self.auth_cookie_secure_override is not None:
return self.auth_cookie_secure_override
return not self.is_development
@lru_cache @lru_cache
+40 -7
View File
@@ -1,6 +1,8 @@
from collections.abc import Generator from collections.abc import Generator
from functools import lru_cache
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings from app.config import get_settings
@@ -10,18 +12,49 @@ class Base(DeclarativeBase):
pass pass
settings = get_settings() def _build_connect_args(database_url: str) -> dict[str, object]:
connect_args: dict[str, object] = {} connect_args: dict[str, object] = {}
if settings.location_database_url.startswith("sqlite"): if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
return connect_args
engine = create_engine(settings.location_database_url, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) @lru_cache
def _get_engine(database_url: str) -> Engine:
engine = create_engine(database_url, connect_args=_build_connect_args(database_url))
if database_url.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _enable_sqlite_wal(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
return engine
@lru_cache
def _get_session_local(database_url: str) -> sessionmaker:
engine = _get_engine(database_url)
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
def get_engine() -> Engine:
return _get_engine(get_settings().app_database_url)
def get_session_local() -> sessionmaker:
return _get_session_local(get_settings().app_database_url)
def reset_db_caches() -> None:
_get_session_local.cache_clear()
_get_engine.cache_clear()
def get_db_session() -> Generator[Session, None, None]: def get_db_session() -> Generator[Session, None, None]:
session = SessionLocal() session_local = get_session_local()
session = session_local()
try: try:
yield session yield session
finally: finally:
+25 -4
View File
@@ -1,15 +1,36 @@
from collections.abc import Generator from collections.abc import Generator
from fastapi import Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db import get_db_session from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient
from app.integrations.ticktick import TickTickClient
def get_app_settings() -> Settings: from app.services.auth import AuthenticatedSession, get_authenticated_session
return get_settings() from app.services.config_page import build_runtime_settings
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
yield from get_db_session() yield from get_db_session()
def get_app_settings(session: Session = Depends(get_db)) -> Settings:
return build_runtime_settings(session, get_settings())
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
return HomeAssistantClient(settings)
def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickTickClient:
return TickTickClient(settings)
def get_current_auth_session(
request: Request,
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> AuthenticatedSession | None:
raw_token = request.cookies.get(settings.auth_session_cookie_name)
return get_authenticated_session(session, raw_token=raw_token)
+97 -1
View File
@@ -1,12 +1,108 @@
from dataclasses import dataclass from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from urllib import error, parse, request
from app.config import Settings from app.config import Settings
logger = logging.getLogger(__name__)
SUCCESS_STATUS_CODES = {200, 201}
class HomeAssistantConfigError(RuntimeError):
"""Raised when required Home Assistant outbound configuration is missing."""
class HomeAssistantRequestError(RuntimeError):
"""Raised when a Home Assistant outbound HTTP request fails."""
@dataclass(slots=True) @dataclass(slots=True)
class HomeAssistantClient: class HomeAssistantClient:
settings: Settings settings: Settings
timeout_seconds: float | None = field(default=None)
def __post_init__(self) -> None:
if self.timeout_seconds is None:
self.timeout_seconds = self.settings.home_assistant_timeout_seconds
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token)
def publish_sensor(
self,
*,
entity_id: str,
state: str,
attributes: dict[str, Any] | None = None,
) -> None:
self._require_config()
if not entity_id:
raise ValueError("entity_id must not be empty")
payload = {
"entity_id": entity_id,
"state": state,
"attributes": attributes or {},
}
self._post_json(f"/api/states/{entity_id}", payload, operation="publish_sensor")
def trigger_webhook(self, *, webhook_id: str, body: Any) -> None:
self._require_config()
if not webhook_id:
raise ValueError("webhook_id must not be empty")
self._post_json(f"/api/webhook/{webhook_id}", body, operation="trigger_webhook")
def _require_config(self) -> None:
if self.is_configured():
return
raise HomeAssistantConfigError(
"Home Assistant outbound integration is not configured. "
"Set HOME_ASSISTANT_BASE_URL and HOME_ASSISTANT_AUTH_TOKEN."
)
def _post_json(self, path: str, payload: Any, *, operation: str) -> None:
url = self._build_url(path)
body = json.dumps(payload).encode("utf-8")
req = request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {self.settings.home_assistant_auth_token}")
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
status_code = response.getcode()
except error.HTTPError as exc:
logger.warning(
"Home Assistant outbound %s failed with HTTP %s for %s",
operation,
exc.code,
url,
)
raise HomeAssistantRequestError(
f"Home Assistant outbound {operation} failed with HTTP {exc.code}"
) from exc
except error.URLError as exc:
logger.warning("Home Assistant outbound %s failed for %s: %s", operation, url, exc)
raise HomeAssistantRequestError(
f"Home Assistant outbound {operation} failed to reach Home Assistant"
) from exc
if status_code not in SUCCESS_STATUS_CODES:
logger.warning(
"Home Assistant outbound %s returned unexpected status %s for %s",
operation,
status_code,
url,
)
raise HomeAssistantRequestError(
f"Home Assistant outbound {operation} returned unexpected status {status_code}"
)
def _build_url(self, path: str) -> str:
base_url = self.settings.home_assistant_base_url.rstrip("/")
quoted_path = parse.quote(path.lstrip("/"), safe="/")
return f"{base_url}/{quoted_path}"
+291 -2
View File
@@ -1,12 +1,301 @@
from dataclasses import dataclass from __future__ import annotations
import json
import logging
import secrets
import base64
from dataclasses import asdict, dataclass, field, fields
from typing import Any
from urllib import error, parse, request
from app.config import Settings from app.config import Settings
logger = logging.getLogger(__name__)
TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize"
TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token"
TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1"
TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
AUTH_SCOPE = "tasks:read tasks:write"
class TickTickConfigError(RuntimeError):
"""Raised when TickTick is missing required runtime configuration."""
class TickTickAuthError(RuntimeError):
"""Raised when TickTick OAuth state validation fails."""
class TickTickRequestError(RuntimeError):
"""Raised when a TickTick API request fails."""
@dataclass(slots=True)
class TickTickProject:
id: str
name: str
color: str | None = None
sortOrder: int | None = None
closed: bool | None = None
groupId: str | None = None
viewMode: str | None = None
permission: str | None = None
kind: str | None = None
@dataclass(slots=True)
class TickTickTask:
projectId: str
title: str
id: str | None = None
isAllDay: bool | None = None
completedTime: str | None = None
content: str | None = None
desc: str | None = None
dueDate: str | None = None
items: list[Any] | None = None
priority: int | None = None
reminders: list[str] | None = None
repeatFlag: str | None = None
sortOrder: int | None = None
startDate: str | None = None
status: int | None = None
timeZone: str | None = None
@dataclass(slots=True)
class TickTickAuthStateStore:
pending_state: str | None = None
def issue_state(self) -> str:
self.pending_state = secrets.token_hex(6)
return self.pending_state
def matches_state(self, state: str) -> bool:
return bool(self.pending_state and state == self.pending_state)
def consume_state(self, state: str) -> bool:
if not self.pending_state or state != self.pending_state:
return False
self.pending_state = None
return True
def clear(self) -> None:
self.pending_state = None
default_auth_state_store = TickTickAuthStateStore()
def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any:
allowed_field_names = {item.name for item in fields(model_type)}
filtered_payload = {
key: value for key, value in payload.items() if key in allowed_field_names
}
return model_type(**filtered_payload)
@dataclass(slots=True) @dataclass(slots=True)
class TickTickClient: class TickTickClient:
settings: Settings settings: Settings
auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store)
timeout_seconds: float = 10.0
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret) return bool(self._client_id() and self._client_secret())
def has_token(self) -> bool:
return bool(self.settings.ticktick_token)
def build_authorization_url(self) -> str:
self._require_auth_config()
state = self.auth_state_store.issue_state()
params = parse.urlencode(
{
"client_id": self._client_id(),
"response_type": "code",
"redirect_uri": self._redirect_uri(),
"state": state,
"scope": AUTH_SCOPE,
}
)
return f"{TICKTICK_AUTH_URL}?{params}"
def exchange_authorization_code(self, *, code: str, state: str) -> str:
self._require_auth_config()
if not code:
raise ValueError("code must not be empty")
if not state:
raise ValueError("state must not be empty")
if not self.auth_state_store.matches_state(state):
raise TickTickAuthError("Invalid state")
body = parse.urlencode(
{
"code": code,
"grant_type": "authorization_code",
"scope": AUTH_SCOPE,
"redirect_uri": self._redirect_uri(),
}
).encode("utf-8")
req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
req.add_header("Authorization", self._basic_auth_header())
payload = self._send_json_request(req, operation="exchange_authorization_code")
self.auth_state_store.clear()
token = payload.get("access_token")
if not isinstance(token, str) or not token:
raise TickTickRequestError("TickTick token response did not include access_token")
return token
def get_projects(self) -> list[TickTickProject]:
self._require_token()
payload = self._authorized_json_request(
method="GET",
path="/project/",
operation="get_projects",
)
if not isinstance(payload, list):
raise TickTickRequestError("TickTick get_projects returned an unexpected payload")
return [_coerce_dataclass_payload(TickTickProject, project) for project in payload]
def get_tasks(self, project_id: str) -> list[TickTickTask]:
self._require_token()
if not project_id:
raise ValueError("project_id must not be empty")
payload = self._authorized_json_request(
method="GET",
path=f"/project/{parse.quote(project_id, safe='')}/data",
operation="get_tasks",
accepted_status_codes={200, 404},
)
if payload is None:
return []
if not isinstance(payload, dict):
raise TickTickRequestError("TickTick get_tasks returned an unexpected payload")
tasks = payload.get("tasks", [])
if not isinstance(tasks, list):
raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload")
return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks]
def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool:
if not task_title:
raise ValueError("task_title must not be empty")
return any(task.title == task_title for task in self.get_tasks(project_id))
def create_task(self, task: TickTickTask) -> None:
self._require_token()
if not task.projectId:
raise ValueError("task.projectId must not be empty")
if not task.title:
raise ValueError("task.title must not be empty")
if self.has_duplicate_task(project_id=task.projectId, task_title=task.title):
return
payload = {key: value for key, value in asdict(task).items() if value is not None}
self._authorized_json_request(
method="POST",
path="/task",
operation="create_task",
body=payload,
accepted_status_codes={200},
)
def _authorized_json_request(
self,
*,
method: str,
path: str,
operation: str,
body: Any | None = None,
accepted_status_codes: set[int] | None = None,
) -> Any:
url = f"{TICKTICK_OPEN_API_BASE_URL}{path}"
encoded_body = None if body is None else json.dumps(body).encode("utf-8")
req = request.Request(url, data=encoded_body, method=method)
req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}")
if body is not None:
req.add_header("Content-Type", "application/json")
return self._send_json_request(
req,
operation=operation,
accepted_status_codes=accepted_status_codes,
)
def _send_json_request(
self,
req: request.Request,
*,
operation: str,
accepted_status_codes: set[int] | None = None,
) -> Any:
accepted_codes = accepted_status_codes or {200}
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
status_code = response.getcode()
if status_code not in accepted_codes:
raise TickTickRequestError(
f"TickTick {operation} returned unexpected status {status_code}"
)
raw_body = response.read()
except error.HTTPError as exc:
if exc.code in accepted_codes:
raw_body = exc.read()
else:
logger.warning(
"TickTick %s failed with HTTP %s for %s",
operation,
exc.code,
req.full_url,
)
raise TickTickRequestError(
f"TickTick {operation} failed with HTTP {exc.code}"
) from exc
except error.URLError as exc:
logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc)
raise TickTickRequestError(
f"TickTick {operation} failed to reach TickTick API"
) from exc
if not raw_body:
return None
try:
return json.loads(raw_body)
except json.JSONDecodeError as exc:
raise TickTickRequestError(
f"TickTick {operation} returned invalid JSON"
) from exc
def _basic_auth_header(self) -> str:
raw_credentials = f"{self._client_id()}:{self._client_secret()}"
token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii")
return f"Basic {token}"
def _client_id(self) -> str:
return self.settings.ticktick_client_id.strip()
def _client_secret(self) -> str:
return self.settings.ticktick_client_secret.strip()
def _redirect_uri(self) -> str:
return self.settings.ticktick_redirect_uri
def _require_auth_config(self) -> None:
if not self.is_configured():
raise TickTickConfigError(
"TickTick integration is not configured. Set TICKTICK_CLIENT_ID and "
"TICKTICK_CLIENT_SECRET."
)
if not self._redirect_uri():
raise TickTickConfigError(
"TickTick integration is missing APP_HOSTNAME for OAuth callback generation."
)
def _require_token(self) -> None:
self._require_auth_config()
if self.has_token():
return
raise TickTickConfigError(
"TickTick integration is missing TICKTICK_TOKEN. Complete the OAuth flow first."
)
+59 -5
View File
@@ -3,24 +3,73 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session
from app import models # noqa: F401 from app import models # noqa: F401
from app.api.routes.auth import router as auth_router
from app.api.routes import pages, status from app.api.routes import pages, status
from app.db import get_session_local
from app.api.routes.homeassistant import router as homeassistant_router
from app.api.routes.location import router as location_router from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router
from app.api.routes.public_ip import router as public_ip_router
from app.api.routes.ticktick import router as ticktick_router
from app.config import get_settings from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.public_ip import check_public_ipv4_and_notify
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
def _run_scheduled_public_ip_check() -> None:
session_local = get_session_local()
session: Session = session_local()
try:
check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
finally:
session.close()
def ensure_auth_db_ready() -> None:
session_local = get_session_local()
session: Session = session_local()
try:
validate_app_runtime_db(get_settings().app_database_url)
initialize_auth_schema(session, get_settings())
seed_missing_config_from_bootstrap(session, get_settings())
sync_app_hostname_from_bootstrap(session, get_settings())
except AppDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc
except AuthBootstrapError as exc:
raise RuntimeError(str(exc)) from exc
finally:
session.close()
def ensure_runtime_dirs() -> None: def ensure_runtime_dirs() -> None:
settings = get_settings() settings = get_settings()
for path in (settings.location_sqlite_path, settings.poo_sqlite_path): if settings.app_sqlite_path is not None:
if path is not None: settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True)
path.parent.mkdir(parents=True, exist_ok=True)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
ensure_runtime_dirs() ensure_runtime_dirs()
ensure_auth_db_ready()
scheduler = BackgroundScheduler(timezone="UTC")
scheduler.add_job(
_run_scheduled_public_ip_check,
trigger=IntervalTrigger(hours=4),
id="public-ip-check",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.start()
yield yield
scheduler.shutdown(wait=False)
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -31,8 +80,8 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
lifespan=lifespan, lifespan=lifespan,
description=( description=(
"Python rewrite skeleton for the home automation backend. " "Home automation backend with auth, runtime config, Home Assistant "
"This stage provides only the foundation for future module migration." "integrations, TickTick integration, and SQLite-backed recorders."
), ),
) )
@@ -40,8 +89,13 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router) app.include_router(status.router)
app.include_router(auth_router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(homeassistant_router)
app.include_router(location_router) app.include_router(location_router)
app.include_router(poo_router)
app.include_router(public_ip_router)
app.include_router(ticktick_router)
return app return app
+13 -1
View File
@@ -1,5 +1,17 @@
"""SQLAlchemy models package.""" """SQLAlchemy models package."""
from app.models.auth import AuthSession, AuthUser
from app.models.config import AppConfigEntry
from app.models.location import Location from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
__all__ = ["Location"] __all__ = [
"AppConfigEntry",
"AuthSession",
"AuthUser",
"Location",
"PooRecord",
"PublicIPHistory",
"PublicIPState",
]
+33
View File
@@ -0,0 +1,33 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class AuthUser(Base):
__tablename__ = "auth_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
class AuthSession(Base):
__tablename__ = "auth_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
csrf_token: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[AuthUser] = relationship(back_populates="sessions")
-4
View File
@@ -1,4 +0,0 @@
from app.db import Base
__all__ = ["Base"]
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class AppConfigEntry(Base):
__tablename__ = "app_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
value: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class PooRecord(Base):
__tablename__ = "poo_records"
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
status: Mapped[str] = mapped_column(String, nullable=False)
latitude: Mapped[float] = mapped_column(Float, nullable=False)
longitude: Mapped[float] = mapped_column(Float, nullable=False)
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class PublicIPState(Base):
__tablename__ = "public_ip_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
current_ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
previous_ipv4: Mapped[str | None] = mapped_column(String(45), nullable=True)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_check_status: Mapped[str] = mapped_column(String(32), nullable=False)
last_check_error: Mapped[str | None] = mapped_column(String(255), nullable=True)
last_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
class PublicIPHistory(Base):
__tablename__ = "public_ip_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
change_type: Mapped[str] = mapped_column(String(32), nullable=False)
provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class HomeAssistantPublishEnvelope(BaseModel):
target: str
action: str
content: str
model_config = ConfigDict(extra="forbid")
+1 -2
View File
@@ -5,7 +5,6 @@ class LocationRecordRequest(BaseModel):
person: str person: str
latitude: str latitude: str
longitude: str longitude: str
altitude: str = "" altitude: str | None = None
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class PooRecordRequest(BaseModel):
status: str
latitude: str
longitude: str
model_config = ConfigDict(extra="forbid")
+13
View File
@@ -0,0 +1,13 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
PublicIPCheckStatus = Literal["first_seen", "unchanged", "changed", "error"]
class PublicIPCheckResponse(BaseModel):
status: PublicIPCheckStatus
checked_at: datetime
changed: bool
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict, Field
class TickTickActionTaskRequest(BaseModel):
title: str | None = None
action: str
due_hour: int = Field(alias="due_hour")
model_config = ConfigDict(extra="forbid", populate_by_name=True)
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import hashlib
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from sqlalchemy import Select, select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models.auth import AuthSession, AuthUser
logger = logging.getLogger(__name__)
password_hasher = PasswordHasher()
class AuthBootstrapError(RuntimeError):
"""Raised when the auth system cannot be safely initialized."""
class AuthPasswordChangeError(ValueError):
"""Raised when a password change request is invalid."""
@dataclass(slots=True)
class AuthenticatedSession:
user: AuthUser
session: AuthSession
def initialize_auth_schema(session: Session, settings: Settings) -> None:
has_any_user = session.scalar(select(AuthUser.id).limit(1)) is not None
if has_any_user:
return
if not settings.auth_bootstrap_username or not settings.auth_bootstrap_password:
raise AuthBootstrapError(
"Auth DB has no users. Set AUTH_BOOTSTRAP_USERNAME and "
"AUTH_BOOTSTRAP_PASSWORD before starting the app."
)
bootstrap_user = AuthUser(
username=settings.auth_bootstrap_username,
password_hash=hash_password(settings.auth_bootstrap_password),
is_active=True,
force_password_change=True,
created_at=_utc_now(),
)
session.add(bootstrap_user)
session.commit()
logger.warning(
"Bootstrapped initial auth user '%s'. Rotate AUTH_BOOTSTRAP_PASSWORD after first setup.",
bootstrap_user.username,
)
def hash_password(password: str) -> str:
return password_hasher.hash(password)
def verify_password(password: str, stored_hash: str) -> bool:
try:
return password_hasher.verify(stored_hash, password)
except VerifyMismatchError:
return False
except (InvalidHashError, VerificationError):
return False
def authenticate_user(session: Session, *, username: str, password: str) -> AuthUser | None:
user = session.scalar(select(AuthUser).where(AuthUser.username == username).limit(1))
if user is None or not user.is_active:
logger.info("Failed login for unknown or inactive user '%s'", username)
return None
if not verify_password(password, user.password_hash):
logger.info("Failed login due to invalid password for user '%s'", username)
return None
return user
def create_session(session: Session, *, user: AuthUser, settings: Settings) -> tuple[AuthSession, str]:
raw_token = secrets.token_urlsafe(32)
auth_session = AuthSession(
user_id=user.id,
token_hash=_hash_token(raw_token),
csrf_token=secrets.token_urlsafe(24),
created_at=_utc_now(),
expires_at=_utc_now() + timedelta(hours=settings.auth_session_ttl_hours),
revoked_at=None,
)
session.add(auth_session)
session.commit()
session.refresh(auth_session)
return auth_session, raw_token
def get_authenticated_session(session: Session, *, raw_token: str | None) -> AuthenticatedSession | None:
if not raw_token:
return None
stmt: Select[tuple[AuthSession, AuthUser]] = (
select(AuthSession, AuthUser)
.join(AuthUser, AuthSession.user_id == AuthUser.id)
.where(AuthSession.token_hash == _hash_token(raw_token))
.limit(1)
)
result = session.execute(stmt).first()
if result is None:
return None
auth_session, user = result
now = _utc_now()
expires_at = _as_utc(auth_session.expires_at)
revoked_at = _as_utc(auth_session.revoked_at)
if expires_at is None:
logger.warning("Auth session %s has no expires_at; treating it as invalid", auth_session.id)
return None
if revoked_at is not None or expires_at <= now or not user.is_active:
if revoked_at is None and expires_at <= now:
auth_session.revoked_at = now
session.commit()
return None
return AuthenticatedSession(user=user, session=auth_session)
def revoke_session(session: Session, *, auth_session: AuthSession) -> None:
if auth_session.revoked_at is not None:
return
auth_session.revoked_at = _utc_now()
session.commit()
def change_password(
session: Session,
*,
user: AuthUser,
current_password: str,
new_password: str,
confirm_password: str,
) -> None:
if not verify_password(current_password, user.password_hash):
raise AuthPasswordChangeError("current password is invalid")
if not new_password:
raise AuthPasswordChangeError("new password must not be empty")
if new_password != confirm_password:
raise AuthPasswordChangeError("new password confirmation does not match")
if len(new_password) < 8:
raise AuthPasswordChangeError("new password must be at least 8 characters long")
if verify_password(new_password, user.password_hash):
raise AuthPasswordChangeError("new password must be different from the current password")
user.password_hash = hash_password(new_password)
user.force_password_change = False
session.commit()
def issue_login_csrf_token() -> str:
return secrets.token_urlsafe(24)
def validate_csrf_token(*, expected: str | None, actual: str | None) -> bool:
if not expected or not actual:
return False
return secrets.compare_digest(expected, actual)
def _hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def _utc_now() -> datetime:
return datetime.now(UTC)
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import reset_db_caches
from app.config import Settings, get_settings
from app.models.config import AppConfigEntry
@dataclass(frozen=True, slots=True)
class ConfigField:
section: str
env_name: str
setting_attr: str
label: str
secret: bool = False
input_type: str = "text"
CONFIG_FIELDS: tuple[ConfigField, ...] = (
ConfigField("System", "APP_NAME", "app_name", "App Name"),
ConfigField("System", "APP_ENV", "app_env", "App Env"),
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"),
ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled"),
ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"),
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True),
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"),
ConfigField(
"Authentication",
"AUTH_SESSION_COOKIE_NAME",
"auth_session_cookie_name",
"Session Cookie Name",
),
ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"),
ConfigField(
"Authentication",
"AUTH_COOKIE_SECURE_OVERRIDE",
"auth_cookie_secure_override",
"Cookie Secure Override",
),
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
ConfigField(
"Poo",
"POO_SENSOR_ENTITY_NAME",
"poo_sensor_entity_name",
"Poo Sensor Entity Name",
),
ConfigField(
"Poo",
"POO_SENSOR_FRIENDLY_NAME",
"poo_sensor_friendly_name",
"Poo Sensor Friendly Name",
),
ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"),
ConfigField(
"TickTick",
"TICKTICK_CLIENT_SECRET",
"ticktick_client_secret",
"TickTick Client Secret",
secret=True,
),
ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_BASE_URL",
"home_assistant_base_url",
"Home Assistant Base URL",
),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_AUTH_TOKEN",
"home_assistant_auth_token",
"Home Assistant Auth Token",
secret=True,
),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_TIMEOUT_SECONDS",
"home_assistant_timeout_seconds",
"Home Assistant Timeout Seconds",
),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_ACTION_TASK_PROJECT_ID",
"home_assistant_action_task_project_id",
"Home Assistant Action Task Project ID",
),
)
class ConfigSaveError(ValueError):
"""Raised when the submitted config payload is invalid."""
def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None:
current_values = _read_config_values(session)
missing_values: dict[str, str] = {}
for field in CONFIG_FIELDS:
if field.env_name in current_values:
continue
missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr))
if not missing_values:
return
_persist_config_values(session, {**current_values, **missing_values})
def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None:
current_values = _read_config_values(session)
bootstrap_hostname = _stringify(bootstrap_settings.app_hostname)
if current_values.get("APP_HOSTNAME") == bootstrap_hostname:
return
current_values["APP_HOSTNAME"] = bootstrap_hostname
_persist_config_values(session, current_values)
get_settings.cache_clear()
reset_db_caches()
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
overrides = _read_config_values(session)
if not overrides:
return bootstrap_settings
payload = _settings_payload(bootstrap_settings)
for field in CONFIG_FIELDS:
if field.env_name in overrides:
payload[field.setting_attr] = overrides[field.env_name]
return Settings(_env_file=None, **payload)
def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]:
runtime_settings = build_runtime_settings(session, bootstrap_settings)
persisted_values = _read_config_values(session)
sections: list[dict[str, Any]] = []
current_section: dict[str, Any] | None = None
for field in CONFIG_FIELDS:
if current_section is None or current_section["name"] != field.section:
current_section = {"name": field.section, "fields": []}
sections.append(current_section)
current_section["fields"].append(
{
"env_name": field.env_name,
"label": field.label,
"value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)),
"secret": field.secret,
"input_type": "password" if field.secret else field.input_type,
"configured": field.env_name in persisted_values
or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))),
}
)
return sections
def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None:
current_values = _read_config_values(session)
merged_values = dict(current_values)
for field in CONFIG_FIELDS:
submitted_value = form_data.get(field.env_name, "")
if field.secret:
if submitted_value:
merged_values[field.env_name] = submitted_value
else:
merged_values[field.env_name] = submitted_value
_validate_config_values(merged_values, bootstrap_settings)
_persist_config_values(session, merged_values)
get_settings.cache_clear()
reset_db_caches()
def save_config_value(
session: Session,
*,
env_name: str,
value: str,
bootstrap_settings: Settings,
) -> None:
current_values = _read_config_values(session)
current_values[env_name] = value
_validate_config_values(current_values, bootstrap_settings)
_persist_config_values(session, current_values)
get_settings.cache_clear()
reset_db_caches()
def is_ticktick_oauth_ready(settings: Settings) -> bool:
return bool(
settings.app_hostname
and settings.ticktick_client_id
and settings.ticktick_client_secret
)
def _read_config_values(session: Session) -> dict[str, str]:
rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
return {row.key: row.value for row in rows}
def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None:
payload = _settings_payload(bootstrap_settings)
for field in CONFIG_FIELDS:
if field.env_name in config_values:
payload[field.setting_attr] = config_values[field.env_name]
try:
Settings(_env_file=None, **payload)
except Exception as exc:
raise ConfigSaveError("invalid config submission") from exc
def _persist_config_values(session: Session, config_values: dict[str, str]) -> None:
existing_entries = {
row.key: row
for row in session.execute(select(AppConfigEntry)).scalars().all()
}
now = datetime.now(UTC)
for env_name, value in config_values.items():
entry = existing_entries.get(env_name)
if entry is None:
session.add(AppConfigEntry(key=env_name, value=value, updated_at=now))
else:
entry.value = value
entry.updated_at = now
session.commit()
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return str(value).lower()
return str(value)
def _settings_payload(settings: Settings) -> dict[str, Any]:
return {
"app_name": settings.app_name,
"app_env": settings.app_env,
"app_debug": settings.app_debug,
"app_hostname": settings.app_hostname,
"app_database_url": settings.app_database_url,
"ticktick_client_id": settings.ticktick_client_id,
"ticktick_client_secret": settings.ticktick_client_secret,
"ticktick_token": settings.ticktick_token,
"home_assistant_base_url": settings.home_assistant_base_url,
"home_assistant_auth_token": settings.home_assistant_auth_token,
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
"smtp_enabled": settings.smtp_enabled,
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password": settings.smtp_password,
"smtp_from_name": settings.smtp_from_name,
"smtp_from_address": settings.smtp_from_address,
"smtp_to_address": settings.smtp_to_address,
"smtp_use_starttls": settings.smtp_use_starttls,
"poo_webhook_id": settings.poo_webhook_id,
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
"auth_bootstrap_username": settings.auth_bootstrap_username,
"auth_bootstrap_password": settings.auth_bootstrap_password,
"auth_session_cookie_name": settings.auth_session_cookie_name,
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
}
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
from email.utils import formataddr
import smtplib
from app.config import Settings
class EmailConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or disabled."""
class EmailDeliveryError(RuntimeError):
"""Raised when sending email fails."""
@dataclass(frozen=True, slots=True)
class SMTPConfig:
host: str
port: int
username: str
password: str
from_name: str
from_address: str
to_address: str
use_starttls: bool
def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTPConfig:
if require_enabled and not settings.smtp_enabled:
raise EmailConfigurationError("SMTP is disabled")
if not settings.smtp_host:
raise EmailConfigurationError("SMTP host is required")
if settings.smtp_port <= 0:
raise EmailConfigurationError("SMTP port must be greater than zero")
if not settings.smtp_from_address:
raise EmailConfigurationError("SMTP from address is required")
if not settings.smtp_to_address:
raise EmailConfigurationError("SMTP to address is required")
return SMTPConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
from_name=settings.smtp_from_name,
from_address=settings.smtp_from_address,
to_address=settings.smtp_to_address,
use_starttls=settings.smtp_use_starttls,
)
def is_smtp_ready(settings: Settings) -> bool:
try:
get_smtp_config(settings, require_enabled=False)
except EmailConfigurationError:
return False
return True
def send_plaintext_email(
settings: Settings,
*,
subject: str,
body: str,
recipient: str | None = None,
require_enabled: bool = True,
) -> None:
smtp_config = get_smtp_config(settings, require_enabled=require_enabled)
message = EmailMessage()
message["Subject"] = subject
message["From"] = _build_from_header(smtp_config)
message["To"] = recipient or smtp_config.to_address
message.set_content(body)
try:
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=10) as smtp:
smtp.ehlo()
if smtp_config.use_starttls:
smtp.starttls()
smtp.ehlo()
if smtp_config.username:
smtp.login(smtp_config.username, smtp_config.password)
smtp.send_message(
message,
from_addr=smtp_config.from_address,
to_addrs=[recipient or smtp_config.to_address],
)
except (OSError, smtplib.SMTPException) as exc:
error_message = _sanitize_error_message(str(exc), smtp_config.password)
raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc
def send_smtp_test_email(settings: Settings) -> None:
send_plaintext_email(
settings,
subject="Home Automation SMTP Test",
body="This is a test email from Home Automation SMTP settings.",
require_enabled=False,
)
def send_public_ip_changed_email(
settings: Settings,
*,
previous_ipv4: str,
current_ipv4: str,
detected_at: datetime,
) -> None:
send_plaintext_email(
settings,
subject="Public IP changed",
body=(
"Your public IPv4 address has changed.\n\n"
f"Previous IP: {previous_ipv4}\n"
f"Current IP: {current_ipv4}\n"
f"Detected at: {_format_utc_timestamp(detected_at)}\n\n"
"If you use Namecheap API trusted IP restrictions, you may need to "
"update the trusted IP manually.\n"
),
)
def _sanitize_error_message(message: str, password: str) -> str:
sanitized = message
if password:
sanitized = sanitized.replace(password, "[redacted]")
return sanitized
def _format_utc_timestamp(value: datetime) -> str:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.strftime("%Y-%m-%d %H:%M:%S UTC")
def _build_from_header(smtp_config: SMTPConfig) -> str:
if smtp_config.from_name:
return formataddr((smtp_config.from_name, smtp_config.from_address))
return smtp_config.from_address
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import json
from datetime import UTC, datetime, time, timedelta
from sqlalchemy.orm import Session
from app.config import Settings
from app.integrations.homeassistant import HomeAssistantClient
from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask
from app.schemas.homeassistant import HomeAssistantPublishEnvelope
from app.schemas.location import LocationRecordRequest
from app.schemas.ticktick import TickTickActionTaskRequest
from app.services.location import record_location
from app.services.poo import publish_latest_poo_status
class UnsupportedHomeAssistantMessage(RuntimeError):
"""Raised when the inbound gateway receives a target/action that is not supported yet."""
def handle_homeassistant_message(
session: Session,
envelope: HomeAssistantPublishEnvelope,
ticktick_client: TickTickClient | None = None,
poo_session: Session | None = None,
settings: Settings | None = None,
homeassistant_client: HomeAssistantClient | None = None,
) -> None:
if envelope.target == "location_recorder":
_handle_location_message(session, envelope)
return
if envelope.target == "poo_recorder":
_handle_poo_message(
envelope,
poo_session=poo_session,
settings=settings,
homeassistant_client=homeassistant_client,
)
return
if envelope.target == "ticktick":
_handle_ticktick_message(envelope, ticktick_client)
return
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnvelope) -> None:
if envelope.action != "record":
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
content = json.loads(envelope.content.replace("'", '"'))
payload = LocationRecordRequest.model_validate(content)
record_location(session, payload)
def _handle_poo_message(
envelope: HomeAssistantPublishEnvelope,
*,
poo_session: Session | None,
settings: Settings | None,
homeassistant_client: HomeAssistantClient | None,
) -> None:
if envelope.action != "get_latest":
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
if poo_session is None or settings is None or homeassistant_client is None:
raise RuntimeError("Poo recorder integration is unavailable")
publish_latest_poo_status(
session=poo_session,
settings=settings,
homeassistant_client=homeassistant_client,
)
def _handle_ticktick_message(
envelope: HomeAssistantPublishEnvelope,
ticktick_client: TickTickClient | None,
) -> None:
if envelope.action != "create_action_task":
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
if ticktick_client is None:
raise UnsupportedHomeAssistantMessage("TickTick client is unavailable")
content = json.loads(envelope.content.replace("'", '"'))
payload = TickTickActionTaskRequest.model_validate(content)
project_id = ticktick_client.settings.home_assistant_action_task_project_id
if not project_id:
raise RuntimeError(
"TickTick action task integration is missing HOME_ASSISTANT_ACTION_TASK_PROJECT_ID"
)
ticktick_client.create_task(
TickTickTask(
projectId=project_id,
title=payload.action,
dueDate=build_action_task_due_date(datetime.now().astimezone(), payload.due_hour),
)
)
def build_action_task_due_date(now: datetime, due_hour: int) -> str:
local_now = now.astimezone()
due = local_now + timedelta(hours=due_hour)
next_midnight = datetime.combine(due.date(), time.min, tzinfo=local_now.tzinfo) + timedelta(days=1)
return next_midnight.astimezone(UTC).strftime(TICKTICK_DATETIME_FORMAT)
+11 -5
View File
@@ -7,13 +7,20 @@ from app.models.location import Location
from app.schemas.location import LocationRecordRequest from app.schemas.location import LocationRecordRequest
def _parse_float_compat(value: str) -> float: def _parse_optional_float_compat(value: str | None) -> float:
try: try:
return float(value) return float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 0.0 return 0.0
def _parse_required_float(value: str, field_name: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid numeric value for {field_name}") from exc
def _utc_now_rfc3339() -> str: def _utc_now_rfc3339() -> str:
now = datetime.now(timezone.utc).replace(microsecond=0) now = datetime.now(timezone.utc).replace(microsecond=0)
return now.isoformat().replace("+00:00", "Z") return now.isoformat().replace("+00:00", "Z")
@@ -26,11 +33,10 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
.values( .values(
person=payload.person, person=payload.person,
datetime=_utc_now_rfc3339(), datetime=_utc_now_rfc3339(),
latitude=_parse_float_compat(payload.latitude), latitude=_parse_required_float(payload.latitude, "latitude"),
longitude=_parse_float_compat(payload.longitude), longitude=_parse_required_float(payload.longitude, "longitude"),
altitude=_parse_float_compat(payload.altitude), altitude=_parse_optional_float_compat(payload.altitude),
) )
) )
session.execute(stmt) session.execute(stmt)
session.commit() session.commit()
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from sqlalchemy import desc, insert, select
from sqlalchemy.orm import Session
from app.config import Settings
from app.integrations.homeassistant import (
HomeAssistantClient,
HomeAssistantConfigError,
HomeAssistantRequestError,
)
from app.models.poo import PooRecord
from app.schemas.poo import PooRecordRequest
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class LatestPooRecord:
timestamp: str
status: str
latitude: float
longitude: float
def _parse_required_float(value: str, field_name: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid numeric value for {field_name}") from exc
def _utc_now_minute_precision() -> str:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
return now.strftime("%Y-%m-%dT%H:%MZ")
def record_poo(
session: Session,
payload: PooRecordRequest,
*,
settings: Settings,
homeassistant_client: HomeAssistantClient,
) -> None:
stmt = insert(PooRecord).prefix_with("OR IGNORE").values(
timestamp=_utc_now_minute_precision(),
status=payload.status,
latitude=_parse_required_float(payload.latitude, "latitude"),
longitude=_parse_required_float(payload.longitude, "longitude"),
)
session.execute(stmt)
session.commit()
try:
publish_latest_poo_status(
session=session,
settings=settings,
homeassistant_client=homeassistant_client,
)
except (HomeAssistantConfigError, HomeAssistantRequestError) as exc:
logger.warning("Failed to publish latest poo status to Home Assistant: %s", exc)
if settings.poo_webhook_id:
try:
homeassistant_client.trigger_webhook(
webhook_id=settings.poo_webhook_id,
body={"status": payload.status},
)
except (HomeAssistantConfigError, HomeAssistantRequestError) as exc:
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
record = session.execute(stmt).scalar_one_or_none()
if record is None:
logger.info("No poo record is available yet")
return None
return LatestPooRecord(
timestamp=record.timestamp,
status=record.status,
latitude=record.latitude,
longitude=record.longitude,
)
def publish_latest_poo_status(
*,
session: Session,
settings: Settings,
homeassistant_client: HomeAssistantClient,
) -> LatestPooRecord | None:
latest = get_latest_poo_record(session)
if latest is None:
logger.info("Skipping Home Assistant poo sensor publish because no poo record exists yet")
return None
record_time = datetime.fromisoformat(latest.timestamp.replace("Z", "+00:00")).astimezone()
homeassistant_client.publish_sensor(
entity_id=settings.poo_sensor_entity_name,
state=latest.status,
attributes={
"last_poo": record_time.strftime("%a | %Y-%m-%d | %H:%M"),
"friendly_name": settings.poo_sensor_friendly_name,
},
)
return latest
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import ipaddress
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Callable, Literal
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models.public_ip import PublicIPHistory, PublicIPState
from app.services.config_page import build_runtime_settings
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_public_ip_changed_email
logger = logging.getLogger(__name__)
PUBLIC_IP_PROVIDER_NAME = "ipify"
PUBLIC_IP_PROVIDER_URL = "https://api.ipify.org"
PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS = 5.0
PublicIPResultStatus = Literal["first_seen", "unchanged", "changed", "error"]
PublicIPv4Fetcher = Callable[[], str]
class PublicIPCheckError(RuntimeError):
"""Raised when the public IPv4 provider cannot return a valid IPv4."""
@dataclass(slots=True)
class PublicIPCheckResult:
status: PublicIPResultStatus
checked_at: datetime
changed: bool
previous_ipv4: str | None = None
current_ipv4: str | None = None
def check_public_ipv4(
session: Session,
*,
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
) -> PublicIPCheckResult:
checked_at = _utc_now()
state = session.scalar(select(PublicIPState).where(PublicIPState.id == 1).limit(1))
try:
raw_ipv4 = (fetch_public_ipv4 or fetch_public_ipv4_from_provider)()
current_ipv4 = _validate_ipv4(raw_ipv4)
except PublicIPCheckError as exc:
logger.warning("Public IPv4 check failed: %s", exc)
if state is not None:
state.last_checked_at = checked_at
state.last_check_status = "error"
state.last_check_error = str(exc)
state.last_provider = provider_name
session.commit()
return PublicIPCheckResult(status="error", checked_at=checked_at, changed=False)
if state is None:
state = PublicIPState(
id=1,
current_ipv4=current_ipv4,
previous_ipv4=None,
first_seen_at=checked_at,
last_checked_at=checked_at,
last_changed_at=None,
last_check_status="first_seen",
last_check_error=None,
last_provider=provider_name,
)
session.add(state)
session.add(
PublicIPHistory(
ipv4=current_ipv4,
observed_at=checked_at,
change_type="first_seen",
provider=provider_name,
)
)
session.commit()
return PublicIPCheckResult(
status="first_seen",
checked_at=checked_at,
changed=False,
current_ipv4=current_ipv4,
)
if state.current_ipv4 == current_ipv4:
state.last_checked_at = checked_at
state.last_check_status = "unchanged"
state.last_check_error = None
state.last_provider = provider_name
session.commit()
return PublicIPCheckResult(
status="unchanged",
checked_at=checked_at,
changed=False,
current_ipv4=current_ipv4,
)
previous_ipv4 = state.current_ipv4
state.previous_ipv4 = previous_ipv4
state.current_ipv4 = current_ipv4
state.last_checked_at = checked_at
state.last_changed_at = checked_at
state.last_check_status = "changed"
state.last_check_error = None
state.last_provider = provider_name
session.add(
PublicIPHistory(
ipv4=current_ipv4,
observed_at=checked_at,
change_type="changed",
provider=provider_name,
)
)
session.commit()
return PublicIPCheckResult(
status="changed",
checked_at=checked_at,
changed=True,
previous_ipv4=previous_ipv4,
current_ipv4=current_ipv4,
)
def check_public_ipv4_and_notify(
session: Session,
*,
bootstrap_settings: Settings,
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
) -> PublicIPCheckResult:
result = check_public_ipv4(
session,
fetch_public_ipv4=fetch_public_ipv4,
provider_name=provider_name,
)
if result.status != "changed" or result.previous_ipv4 is None or result.current_ipv4 is None:
return result
runtime_settings = build_runtime_settings(session, bootstrap_settings)
try:
send_public_ip_changed_email(
runtime_settings,
previous_ipv4=result.previous_ipv4,
current_ipv4=result.current_ipv4,
detected_at=result.checked_at,
)
except (EmailConfigurationError, EmailDeliveryError) as exc:
logger.warning("Public IPv4 change notification failed: %s", exc)
return result
def fetch_public_ipv4_from_provider() -> str:
try:
response = httpx.get(
PUBLIC_IP_PROVIDER_URL,
params={"format": "text"},
timeout=PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS,
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise PublicIPCheckError(f"provider request failed: {exc}") from exc
return response.text.strip()
def _validate_ipv4(raw_value: str) -> str:
if not raw_value:
raise PublicIPCheckError("provider returned an empty response")
try:
parsed = ipaddress.ip_address(raw_value)
except ValueError as exc:
raise PublicIPCheckError("provider returned an invalid IPv4 value") from exc
if parsed.version != 4:
raise PublicIPCheckError("provider returned a non-IPv4 value")
return str(parsed)
def _utc_now() -> datetime:
return datetime.now(UTC)
+151 -1
View File
@@ -61,6 +61,11 @@ h1 {
margin: 0; margin: 0;
} }
.single-column {
grid-template-columns: minmax(180px, 320px);
margin-bottom: 24px;
}
.meta div { .meta div {
padding: 16px; padding: 16px;
border-radius: 16px; border-radius: 16px;
@@ -83,6 +88,147 @@ a {
color: var(--accent); color: var(--accent);
} }
.auth-panel {
max-width: 520px;
margin-inline: auto;
}
.auth-form,
.logout-form {
display: grid;
gap: 16px;
}
.auth-form label {
display: grid;
gap: 8px;
font-size: 0.95rem;
color: var(--muted);
}
.auth-form input {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(31, 41, 51, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
color: var(--text);
font: inherit;
}
button {
width: fit-content;
min-width: 120px;
padding: 12px 18px;
border: none;
border-radius: 999px;
background: var(--accent);
color: white;
font: inherit;
cursor: pointer;
}
button:hover {
filter: brightness(1.04);
}
.alert {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(157, 37, 37, 0.08);
border: 1px solid rgba(157, 37, 37, 0.14);
color: #8b2a2a;
}
.notice {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(45, 106, 79, 0.08);
border: 1px solid rgba(45, 106, 79, 0.14);
color: var(--accent);
}
.config-block + .config-block {
margin-top: 28px;
}
.config-block h2 {
margin: 0 0 16px;
font-size: 1.25rem;
}
.config-form {
display: grid;
gap: 20px;
}
.config-section {
margin: 0;
padding: 18px;
border: 1px solid rgba(31, 41, 51, 0.08);
border-radius: 16px;
display: grid;
gap: 14px;
}
.config-section legend {
padding: 0 8px;
color: var(--accent);
}
.config-form label small {
color: var(--muted);
}
.integration-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 8px;
border-top: 1px solid rgba(31, 41, 51, 0.08);
}
.integration-action-title {
margin: 0 0 6px;
font-weight: 600;
color: var(--text);
}
.integration-action-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-width: 120px;
padding: 12px 18px;
border: none;
border-radius: 999px;
background: var(--accent);
color: white;
text-decoration: none;
cursor: pointer;
}
.button-link:hover {
filter: brightness(1.04);
}
.button-link.disabled {
background: rgba(91, 104, 117, 0.28);
color: rgba(31, 41, 51, 0.72);
cursor: not-allowed;
pointer-events: none;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.shell { .shell {
margin: 24px auto; margin: 24px auto;
@@ -91,5 +237,9 @@ a {
.panel { .panel {
padding: 24px; padding: 24px;
} }
}
.integration-action-row {
align-items: stretch;
flex-direction: column;
}
}
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title> <title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
+139
View File
@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Config · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Configuration</p>
<h1>Config</h1>
{% if force_password_change %}
<div class="alert">
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
</div>
{% endif %}
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
{% if config_error %}
<div class="alert">{{ config_error }}</div>
{% endif %}
{% if config_saved %}
<div class="notice">config saved to the app database. Some changes may require an app restart.</div>
{% endif %}
{% if ticktick_oauth_error %}
<div class="alert">{{ ticktick_oauth_error }}</div>
{% endif %}
{% if ticktick_oauth_notice %}
<div class="notice">{{ ticktick_oauth_notice }}</div>
{% endif %}
{% if smtp_test_error %}
<div class="alert">{{ smtp_test_error }}</div>
{% endif %}
{% if smtp_test_notice %}
<div class="notice">{{ smtp_test_notice }}</div>
{% endif %}
<div class="meta single-column">
<div>
<dt>当前用户</dt>
<dd>admin</dd>
</div>
</div>
<section class="config-block">
<h2>Change Password</h2>
<form class="auth-form" method="post" action="/config/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
</section>
<section class="config-block">
<h2>Config</h2>
<form class="config-form" method="post" action="/config">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for section in config_sections %}
<fieldset class="config-section">
<legend>{{ section.name }}</legend>
{% for field in section.fields %}
<label>
<span>{{ field.label }}</span>
{% if field.secret %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
{% else %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
{% endif %}
</label>
{% endfor %}
{% if section.name == "TickTick" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">TickTick OAuth</p>
<p class="integration-action-copy">Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}</p>
{% if ticktick_oauth_ready %}
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
{% else %}
<p class="integration-action-copy">Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.</p>
{% endif %}
</div>
{% if ticktick_oauth_ready %}
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
{% else %}
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
{% endif %}
</div>
{% endif %}
{% if section.name == "SMTP" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">SMTP Test Email</p>
<p class="integration-action-copy">Save the SMTP settings first, then send a simple plaintext test email to the configured recipient.</p>
</div>
{% if smtp_test_ready %}
<button type="submit" formaction="/config/smtp/test" formmethod="post">Send SMTP Test</button>
{% else %}
<span class="button-link disabled" aria-disabled="true">Send SMTP Test</span>
{% endif %}
</div>
{% endif %}
</fieldset>
{% endfor %}
<button type="submit">Save Config</button>
</form>
</section>
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
+4 -1
View File
@@ -23,6 +23,10 @@
<dt>OpenAPI</dt> <dt>OpenAPI</dt>
<dd><a href="/docs">/docs</a></dd> <dd><a href="/docs">/docs</a></dd>
</div> </div>
<div>
<dt>登录</dt>
<dd><a href="/login">/login</a></dd>
</div>
<div> <div>
<dt>Notion</dt> <dt>Notion</dt>
<dd>{{ notion_status }}</dd> <dd>{{ notion_status }}</dd>
@@ -30,4 +34,3 @@
</dl> </dl>
</section> </section>
{% endblock %} {% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}登录 · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel auth-panel">
<p class="eyebrow">Authentication</p>
<h1>登录</h1>
<p class="lead">
登录成功后会进入受保护的 config 页面。
</p>
{% if error_message %}
<div class="alert">{{ error_message }}</div>
{% endif %}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">登录</button>
</form>
</section>
{% endblock %}
+15 -1
View File
@@ -13,12 +13,20 @@ anyio==4.13.0
# httpx # httpx
# starlette # starlette
# watchfiles # watchfiles
apscheduler==3.11.2
# via -r requirements.in
argon2-cffi==25.1.0
# via -r requirements.in
argon2-cffi-bindings==25.1.0
# via argon2-cffi
build==1.4.3 build==1.4.3
# via pip-tools # via pip-tools
certifi==2026.2.25 certifi==2026.2.25
# via # via
# httpcore # httpcore
# httpx # httpx
cffi==2.0.0
# via argon2-cffi-bindings
click==8.3.2 click==8.3.2
# via # via
# pip-tools # pip-tools
@@ -36,7 +44,9 @@ httpcore==1.0.9
httptools==0.7.1 httptools==0.7.1
# via uvicorn # via uvicorn
httpx==0.28.1 httpx==0.28.1
# via -r dev-requirements.in # via
# -r dev-requirements.in
# -r requirements.in
idna==3.11 idna==3.11
# via # via
# anyio # anyio
@@ -60,6 +70,8 @@ pip-tools==7.5.3
# via -r dev-requirements.in # via -r dev-requirements.in
pluggy==1.6.0 pluggy==1.6.0
# via pytest # via pytest
pycparser==2.23
# via cffi
pydantic==2.13.2 pydantic==2.13.2
# via # via
# fastapi # fastapi
@@ -104,6 +116,8 @@ typing-inspection==0.4.2
# via # via
# pydantic # pydantic
# pydantic-settings # pydantic-settings
tzlocal==5.3.1
# via apscheduler
uvicorn[standard]==0.44.0 uvicorn[standard]==0.44.0
# via -r requirements.in # via -r requirements.in
uvloop==0.22.1 uvloop==0.22.1
+6
View File
@@ -0,0 +1,6 @@
services:
migration:
build: .
app:
build: .
+24 -13
View File
@@ -1,16 +1,27 @@
version: "3.9"
services: services:
app: migration:
build: . container_name: home-automation-migration
ports: image: code.wanderingbadger.dev/tliu93/home-automation:latest
- "8000:8000" user: "1000:1000"
env_file: restart: "no"
- .env init: true
environment: command: ["python", "-m", "scripts.run_migrations"]
LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db
POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db
APP_HOST: 0.0.0.0
APP_PORT: 8000
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro
app:
container_name: home-automation-app
image: code.wanderingbadger.dev/tliu93/home-automation:latest
user: "1000:1000"
restart: unless-stopped
init: true
depends_on:
migration:
condition: service_completed_successfully
ports:
- "127.0.0.1:8881:8000"
volumes:
- ./data:/app/data
- ./.env:/app/.env:ro
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
exec "$@"
+14 -5
View File
@@ -24,27 +24,36 @@
- `config.py` - `config.py`
- 环境变量驱动的 settings - 环境变量驱动的 settings
- `db.py` - `db.py`
- SQLAlchemy engine / session / Base - 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engineSQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
- `dependencies.py` - `dependencies.py`
- 通用依赖注入 - 通用依赖注入
- `api/` - `api/`
- HTTP routes - HTTP routes
- 当前已迁入 `/login``/logout``/admin`
- 当前已迁入 `GET /public-ip/check`
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
- 当前已迁入 `POST /poo/record``GET /poo/latest`
- `models/` - `models/`
- SQLAlchemy models - SQLAlchemy models
- 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db`
- `schemas/` - `schemas/`
- Pydantic schemas - Pydantic schemas
- `services/` - `services/`
- 业务服务层 - 业务服务层
- 当前已迁入 config page 的 DB 持久化逻辑
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
- 当前已迁入 SMTP 发信与测试发信逻辑
- `integrations/` - `integrations/`
- 外部系统适配层占位 - 外部系统适配层
- 当前已迁入 Home Assistant outbound adapter
- `templates/` - `templates/`
- Jinja2 模板 - Jinja2 模板
- `static/` - `static/`
- 极简静态资源 - 极简静态资源
### `alembic/` ### `alembic_app/`
数据库 migration 基础设施。当前尚未迁入业务表,但迁移链路已就绪 App DB 的唯一 Alembic migration 链,同时管理 `location` / `poo_records` 表。M1 将三个独立 DB 合并进 `app.db` 后,`alembic_location/``alembic_poo/` 已退役,全部由此链统一管理
### `tests/` ### `tests/`
@@ -64,6 +73,7 @@ pytest 测试目录。后续可以在这里自然扩展:
- 当前数据库继续使用 SQLite - 当前数据库继续使用 SQLite
- 当前不引入前后端分离 - 当前不引入前后端分离
- 当前不设计 Notion 模块 - 当前不设计 Notion 模块
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
## 关于 Notion ## 关于 Notion
@@ -76,4 +86,3 @@ Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确
- 不预留 Notion 相关业务流 - 不预留 Notion 相关业务流
如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。
+120
View File
@@ -0,0 +1,120 @@
# 基础鉴权说明
本文档说明当前 Python 重构项目里已经落地的第一版鉴权基座。
这一轮只解决:
- 登录页
- 登录 / 登出流程
- server-side session
- 一个最小受保护页面
这一轮明确不解决:
- 完整 config persistence
- 完整 config CRUD
- 多用户权限系统
- OAuth / SSO / RBAC
## 当前 auth 模型
- 认证方式:`username/password`
- 会话方式:server-side session
- 客户端凭据:session cookie
- 页面形态:Jinja server-side template
## 当前持久化
当前新增一个共享 App DB
- `APP_DATABASE_URL`
- 默认值:`sqlite:///./data/app.db`
当前 auth 相关数据存放在这个 DB 中:
- `auth_users`
- `auth_sessions`
- `app_config`
当前没有把 auth 数据和 `location` / `poo` DB 混放。
当前这部分现在也走 Alembic 管理:
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
- 初始化脚本:`python scripts/app_db_adopt.py`
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
`app_config` 现在承接运行时配置持久化。
其中:
- `.env` 负责 bootstrap / fallback
- `app_config` 表负责运行时配置覆盖
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
## 首次启动与 bootstrap
如果 auth DB 中还没有任何用户,应用启动时会要求:
- `AUTH_BOOTSTRAP_USERNAME`
- `AUTH_BOOTSTRAP_PASSWORD`
并创建首个 admin 用户。
当前默认 bootstrap 值就是:
- username: `admin`
- password: `admin`
首次登录后,系统会强制要求修改密码。
如果你希望在首次启动前就覆盖默认值,可以直接设置环境变量:
- `AUTH_BOOTSTRAP_USERNAME`
- `AUTH_BOOTSTRAP_PASSWORD`
建议流程是:
1. 配好 `.env`
2. 运行 `python scripts/app_db_adopt.py`
3. 启动应用
4.`admin / admin` 首次登录
5. 立即修改密码
## 安全设计
当前这版已经落实的基础安全点:
- 密码不明文存储,使用 Argon2 哈希
- session cookie 为 `HttpOnly`
- cookie 使用 `SameSite=Lax`
- `Secure` cookie 在非 `development` 环境默认开启
- 登录表单与登出表单都有基础 CSRF 校验
- session token 为随机生成,服务端只持久化 token hash
- session 有过期时间与显式失效机制
## 当前受保护范围
当前这轮只保护了页面入口:
- `GET /config`
- `POST /config`
- `POST /config/change-password`
- `POST /logout`
相关流程:
- `GET /login`
- `POST /login`
未登录访问 `/config` 时会被重定向到 `/login`
## 下一步不在本轮内
后续可以在这个基座上继续做:
- 配置页面接入
- config persistence
- 更细的受保护路由范围
- 用户初始化 / 密码轮换的更正式 runbook
-557
View File
@@ -1,557 +0,0 @@
# 当前系统盘点
本文档用于盘点当前 branch 上的 Go 实现,并将其作为后续 Python 重构的唯一事实基线。
## 范围与基线
- 当前事实基线:`legacy/go-backend/src/` 下的 Go 代码
- 不纳入当前基线:更早的 Python 版本
- 主入口:[`legacy/go-backend/src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62)
## 系统概览
当前应用是一个单进程 Go HTTP 服务,具备以下特征:
- 暴露少量 REST API
- 使用本地 SQLite 做持久化
- 调用 Home Assistant API 和 webhook
- 通过 OAuth 和 REST API 集成 TickTick
- 当前仍依赖 Notion 做 poo 记录同步
- 内置一个每日执行的定时同步任务
进程启动后会先读取 YAML 配置文件,再初始化 Notion 与 TickTick 工具层、初始化各业务组件自己的 SQLite 数据库、注册路由、启动调度器,最后在配置的端口上提供 HTTP 服务。可参考 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) 和 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:104)。
## API 盘点
### `GET /status`
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:106)
- 用途:基础存活检查
- 请求参数:无
- 请求体:无
- 响应:纯文本 `OK`
- 鉴权:当前代码中无鉴权
- 调用方类型:通用健康检查,可能用于本地监控或 supervisor 级别的探活
### `GET /poo/latest`
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:110)
- 处理函数:[`pooRecorder.HandleNotifyLatestPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:87)
- 用途:将最新一条 poo 状态重新发布到 Home Assistant 的 sensor state
- 请求参数:无
- 请求体:无
- 响应:
- 成功:空响应体,默认 HTTP 200
- 失败:通过 `http.Error(...)` 返回文本错误信息
- 鉴权:当前代码中无鉴权
- 外部调用方:
- 会被 `POST /homeassistant/publish` 间接触发,当 `target=poo_recorder``action=get_latest` 时,代码会通过内部 HTTP 请求访问 `http://localhost:{port}/poo/latest`,见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:110)
- 副作用:
-`poo_records` 读取最新一条记录
- 调用 Home Assistant `/api/states/{entity_id}` 更新 sensor 状态,见 [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:65)
### `POST /poo/record`
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:111)
- 处理函数:[`pooRecorder.HandleRecordPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57)
- 用途:记录一条 poo 事件,同时镜像到 Notion、刷新 Home Assistant sensor,并可选触发一个 Home Assistant webhook
- 请求体 JSON
- `status: string`
- `latitude: string`
- `longitude: string`
- 请求校验:
- JSON decoder 开启了 `DisallowUnknownFields`
- 如果配置里缺少 `pooRecorder.tableId`,请求会直接失败,虽然从纯本地 DB 角度看本来仍有可能写入成功
- 响应:
- 成功:空响应体,默认 HTTP 200
- 请求错误:返回 decoder 错误文本,HTTP 400
- 服务端错误:返回错误文本,HTTP 500
- 鉴权:当前代码中无鉴权
- 外部调用方:大概率是 Home Assistant、移动端 shortcut、或手工调用;代码中没有明确写死调用方
- 副作用:
- 向 SQLite `poo_records` 插入一条记录
- 异步向 Notion 追加一行
- 同步发布最新 Home Assistant sensor 状态
- 如果存在 `pooRecorder.webhookId`,异步触发一个 Home Assistant webhook
### `POST /homeassistant/publish`
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:112)
- 处理函数:[`HomeAssistant.HandleHaMessage`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36)
- 用途:接收自动化消息,根据 `target``action` 做分发
- 请求体 JSON
- `target: string`
- `action: string`
- `content: string`
- 请求校验:
- JSON decoder 开启了 `DisallowUnknownFields`
- 响应:
- 成功路径:空响应体,默认 HTTP 200
- 失败路径:通常为空响应体并返回 HTTP 500TickTick auth 回调是单独的接口,不在这里
- 鉴权:当前代码中无鉴权
- 外部调用方:设计意图上是给 Home Assistant automation 消息调用
当前代码支持的消息契约如下:
- `target=poo_recorder`, `action=get_latest`
- 转发到本地 `GET /poo/latest`
- 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:72)
- `target=location_recorder`, `action=record`
- `content` 预期是一个 JSON 风格字符串,实际很可能使用单引号
- 当前代码会用 `strings.ReplaceAll(message.Content, "'", "\"")` 做归一化
- 然后转发到本地 `POST /location/record`
- 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82)
- `target=ticktick`, `action=create_action_task`
- `content` 预期可解析为:
- `action: string`
- `due_hour: int`
- 当前代码会忽略调用方传来的 `title` 字段,而是把 `action` 映射为 TickTick task title
- 到期时间的计算方式是:取 `now + due_hour` 后所在日期的“次日零点”,再转成 TickTick 使用的时间格式
- 最终在配置指定的 TickTick project 中创建任务
- 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:124)
不支持的 `target``action` 会返回 HTTP 500,并打 warning 日志。相关测试在 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:68)。
### `POST /location/record`
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:114)
- 处理函数:[`locationRecorder.HandleRecordLocation`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:43)
- 用途:记录人的位置点,用于人生轨迹 / movement history
- 请求体 JSON
- `person: string`
- `latitude: string`
- `longitude: string`
- `altitude: string`,从请求结构上看是可选,但代码里即使为空也会被解析成 `0`
- 请求校验:
- JSON decoder 开启了 `DisallowUnknownFields`
- 数值解析错误会被忽略;如果 `latitude` / `longitude` / `altitude` 不是合法数字,当前实现会静默落成 `0`
- 响应:
- 成功:空响应体,默认 HTTP 200
- 请求错误:返回 decoder 错误文本,HTTP 400
- 鉴权:当前代码中无鉴权
- 外部调用方:
- 可被任意客户端直接调用
- 也会被 `POST /homeassistant/publish` 间接触发
- 副作用:
- 向 SQLite `location` 表插入一条记录
### `GET /ticktick/auth/code`
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:116)
- 处理函数:[`TicktickUtilImpl.HandleAuthCode`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103)
- 用途:TickTick OAuth redirect callback
- Query 参数:
- `state`
- `code`
- 响应:
- 成功:纯文本 `Authorization successful`
- 失败:纯文本错误信息,HTTP 400 或 500
- 鉴权:
- 通过 OAuth `state` 与进程内保存的 `authState` 做校验
- 没有额外的 session 或用户级鉴权
- 外部调用方:TickTick OAuth redirect
- 副作用:
- 用 authorization code 换取 access token
- 通过 `viper.WriteConfig()``ticktick.token` 写回 YAML 配置文件
## 外部集成盘点
### TickTick
- 在 Python 重构中的状态:应保留
- 主要文件:
- [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:1)
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:100)
- 当前职责:
- 初始化 TickTick 鉴权状态
- 当 token 缺失时启动 OAuth 授权
- 接收 OAuth callback 并持久化 token
- 读取 project 下的 tasks
- 若不存在同名任务,则创建新任务
- 连接方式:
- OAuth authorization code flow
- 调用 `https://ticktick.com/oauth/token`
- 调用 `https://api.ticktick.com/open/v1/...`
- 依赖的配置项:
- `ticktick.clientId`
- `ticktick.clientSecret`
- `ticktick.redirectUri`
- `ticktick.token`
- 关键实现依赖:
- 原生 `net/http`
- `viper`,同时承担配置读取和配置回写
- 迁移高风险点:
- OAuth callback 的 `state` 只保存在进程内;如果服务在授权开始和回调完成之间重启,流程会断
- token 直接写回 YAML 配置文件,虽然简单,但运维上比较脆弱
- 去重逻辑只按 task title 精确匹配
- due date 的计算语义是隐含在代码里的,重构前应先冻结
- `Init()` 会在启动时积极检查配置,且在 token 缺失时打印手动授权 URL;Python 版需要明确是否仍要在启动阶段卡住这一流程
### Home Assistant
- 在 Python 重构中的状态:应保留
- 主要文件:
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:1)
- [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:1)
- 当前职责:
- 接收来自 Home Assistant automations 的命令 envelope
- 将命令转发给本地模块
- 把 sensor state 发布回 Home Assistant
- 在 poo 记录后触发 Home Assistant webhook
- 连接方式:
- 入站 webhook 风格 JSON 接口:`POST /homeassistant/publish`
- 出站 REST 调用:Home Assistant `/api/states/{entity_id}`
- 出站 webhook 调用:`/api/webhook/{webhook_id}`
- 出站调用使用 bearer token
- 依赖的配置项:
- `homeassistant.ip`
- `homeassistant.port`
- `homeassistant.authToken`
- `homeassistant.actionTaskProjectId`
- `pooRecorder.webhookId`
- `pooRecorder.sensorEntityName`
- `pooRecorder.sensorFriendlyName`
- 关键实现依赖:
- 原生 `net/http`
- 通过 `localhost:{port}` 发起自调用,而不是直接走函数调用
- 迁移高风险点:
- 入站 `/homeassistant/publish` 当前没有鉴权
- 当前命令 envelope 里的 `content` 是字符串,且常带单引号,现有客户端可能依赖这种非标准格式
- 模块间当前是通过自调用 HTTP 和 1 秒 timeout 编排的
- sensor 发布和 webhook 触发都属于强副作用行为,需要在兼容性测试里单独覆盖
### Notion
- 在 Python 重构中的状态:当前存在,但按已知目标不计划默认保留
- 主要文件:
- [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:1)
- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191)
- helper CLI[`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21)
- 当前职责:
- 使用 config token 初始化 Notion client
- 读取 / 写入 poo 记录对应的表格行
- 每日做 SQLite 和 Notion 的双向同步
- 提供一个反转 Notion 表顺序的辅助 CLI
- 连接方式:
- 通过 `github.com/jomei/notionapi` 调用 Notion API
- token 鉴权
- 依赖的配置项:
- `notion.token`
- `pooRecorder.tableId`
- 关键实现依赖:
- `github.com/jomei/notionapi`
- 迁移高风险点:
- 当前服务启动时如果缺少 `notion.token` 会直接退出,即便 Notion 并不是系统所有功能都需要的基础能力
- `POST /poo/record` 当前要求 `pooRecorder.tableId` 存在,并会异步镜像到 Notion
- 每日定时同步会同时改写 Notion 和 SQLite,若 Python 版移除这一行为,数据一致性预期会发生变化
## 数据库与 Schema 盘点
### 数据库类型
- 当前使用 SQLite 做组件级持久化
- poo recorder 中显式导入了 SQLite driver,见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:20)
- location recorder 也通过 driver 名 `sqlite` 打开 SQLite,见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:80)
### Poo recorder 数据库
- 配置项:`pooRecorder.dbPath`
- 默认路径:`pooRecorder.db`
- migration 机制:
- 手写 `PRAGMA user_version`
- 当前有效版本可以认为是 `1`
- 目前只实现了 `0 -> 1`
- 表:
- `poo_records`
- schema 定义见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:162)
- 字段:
- `timestamp TEXT PRIMARY KEY`
- `status TEXT NOT NULL`
- `latitude REAL NOT NULL`
- `longitude REAL NOT NULL`
- 核心用途:
- 用于查询最新 poo 状态并发布到 Home Assistant sensor
- 作为本地 poo 历史的持久化来源
- 作为 Notion 双向同步的本地数据源和数据汇
- 明显核心字段:
- `timestamp`
- `status`
- `latitude`
- `longitude`
- 可能属于历史包袱 / 后续需要再判断的点:
- 当前实现与 Notion 表行结构高度耦合
- 时间戳是字符串,格式为 `2006-01-02T15:04Z07:00`,不是带秒的完整 RFC3339
- API 请求模型接受的经纬度是字符串,因此 Python 版的类型规范化要小心兼容
### Location recorder 数据库
- 配置项:`locationRecorder.dbPath`
- 默认路径:`location_recorder.db`
- migration 机制:
- 手写 `PRAGMA user_version`
- 当前版本 `2`
- 已实现 migration
- `0 -> 1`:建表
- `1 -> 2`:把旧 datetime 字符串改写成 RFC3339 UTC
- 表:
- `location`
- schema 定义见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:115)
- 字段:
- `person TEXT NOT NULL`
- `datetime TEXT NOT NULL`
- `latitude REAL NOT NULL`
- `longitude REAL NOT NULL`
- `altitude REAL`
- 主键 `(person, datetime)`
- 核心用途:
- 持久化人生轨迹 / 位置点记录
- 明显核心字段:
- `person`
- `datetime`
- `latitude`
- `longitude`
- 可能属于历史包袱 / 后续需要再判断的点:
- `altitude` 在语义上是可选,但当前入站解析会把缺失和非法值一起压成 `0`
- 当前没有查询 API,这张表目前主要承担“只写不读”的存储角色
### 跨模块数据库观察
- 当前没有统一的共享 schema;每个组件各自打开自己的 SQLite 文件
- 没有使用 ORM
- 没有统一 migration 框架
- 除主键外,没有看到额外索引
- `poo` 的常规写入和异步 Notion 镜像之间没有事务保证,一致性更接近 best-effort
## 业务模块拆分
### 1. HTTP 外壳 / 应用启动层
- 职责:
- 读取配置
- 设置日志级别
- 管理 scheduler 生命周期
- 注册路由
- 处理优雅退出
- 主要文件:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62)
- 依赖:
- 所有业务模块
- 迁移判断:
- 这部分后续应成为 FastAPI 的 app 装配层
- 可以在早期先迁为“薄壳”
### 2. Poo recorder
- 职责:
- 接收 poo 记录
- 持久化本地 poo 历史
- 向 Home Assistant 发布最新状态 sensor
- 触发可选 Home Assistant webhook
- 与 Notion 做同步
- 主要文件:[`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:50)
- 依赖:
- SQLite
- Home Assistant util
- Notion util
- scheduler
- 迁移判断:
- 这是功能上重要、但耦合也最重的模块
- 适合在设计上先拆成:
- poo API / service
- Home Assistant 发布适配层
- legacy Notion sync adapter
### 3. Location recorder
- 职责:
- 接收位置更新
- 持久化人生轨迹点
- 主要文件:[`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:39)
- 依赖:
- SQLite
- 迁移判断:
- 相对独立
- 很适合作为优先迁移对象
- 但需要先明确数值校验规则,因为当前实现会把非法数字静默压成 `0`
### 4. Home Assistant 命令路由层
- 职责:
- 接收命令 envelope
- 根据 target/action 调度 poo、location、ticktick 行为
- 主要文件:[`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36)
- 依赖:
- 本地服务端口
- TickTick util
- 迁移判断:
- 它是外部 automations 的关键契约
- 应在迁移早中期就被冻结和复刻
### 5. TickTick adapter
- 职责:
- OAuth callback
- project / task REST 操作
- 任务去重
- 主要文件:[`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:81)
- 依赖:
- TickTick API
- 可写配置文件
- 迁移判断:
- 作为内部 adapter 相对独立
- 复杂度中等,主要难点是 OAuth 和 token 持久化
### 6. Home Assistant 出站 client
- 职责:
- 发布 sensor state
- 触发 webhook
- 主要文件:[`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:30)
- 依赖:
- Home Assistant API/token
- 迁移判断:
- 小而独立
- 很适合作为较早迁移的适配层
### 7. Notion adapter 与辅助 CLI
- 职责:
- 读写 Notion table rows
- 维护 poo 相关表格的辅助操作
- 主要文件:
- [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:14)
- [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21)
- 迁移判断:
- 当前存在,但按已知方向应视为 planned non-migration
- 更适合被标记为 legacy 模块,而不是直接带进 Python 主体
### 8. 辅助 helper CLI
- `src/helper/poo_recorder_helper`
- 用于 Notion 表反转的运维辅助工具
- `src/helper/location_recorder`
- 当前基本还是脚手架,没有实质业务逻辑
- 迁移判断:
- 二者都不是后端重构的核心目标
- `poo_recorder_helper` 应随着 Notion 一并视作 legacy
## 运行方式与部署形态
### 配置方式
- 配置文件名:`config.yaml`
- 搜索路径:
- 当前工作目录
- `$HOME/.config/home-automation`
- 配置加载代码:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65)
- 开启了 `viper.WatchConfig()`
代码中实际出现的配置项包括:
- `port`
- `logLevel`
- `notion.token`
- `ticktick.clientId`
- `ticktick.clientSecret`
- `ticktick.redirectUri`
- `ticktick.token`
- `homeassistant.ip`
- `homeassistant.port`
- `homeassistant.authToken`
- `homeassistant.actionTaskProjectId`
- `pooRecorder.tableId`
- `pooRecorder.webhookId`
- `pooRecorder.sensorEntityName`
- `pooRecorder.sensorFriendlyName`
- `pooRecorder.dbPath`
- `locationRecorder.dbPath`
### 进程模型
- 单个二进制,通过 Cobra 子命令 `serve` 启动
- 监听 `SIGINT` / `SIGTERM` 做优雅退出
- scheduler 与 HTTP server 在同一进程内运行
- 路由层使用 `gorilla/mux`
### 定时任务
- 每天 `0 5 * * *` 执行一次 poo records 与 Notion 的同步
- 定义在 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180)
### 被动接收式接口
- 上面列出的入站 REST API
- TickTick OAuth callback endpoint
### 运行时依赖的外部服务
- Home Assistant HTTP API 与 webhook
- TickTick OAuth 与 REST API
- 当前 poo 流程仍依赖 Notion API
- 本地可写文件系统,用于配置文件和 SQLite DB
### 当前本地 / 服务部署形态
- 安装脚本会构建 Go 二进制,并安装到 `$HOME/.local/home-automation-backend`
- 使用 Supervisor 管理进程
- 生成的 supervisor 配置最终执行 `{binary} serve`
- 参考:
- [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45)
- [`helper/home_automation_backend_template.conf`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/home_automation_backend_template.conf:1)
### 容器化情况
- 这一轮代码扫描中没有发现 `Dockerfile` 或 compose 文件
- 当前部署形态是 supervisor-based,而不是 container-based
## 测试与文档现状
### 测试现状
- 只发现一个测试文件:[`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1)
- 当前测试覆盖:
- 入站命令 JSON 解码
- target/action 路由分发
- 转发到 poo/location handler 的行为
- TickTick task 创建委托
- 错误日志和失败路径
- 当前没有覆盖:
- poo recorder 的 DB 行为
- location recorder 的 DB 行为
- TickTick OAuth 流程
- Home Assistant 出站发布
- Notion sync 逻辑
- 启动与配置加载
- scheduler 行为
### 本轮测试执行情况
- 我尝试在 `legacy/go-backend/src/` 下执行 `go test ./...`
- 但当前会话环境里没有安装 `go` 命令,因此无法实际运行测试
- 所以这轮关于测试的判断,基于静态阅读,而不是实际执行结果
### 文档现状
- 仓库里的 `README.md` 基本只有标题和 badge,内容非常少
- 没有用户可读的 API 文档
- 没有 schema 文档
- 没有 Home Assistant / TickTick 的契约说明文档
- 没有关于配置项、OAuth 初始化、数据库文件位置的运维文档
### 后续最需要补齐的文档
- Home Assistant 命令 envelope 与支持的 action
- 各 API 的 request / response 契约
- TickTick OAuth 初始化与 token 持久化方式
- 数据库归属、用途与保留策略
- Notion 下线 / 不迁移说明
## 对 Python 重构特别重要的事实
- 当前 API 行为整体比较“轻响应、重副作用”,很多成功请求返回的都是空响应体
- 当前所有入站 API 都没有看到鉴权
- 当前系统本地真相来源是 SQLite,但 poo 数据还同时与 Notion 同步
- `notion.token` 现在不是可选项,缺失时服务会在 `initUtil()` 阶段直接退出
- Home Assistant 命令路由当前是通过本地 HTTP 自调用实现的,而不是直接服务层调用
- TickTick callback 会改写应用本身使用的 YAML 配置文件
+127
View File
@@ -0,0 +1,127 @@
# 设计文档与多模型协作约定
本目录把 `docs/roadmap.md` 里的三个里程碑展开成**可被 coding agent 流水线执行**的详细设计文档。
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。
---
## 1. 协作模型:Orchestrator → Implementer → Reviewer
设计目标是让三个不同档位的模型分工协作:
| 角色 | 模型档位 | 职责 |
| --- | --- | --- |
| **Orchestrator(编排者)** | 强模型 | 读里程碑文档,挑出依赖已满足的下一个原子任务,派发给 implementer;收到 reviewer 的 PASS 后推进下一个任务 |
| **Implementer(实现者)** | 便宜模型 | 一次只做**一个**原子任务,严格按任务卡执行,跑完本地校验后回报 |
| **Reviewer(评审者)** | 强模型 | 对照验收标准 + Reviewer checklist 独立复核、独立跑校验闸门,返回 `PASS` 或一份编号返工清单 |
### 循环
```
Orchestrator 选任务 T(其 Depends 全部为 done
Implementer 实现 T ──► 跑校验闸门 ──► 回报 diff + 校验输出
Reviewer 复核 T
├── PASS ─────────► Orchestrator 标记 T 为 done,进入下一个任务
└── REWORK[1..n] ─► 退回 Implementer,按编号逐条修,直到 PASS
```
### 角色边界(重要)
- **Implementer 不得扩大范围**:只能改任务卡 `Files` 里列出的文件;超出范围的问题要在回报里以 `OUT-OF-SCOPE:` 标注,交给 Orchestrator 决定是否新开任务,而不是顺手改掉。
- **Reviewer 必须独立重跑校验闸门**,不能只信 implementer 的回报。
- **Reviewer 的返工清单必须可执行、带编号**(`REWORK 1: ...`),不写主观感受。
- 一个任务**不通过校验闸门就不算完成**,Orchestrator 不得跳过。
---
## 2. 原子任务的定义
一个"原子任务"必须同时满足:
1. **单一关注点**:一个任务只解决一件事(一次 schema 变更、一个端点、一个模块迁移……)。
2. **PR 大小**:理想 diff < ~200 行(结构性 sweep 任务可放宽,但应在卡上标 `[structural]` 并优先派给较强 implementer)。
3. **边界处可绿**:任务完成时,整个仓库通过校验闸门(见下)。**一个任务"拥有"它所改代码对应的测试**——如果改动会让某些现有测试失败,修这些测试就属于这个任务的范围,不允许留红给下一个任务。
4. **可独立验收**:验收标准是客观、可机械检查的断言,不依赖人的主观判断。
5. **依赖显式**:通过 `Depends` 字段声明前置任务。没有声明依赖的任务,Orchestrator 可并行派发。
---
## 3. 任务卡格式
每个任务在里程碑文档中以如下结构出现。Implementer 和 Reviewer 都只需要任务卡 + 本约定文件,**不需要读其它任务**即可工作。
```markdown
### M{n}-T{nn} — <标题> [structural?]
- **Status**: `todo` | `in-progress` | `in-review` | `done`
- **Depends**: M{n}-T{nn}, …(或 `none`
- **Context**: 1–2 句,为什么要做这个、它在里程碑里的位置。
**Files**(精确到路径,标注动作)
- `create path/to/new_file.py`
- `modify path/to/existing.py`
- `delete path/to/old.py`
**Steps**(便宜模型可直接照做的有序步骤)
1.
2.
**Out of scope / 不要碰**
- …(明确列出容易被误改的相邻区域,约束便宜模型漂移)
**Acceptance criteria**(客观、可勾选;Reviewer 逐条核)
- [ ]
- [ ] 校验闸门全绿(见 §4
**Reviewer checklist**(除验收标准外,强模型重点看的点)
-
```
---
## 4. 校验闸门(每个任务结束都要全绿)
在仓库根目录、激活 `.venv` 后执行:
```bash
# 1) 单元 / 集成测试(CI 同款,权威闸门)
pytest
# 2) Lintpyproject 已配置 ruffline-length=100
ruff check .
# 3) 若本任务改动了任何 HTTP 路由 / schema:重导出 OpenAPI 并确认已提交
python scripts/export_openapi.py
git diff --exit-code openapi/ # 必须无未提交差异
```
- `pytest` 是**权威闸门**`.github/workflows/pytest.yml` 跑的就是它,任何任务都不得让它变红。
- 改了路由 / Pydantic schema 的任务,`openapi/openapi.json``openapi/openapi.yaml` 必须在同一任务里重新生成并提交(`openapi/` 纳入版本控制)。
- 前端(M2/M3)相关任务的前端侧闸门(lint / typecheck / build)在对应里程碑文档里单独定义。
---
## 5. 提交与集成约定
- 每个任务一个 commit,message 前缀任务 ID,例如:`M1-T03: unify data layer onto single app DB engine`
- 一个里程碑在一个 feature 分支上推进(如 `feature/m1-db-consolidation`),按任务依赖顺序合并。
- 任务卡里的 `Status` 字段由 Orchestrator 维护,作为流水线的单一进度源。
- 涉及**不可逆 / 数据破坏**的步骤(删旧 DB 文件、删 Grafana volume 等)一律不进自动化任务,只在文档里标为人工步骤(见 M1 的"人工操作"小节)。
---
## 6. 数据安全红线(贯穿所有里程碑,不可违反)
1. **任何脚本 / migration 都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、确认无误的独立步骤。
2. 涉及历史数据的迁移**先在备份副本上演练**,再对真实库执行。
3. 数据迁移脚本必须**幂等**且**搬完对账行数**,对不上立即中止并非零退出。
4. 破坏性 Reviewer 一票否决:只要任务里出现"删文件 / drop 有数据的表 / truncate"Reviewer 直接 REWORK,要求改为人工步骤。
+348
View File
@@ -0,0 +1,348 @@
# M1 — 单库化地基(DB Consolidation
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本文档只展开 M1 的现状、目标与原子任务。
## 1. 目标
把 location、poo 两个独立 SQLite 库合并进 `app.db`,收敛成**单库 + 单 engine + 单 DeclarativeBase + 单 Alembic 链**,清理项目早期散落的数据层代码,并移除 Grafana。历史数据零丢失。
## 2. 现状(实现者可据此工作,不必重新通读全仓库)
**三套数据层(散落点)**
- `app/db.py``Base` + `engine`/`SessionLocal`/`get_db_session` —— 实际绑定 `settings.location_database_url`(即 location 库,命名有误导性)。
- `app/models/base.py`:仅 `from app.db import Base` 转出。
- `app/poo_db.py``PooBase` + `poo_engine`/`PooSessionLocal`/`get_poo_db_session` —— 绑定 `poo_database_url`
- `app/auth_db.py``AuthBase` + 带 `lru_cache``_get_auth_engine` / `get_auth_session_local` / `reset_auth_db_caches` / `get_auth_db_session` —— 绑定 `app_database_url`(真正的 app 库)。
**模型与归属**
- `app/models/auth.py``AuthUser``AuthSession``AuthBase` → app 库)
- `app/models/config.py``AppConfigEntry``AuthBase` → app 库)
- `app/models/public_ip.py``PublicIPState``PublicIPHistory``AuthBase` → app 库)
- `app/models/location.py``Location``Base` → location 库),表 `location`PK(`person`,`datetime`)`latitude`/`longitude` NOT NULL`altitude` nullable
- `app/models/poo.py``PooRecord``PooBase` → poo 库),表 `poo_records`PK(`timestamp`)`status`/`latitude`/`longitude` NOT NULL
- `app/models/__init__.py`:导出除 `PooRecord` 外的模型(`PooRecord` 单独存在)
**三条 Alembic 链**
- `alembic_app.ini` + `alembic_app/``env.py``AuthBase.metadata`),head = `20260429_05_public_ip_monitor`
- `alembic_location.ini` + `alembic_location/`head = `20260419_01_location_baseline`
- `alembic_poo.ini` + `alembic_poo/`head = `20260420_01_poo_baseline`
**adoption / 启动链路**
- `scripts/app_db_adopt.py`(常量 `APP_BASELINE_REVISION = "20260429_05_public_ip_monitor"`
- `scripts/location_db_adopt.py``scripts/poo_db_adopt.py`(含 legacy 校验:`EXPECTED_USER_VERSION`、表结构断言)
- `scripts/run_migrations.py`:依次调用三个 adopt 函数,返回 `{"app","location","poo"}`
- `app/main.py` lifespan`ensure_runtime_dirs`app/location/poo 三路径)、`ensure_auth_db_ready``ensure_location_db_ready``ensure_poo_db_ready`,再起 APScheduler 每 4h 检查 public IP
**依赖与路由**
- `app/dependencies.py``get_auth_db`app session)、`get_db`location session)、`get_poo_db`poo session)、`get_app_settings``get_current_auth_session``get_homeassistant_client``get_ticktick_client`
- `app/api/routes/location.py``POST /location/record`,依赖 `get_db`**无鉴权**
- `app/api/routes/poo.py``POST /poo/record``GET /poo/latest`,依赖 `get_poo_db`**无鉴权**
- `app/api/routes/homeassistant.py`:同时用 `get_db`location)和 `get_poo_db`
**config**
- `app/config.py``app_database_url` / `location_database_url` / `poo_database_url` 三字段 + computed `app_sqlite_path` / `location_sqlite_path` / `poo_sqlite_path`
- `app/services/config_page.py``build_runtime_settings` 用到 `reset_auth_db_caches`;配置页 sections 暴露 `location_database_url` / `poo_database_url`(约 263264 行)
**测试耦合点(M1 必然要改)**
- `tests/conftest.py``test_database_urls` 设三套环境变量;`ready_location_database` / `ready_poo_database` / `auth_database` / `location_client`monkeypatch `app_db.engine`/`SessionLocal`/ `poo_client`monkeypatch `poo_db.poo_engine`/`PooSessionLocal`
- `tests/test_location.py` / `tests/test_poo.py`:用上述 client + 各自 adopt 脚本的 adoption 测试
- `tests/test_deployment.py`:断言 `run_all_migrations()` 返回 `{app,location,poo}` 三库各自 revision;断言 entrypoint 不含 `*_db_adopt`
- `tests/test_homeassistant_inbound.py`monkeypatch `app.poo_db`
- `tests/test_config.py` / `tests/test_public_ip.py` / `tests/test_smtp.py`:硬编码三套 URL / 路径
- `reset_auth_db_caches``conftest``test_app``test_auth``test_deployment``test_ticktick` 引用
## 3. 目标架构(M1 完成态)
**单数据层 `app/db.py`**
```python
class Base(DeclarativeBase): ...
# 绑定 settings.app_database_url 的 cached engine;建连时启用 WALPRAGMA journal_mode=WAL
def get_engine() -> Engine: ...
def get_session_local() -> sessionmaker: ...
def reset_db_caches() -> None: ...
def get_db_session() -> Generator[Session, None, None]: ...
```
- 所有模型(auth / config / public_ip / location / poo)都继承这一个 `Base`
- 删除 `app/auth_db.py``app/poo_db.py``app/models/base.py`
- 依赖收敛为**单一** `get_db`app session);移除 `get_poo_db`、旧 `get_auth_db`
- 一条 Alembic 链(`alembic_app`),`location` / `poo_records` 成为其管理对象;删除 `alembic_location*` / `alembic_poo*`
- `config.py` 只保留 `app_database_url`;移除 location/poo 的 url 与 path。
- `docker-compose.yml` 去掉 grafana service;删除 `grafana/`
- 数据搬迁由 `scripts/migrate_legacy_data.py` 一次性完成(不进 Alembic 链)。
## 4. 任务依赖图
```
T01 (app 链建 location+poo 空表)
├─► T02 (数据搬迁脚本) # 逻辑上需要新表存在
└─► T03 [structural] (统一数据层/模型/依赖/路由)
└─► T04 (lifespan + run_migrations 收敛, 删 adopt 脚本)
└─► T05 (config 去 location/poo url + 配置页 + 测试硬编码)
T06 (删 Grafana) # 独立, 可并行
T07 (文档 + OpenAPI 重导出) # 收尾, 依赖 T03/T04/T05
```
`T01``T06` 无前置可先开;`T02` 依赖 `T01``T03` 依赖 `T01``T04`/`T05` 依赖 `T03``T07` 最后。
---
## 5. 原子任务
### M1-T01 — app 链新增 revision:建 `location` + `poo_records` 空表 `[schema]`
- **Status**: `todo`
- **Depends**: none
- **Context**: 让 app 库的 Alembic 链能建出这两张表,schema 与旧库**完全一致**。本任务只动 schema,不搬数据、不移模型。
**Files**
- `create alembic_app/versions/20260611_06_merge_location_poo_tables.py`
- `modify scripts/app_db_adopt.py`(更新 `APP_BASELINE_REVISION`
**Steps**
1. 新 revision`revision = "20260611_06_merge_location_poo_tables"``down_revision = "20260429_05_public_ip_monitor"`
2. `upgrade()``op.create_table` 手写建 `location``poo_records`,列/约束严格照抄现有 baseline(`location`: person TEXT, datetime TEXT, latitude REAL NOT NULL, longitude REAL NOT NULL, altitude REAL nullable, PK(person,datetime)`poo_records`: timestamp TEXT, status TEXT, latitude REAL NOT NULL, longitude REAL NOT NULL, PK(timestamp))。
3. `downgrade()``op.drop_table("poo_records")` + `op.drop_table("location")`
4.`scripts/app_db_adopt.py``APP_BASELINE_REVISION` 更新为新 head。
**Out of scope / 不要碰**
- 不要把 `Location` / `PooRecord` 模型改到 app Base(那是 T03)。
- 不要触碰 `alembic_location*` / `alembic_poo*`T03/T04 删)。
- 不要在本 revision 里写任何数据拷贝。
**Acceptance criteria**
- [ ] 在一个全新临时 app 库上 `command.upgrade(alembic_app head)` 后,`sqlite_master``location``poo_records`、且与旧 baseline 表结构一致(`PRAGMA table_info` 对齐)。
- [ ] `downgrade -1` 能干净回滚这两张表。
- [ ] `APP_BASELINE_REVISION == "20260611_06_merge_location_poo_tables"`
- [ ] 校验闸门全绿(`pytest``test_deployment` 对 app head 的断言仍通过,因为它用的是常量)。
**Reviewer checklist**
- 表结构与旧 baseline **逐列逐约束**一致(类型 TEXT/REAL、nullable、PK 顺序)。
- `down_revision` 正确指向旧 head,链上只有一个 head。
---
### M1-T02 — 数据搬迁脚本 `scripts/migrate_legacy_data.py`
- **Status**: `todo`
- **Depends**: M1-T01
- **Context**: 把旧 `locationRecorder.db` / `pooRecorder.db` 的行幂等拷进 app 库的新表,搬完对账。**不进 Alembic 链**,人工运行一次。
**Files**
- `create scripts/migrate_legacy_data.py`
- `create tests/test_migrate_legacy_data.py`
**Steps**
1. 入口 `migrate_legacy_data(app_url, location_url, poo_url, *, dry_run=False) -> dict`CLI 默认从 env 读三个 url(即便 location/poo url 已从 `Settings` 移除,本脚本可直接读环境变量或接受 `--location-db`/`--poo-db` 参数,保持自包含)。
2. 对每个旧库:若文件不存在 → 该表 `skipped`(**不报错**,保证 CI / 全新部署可安全 no-op)。
3. 拷贝用 SQLite `ATTACH DATABASE '<old>' AS legacy` + `INSERT OR IGNORE INTO main.<table> SELECT <显式列> FROM legacy.<table>`(显式列名,禁用 `SELECT *`)。`INSERT OR IGNORE` 保证幂等(PK 冲突跳过)。
4. 搬完对账:对每张表比对 `源行数``目标行数中来自源的部分`;目标行数 < 源行数则 `raise` 并以非零码退出。
5. `dry_run` 模式只读统计、不写入。
6. 打印每表结果:`{location: {source, copied, skipped, final}, poo_records: {...}}`
**Out of scope / 不要碰**
- **绝不** `os.remove` / 覆盖任何旧文件(数据安全红线)。
- 不修改 Alembic 链,不在 app 启动链路里调用本脚本。
- 不改 `config.py`
**Acceptance criteria**
- [ ] 单测:给定含 N 行的临时旧库 + 已 upgrade 的临时 app 库,运行后 app 库对应表有 N 行;**再运行一次**仍是 N 行(幂等)。
- [ ] 单测:旧库文件不存在时该表返回 `skipped`,不抛异常,app 库该表保持为空。
- [ ] 单测:构造"目标缺行"场景,断言对账失败抛错且退出码非零。
- [ ] 脚本中不出现任何文件删除/覆盖调用(`grep -nE "os\.remove|unlink|shutil|truncate|DROP TABLE" scripts/migrate_legacy_data.py` 为空)。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 幂等机制确实是 PK 冲突安全(`INSERT OR IGNORE` 或等价 upsert),不是靠"先清空目标"。
- 对账逻辑会在丢行时**真的中止**(非零退出),不是只打印 warning。
- 列名显式,与两表 schema 完全对应。
---
### M1-T03 — 统一数据层、模型、依赖、路由到单库 `[structural]`
- **Status**: `todo`
- **Depends**: M1-T01
- **Context**: M1 的核心 sweep。把三套 engine/Base/session 收敛成 `app/db.py` 一套(绑 app 库、开 WAL),所有模型挂到同一个 `Base`,依赖收敛为单一 `get_db`,所有路由改用它。**本任务必须原子落地**——删除旧模块会同时打断所有 importer,无法分多次保持绿色。Orchestrator 可按下方 Steps 的自然分段派给较强 implementer。
**Files**
- `modify app/db.py`(改写为统一数据层:`Base` + 绑 `app_database_url` 的 cached engine + WAL + `get_session_local` + `reset_db_caches` + `get_db_session`
- `delete app/auth_db.py`
- `delete app/poo_db.py`
- `delete app/models/base.py`
- `modify app/models/location.py``from app.db import Base`
- `modify app/models/poo.py`(改继承统一 `Base`import 改 `app.db`
- `modify app/models/auth.py``app/models/config.py``app/models/public_ip.py``AuthBase` → 统一 `Base`
- `modify app/models/__init__.py`(补导出 `PooRecord`,保证 `from app import models` 注册所有表到同一 metadata)
- `modify app/dependencies.py`(单一 `get_db`;删 `get_poo_db``get_app_settings`/`get_current_auth_session` 改用 `get_db`
- `modify app/api/routes/auth.py``pages.py``public_ip.py``ticktick.py``get_auth_db``get_db`
- `modify app/api/routes/location.py``poo.py``homeassistant.py`location/poo session 改用 `get_db`;删 `get_poo_db` 引用)
- `modify app/services/config_page.py``reset_auth_db_caches``reset_db_caches`
- `modify app/main.py``import app.auth_db as auth_db` → 统一层;`get_auth_session_local``get_session_local`
- `modify tests/conftest.py``tests/test_app.py``tests/test_auth.py``tests/test_ticktick.py``tests/test_homeassistant_inbound.py``tests/test_location.py``tests/test_poo.py`import sweep + 把 location/poo client 改成写 app 库的统一 session;移除对 `app.poo_db`/`app.db`(location) monkeypatch 的依赖)
**Steps**
1. 改写 `app/db.py``Base(DeclarativeBase)`;沿用 `auth_db.py` 的 cached-engine + reset 模式但绑 `app_database_url`;为 sqlite 连接注册 `PRAGMA journal_mode=WAL`(用 `event.listens_for(engine, "connect")` 或建连后执行)。导出 `get_engine`/`get_session_local`/`reset_db_caches`/`get_db_session`
2. 模型 sweep:所有 `from app.auth_db import AuthBase` / `from app.poo_db import PooBase` / `from app.db import Base` 统一成 `from app.db import Base`;类继承统一 `Base``app/models/__init__.py` 增加 `from app.models.poo import PooRecord` 并补进 `__all__`
3.`app/auth_db.py``app/poo_db.py``app/models/base.py`
4. 依赖 sweep`app/dependencies.py` 留单一 `get_db`yield 统一 session),删 `get_poo_db``get_app_settings``get_current_auth_session``Depends(get_auth_db)``Depends(get_db)`
5. 路由 sweep:所有 `Depends(get_auth_db)``Depends(get_poo_db)``Depends(get_db)` 统一为 `Depends(get_db)`(变量名 `auth_db_session`/`poo_db`/`db` 可保留,不强制改)。
6. `app/services/config_page.py``reset_auth_db_caches``reset_db_caches`
7. `app/main.py`:把 `_run_scheduled_public_ip_check` / `ensure_auth_db_ready` 里的 `auth_db.get_auth_session_local()` 换成统一 `get_session_local()`。(lifespan 里 location/poo 的 ready 检查留到 T04 删。)
8. 测试 sweep`reset_auth_db_caches``reset_db_caches`6 个文件);conftest 的 `location_client`/`poo_client` 改成"写入统一 app session 即可"的形式(不再 monkeypatch 已删除的 `app.poo_db`/location `app.db`);`test_homeassistant_inbound` 同理。
**Out of scope / 不要碰**
- 不删 `scripts/location_db_adopt.py` / `scripts/poo_db_adopt.py`,不改 lifespan 的 location/poo ready 调用(那是 T04,避免与本任务交叉冲突)。
- 不动 `config.py` 的字段(T05)。
- 不改业务逻辑(service 内部算法、HA 集成行为保持不变)。
**Acceptance criteria**
- [ ] `grep -rnE "auth_db|poo_db|PooBase|AuthBase|get_auth_db|get_poo_db|reset_auth_db_caches|app\.models\.base" app | grep -v __pycache__` 结果为空。
- [ ] `app/db.py` 的 engine 绑定 `app_database_url`sqlite 下 `PRAGMA journal_mode` 实测为 `wal`
- [ ] 所有模型 `Base.metadata.tables` 同时包含 auth/config/public_ip/location/poo_records 五类表。
- [ ] `pytest` 全绿(含 location/poo/homeassistant_inbound 测试在单库下通过)。
- [ ] `ruff check .` 无新增告警。
**Reviewer checklist**
- WAL 真的生效(实际连接 `PRAGMA journal_mode` 返回 `wal`),不是只写了注释。
- location/poo 的读写在单库下行为不变(端点仍返回 200、行落库)。
- 没有遗留指向已删模块的死 import;没有把业务逻辑顺手改了。
- `get_db` 现在产出的是 app 库 session(不是旧 location 库)。
---
### M1-T04 — 收敛启动链路:lifespan + run_migrations,删除 location/poo adopt 脚本
- **Status**: `todo`
- **Depends**: M1-T03
- **Context**: 单库后只需保证 app 库就绪;location/poo 的 adoption 链路整条退役。
**Files**
- `modify app/main.py``ensure_runtime_dirs` 只建 app 路径;删 `ensure_location_db_ready`/`ensure_poo_db_ready` 及其调用与 import
- `modify scripts/run_migrations.py`(只 `adopt_or_initialize_app_db`,返回 `{"app": ...}`
- `delete scripts/location_db_adopt.py`
- `delete scripts/poo_db_adopt.py`
- `delete alembic_location.ini``alembic_location/`(含 env.py、versions
- `delete alembic_poo.ini``alembic_poo/`
- `modify tests/test_deployment.py``run_all_migrations` 期望值改为单 `{"app": ...}`;删/改 legacy location/poo 迁移断言;保留"app DB 不存在则 fail-closed"用例)
- `modify tests/test_location.py``tests/test_poo.py`(删除针对已删 adopt 脚本的 adoption 测试;保留端点行为测试)
- `modify tests/conftest.py`(删 `_make_alembic_config`/`_make_poo_alembic_config`/`ready_location_database`/`ready_poo_database` 等已无意义的 fixture
**Steps**
1. `app/main.py`:移除 `from scripts.location_db_adopt ...` / `poo_db_adopt` import;删两个 `ensure_*_db_ready` 函数及 lifespan 中调用;`ensure_runtime_dirs` 只处理 `settings.app_sqlite_path`
2. `scripts/run_migrations.py``run_all_migrations` 只返回 app 一项。
3. 删除两套 adopt 脚本与两套 alembic 环境/ini。
4. 测试:把 `test_migration_runner_*` 改成单库口径;删掉引用已删脚本常量(`LOCATION_BASELINE_REVISION` 等)的用例。
**Out of scope / 不要碰**
- 不动 `scripts/app_db_adopt.py` 的核心逻辑(仅 T01 已更新其常量)。
- 不动数据搬迁脚本(T02)。
**Acceptance criteria**
- [ ] `grep -rnE "location_db_adopt|poo_db_adopt|alembic_location|alembic_poo" app scripts tests | grep -v __pycache__` 为空。
- [ ] 仓库不再有 `alembic_location*` / `alembic_poo*` 文件。
- [ ] `python -m scripts.run_migrations` 在全新临时 app 库上成功初始化(含 location/poo_records 表)。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- lifespan 仍对 app 库 fail-closed(缺库时明确报错),未弱化启动安全。
- 没有残留对已删 alembic 环境的引用(包括 `.ini` 路径字符串)。
---
### M1-T05 — config 去除 location/poo URL 与路径,清理配置页与测试硬编码
- **Status**: `todo`
- **Depends**: M1-T03
- **Context**: 配置层只剩 `app_database_url`,运行时不再有 location/poo 库概念。
**Files**
- `modify app/config.py`(删 `location_database_url`/`poo_database_url` 字段与 `location_sqlite_path`/`poo_sqlite_path` computed 属性)
- `modify app/services/config_page.py`(配置页 sections 移除 `location_database_url`/`poo_database_url` 展示项)
- `modify .env.example`(移除两行 legacy DB URL;保留 `APP_DATABASE_URL`
- `modify tests/test_config.py`(删对两个 URL/路径的断言)
- `modify tests/test_public_ip.py``tests/test_smtp.py`(构造 `Settings` 时去掉 location/poo url 入参)
- `modify tests/conftest.py``test_database_urls` 不再 set `LOCATION_DATABASE_URL`/`POO_DATABASE_URL`
**Out of scope / 不要碰**
- 不动 `migrate_legacy_data.py`(它自带读旧库路径的能力,与 `Settings` 解耦)。
- 不改其它配置项(SMTP / TickTick / HA 等)。
**Acceptance criteria**
- [ ] `grep -rnE "location_database_url|poo_database_url|location_sqlite_path|poo_sqlite_path" app tests | grep -v __pycache__` 为空。
- [ ] 配置页渲染不再出现 location/poo DB URL 字段。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 没有别的代码还假设 `Settings` 上存在这两个属性(运行期不会 AttributeError)。
---
### M1-T06 — 移除 Grafana
- **Status**: `todo`
- **Depends**: none(可与 T01 并行)
- **Context**: 可视化将由 M2 的 React 承担;Grafana 直接删除,不再 re-point。
**Files**
- `modify docker-compose.yml`(删 `grafana` service 及其 `depends_on`/挂载;删顶层 `volumes.homeautomation_grafana_storage`
- `delete grafana/``provisioning/``dashboards/` 全部)
- `modify tests/test_deployment.py`(若有针对 grafana service 的断言则同步移除)
- `modify README.md`(删"Grafana Provisioning"整节——也可并入 T07,二选一,避免重复改同段)
**Out of scope / 不要碰**
- **不在脚本里删除** named volume `homeautomation_grafana_storage` 的实际数据卷——这是人工 ops 步骤(见 §6),compose 里移除声明即可。
- 不动 app/migration service。
**Acceptance criteria**
- [ ] `docker-compose.yml` 不再含 `grafana``homeautomation_grafana_storage`
- [ ] 仓库不再有 `grafana/` 目录。
- [ ] `docker compose config` 能成功解析(语法有效)。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 没有遗留对 `./grafana/...` 挂载路径的引用。
- 没有顺手删 `./data` 卷或改动 app service 端口/卷。
---
### M1-T07 — 文档与 OpenAPI 收尾
- **Status**: `todo`
- **Depends**: M1-T03, M1-T04, M1-T05
- **Context**: 让文档反映单库现实,并把"前后端不分离 / 三库不合并 / Grafana"约束在 architecture 文档中正式退役。
**Files**
- `modify README.md`(三库 → 单库;删 location/poo DB 初始化与 adopt 说明;更新"运行测试"段落使其与实际测试一致)
- `modify docs/architecture-overview.md`(退役"三库不合并"location/poo Alembic 链合并说明)
- `modify docs/roadmap.md`(勾掉 M1 范围项)
- `run python scripts/export_openapi.py` 并提交 `openapi/` 变更(location/poo 路由依赖在 T03 改过,schema 可能变化)
**Acceptance criteria**
- [ ] README / architecture 不再描述 location/poo 独立库与 adopt 脚本。
- [ ] `python scripts/export_openapi.py``git diff --exit-code openapi/` 无未提交差异。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 文档无残留的旧命令(`location_db_adopt.py` 等)。
- OpenAPI 已重导出且入库。
---
## 6. 人工操作 runbook(生产切换,不进自动化任务)
按数据安全红线,下列步骤由人执行,**不**写进 implementer 任务:
1. **备份**:停服前复制 `data/app.db``data/locationRecorder.db``data/pooRecorder.db` 到带时间戳的归档目录。
2. **演练**:把上述备份恢复到 scratch 目录,先在副本上跑完整流程(升级 + `migrate_legacy_data.py --dry-run` 再实跑),核对行数。
3. **部署新镜像**:新镜像的 migration job 会把 app 库升级到新 head,建出空的 `location` / `poo_records`
4. **搬数据**:在生产机运行 `python scripts/migrate_legacy_data.py`(指向归档前的旧库),核对对账输出。
5. **验证**app 起来后确认 location/poo 端点与历史查询正常、行数与旧库一致。
6. **(事后,确认无误再做)撤旧库**:归档旧 `.db` 文件、删除 `homeautomation_grafana_storage` 卷。**这一步人工、可回退地保留归档,永不在脚本中自动执行。**
## 7. 里程碑完成定义(Definition of Done
- 运行期只存在 `app.db` 一个库、一个 engine、一个 `Base`、一条 Alembic 链。
- `grep` 不到任何 `auth_db` / `poo_db` / location 独立库 / adopt 脚本 / grafana 的残留引用。
- 旧库历史数据已通过 `migrate_legacy_data.py` 搬入且对账通过。
- `pytest``ruff check .``export_openapi` 全绿且 `openapi/` 已入库。
- README / architecture / roadmap 反映单库现实。
+232
View File
@@ -0,0 +1,232 @@
# M2 — 前端 v2React SPA
> 阅读前提:先读 [`README.md`](./README.md)。M2 依赖 M1 完成(单库 + 干净的数据层 + API 建立在合并后的 schema 上)。
## 1. 目标
**React SPA** 取代现有 Jinja 页面,由 FastAPI **同源**托管(同一容器、同一 origin)。一步合并 roadmap 的"前端重写"与"前端做厚":配置界面 + 数据可视化(热力图 / 地图,接管 Grafana)+ 记录的按需展示与小幅增删改。
> **元目标(agentic 实验)**:这是用 agent 写 React 的试水,全程尽量不读代码。因此本里程碑**强约束 OpenAPI → 类型化 TS client 作为契约护栏**:后端 API 先稳,前端永远对着强类型契约写,便宜模型不易跑偏,reviewer 也有客观依据。
## 2. 现状(M1 完成后)
- 页面仍是服务端 Jinja`app/api/routes/pages.py``GET/POST /config``/``/admin``POST /config/smtp/test`+ `app/templates/``base/config/home/login.html``styles.css`)。
- 鉴权:`get_current_auth_session`(读 `auth_session_cookie_name` cookie),server-side session + 每会话 `csrf_token` 内嵌在表单。
- `app/main.py``app.mount("/static", StaticFiles(...))`
- 配置读写逻辑在 `app/services/config_page.py``build_config_sections` / `save_config_updates` / `build_runtime_settings`)。
- 业务数据:单库中的 `location``poo_records``public_ip_state``public_ip_history`
## 3. 目标架构
### 3.1 后端:JSON API + SPA 托管
- 所有数据交互走 **JSON API**,统一前缀 `/api`(SPA 是客户端渲染,必须有 API——这与"同源/同容器"无关)。
- FastAPI 既挂 `/api/*`,又挂 SPA 静态产物,并对非 `/api`、非静态资源的路径**回退到 `index.html`**(支持前端路由 deep-link)。
- Jinja 页面在 SPA 达到功能对齐后移除。
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
- 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**token 属 M3)。
- CSRF:新增 `GET /api/session` 返回当前用户 + 该会话的 `csrf_token`SPA 在所有写请求(POST/PUT/PATCH/DELETE)放 `X-CSRF-Token` header,后端校验其与 session 内 `csrf_token` 一致。等价于把现有表单 CSRF 平移到 header。
- 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record``POST /poo/record`)维持现状到 M3**。
### 3.3 前端工程
- `frontend/`**Vite + React + TypeScript**。
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。
- 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策)。
- 状态/数据请求:轻量即可(如 TanStack Query),不引入重型框架。
### 3.4 构建与部署
- 多阶段 `Dockerfile`node 阶段 `npm ci && npm run build` → 把 `frontend/dist` 拷进 python 镜像的静态目录;运行镜像不带 node。
- compose 仍是单 app 容器(同源)。
## 4. API 契约(M2 要落地的端点)
> 全部 `/api` 前缀、session 保护、JSON 进出。具体 schema 在各任务里用 Pydantic 定义,并经 `export_openapi.py` 固化。
| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 会话 | `GET /api/session` | 返回当前用户 + csrf_token;未登录 401 |
| 会话 | `POST /api/auth/login` | 账号密码登录,下发 session cookie |
| 会话 | `POST /api/auth/logout` | 注销 |
| 会话 | `POST /api/auth/password` | 改密(沿用现有强制改密语义)|
| 配置 | `GET /api/config` | 返回配置 sectionssecret 不回显)|
| 配置 | `PUT /api/config` | 保存配置(留空保留旧 secret 语义不变)|
| 配置 | `POST /api/config/smtp/test` | 触发测试发信 |
| 数据 | `GET /api/locations` | location 记录查询(时间范围/分页,供地图/热力图)|
| 数据 | `GET /api/poo` | poo 记录列表(分页)|
| 数据 | `GET /api/public-ip` | 当前状态 + 变化历史 |
| CRUD | `PATCH /api/locations/{person}/{datetime}` | 修正单条 location |
| CRUD | `DELETE /api/locations/{person}/{datetime}` | 删除单条 location |
| CRUD | `PATCH /api/poo/{timestamp}` | 修正单条 poo |
| CRUD | `DELETE /api/poo/{timestamp}` | 删除单条 poo |
> 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。
## 5. 需先拍板的决策(Orchestrator 在派 T06 前确认)
1. **地图/热力图库**MapLibre GL(矢量、现代)vs Leaflet(简单、生态大)。推荐 Leaflet + `leaflet.heat`(试水门槛低)。
2. **OpenAPI client 生成物**:入库(确定性、便于 review)vs build 时生成(仓库干净)。推荐**入库**,并加一个 `npm run codegen` + CI 校验"生成物与 openapi 同步"。
3. **CSRF 落地**header `X-CSRF-Token` + `GET /api/session` 下发(推荐)vs 双提交 cookie。
4. **是否保留少量 Jinja**:建议 SPA 对齐后**全量移除** `templates/`,只留 SPA。
> 这些可用 1 个轻量"决策任务"或直接由 Orchestrator 在本节记录选择,再开 T06。
## 6. 任务依赖图
```
后端 API(可与前端 scaffold 并行)
M2-T01 config API
M2-T02 session/auth API ─┐
M2-T03 data read API ├─► 都产出 OpenAPI 契约
M2-T04 record CRUD API │
M2-T05 smtp/action API ─┘
│ (openapi 稳定后)
M2-T06 前端 scaffold + codegen ──► M2-T07 auth UI
├─► M2-T08 config UI
├─► M2-T09 可视化 UI
└─► M2-T10 records 管理 UI
M2-T11 FastAPI 托管 SPA + 移除 Jinja(依赖 T07T10 达到对齐)
M2-T12 多阶段 Dockerfile + CI/compose
M2-T13 文档 + OpenAPI 收尾
```
---
## 7. 原子任务(任务卡)
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
### M2-T01 — config JSON API
- **Status**: `todo` · **Depends**: noneM1 完成后)
- **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。
- **Files**: `create app/api/routes/api/config.py``create app/schemas/config.py``modify app/main.py`(注册路由);`create tests/test_api_config.py`
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`session 保护;secret 不回显、留空保留旧值语义照搬。
- **Acceptance**:
- [ ] 未登录访问 `GET /api/config` 返回 401。
- [ ] 登录后 `GET` 返回 sectionssecret 字段被遮罩。
- [ ] `PUT` 留空 secret 时保留旧值;非法值返回 4xx 且不写库。
- [ ] 校验闸门全绿(含 `openapi/` 重导出入库)。
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
### M2-T02 — session / auth JSON API
- **Status**: `todo` · **Depends**: none
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
- **Files**: `create app/api/routes/api/session.py``app/schemas/session.py``modify app/main.py``create tests/test_api_session.py`
- **Steps**: `GET /api/session`401 或 user+csrf)、`POST /api/auth/login``POST /api/auth/logout``POST /api/auth/password`,复用 `app/services/auth.py`
- **Acceptance**:
- [ ] 正确账号密码登录后置下 HttpOnly session cookie`GET /api/session` 返回 user + csrf_token。
- [ ] 错误凭据 401,不下发 cookie。
- [ ] 写端点缺 `X-CSRF-Token` 或不匹配 → 403。
- [ ] 强制改密语义与现有一致。
- [ ] 校验闸门全绿。
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env``SameSite=Lax`;密码仍 Argon2,不明文。
### M2-T03 — 数据读取 APIlocations / poo / public-ip
- **Status**: `todo` · **Depends**: none
- **Files**: `create app/api/routes/api/data.py``app/schemas/data.py``modify app/main.py``create tests/test_api_data.py`
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`state + history);session 保护;查询参数有上限防全表导出。
- **Acceptance**:
- [ ] 分页/时间范围参数生效且有上限;越权未登录 401。
- [ ] 返回 schema 经 OpenAPI 固化。
- [ ] 校验闸门全绿。
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
### M2-T04 — 记录 CRUD API(修正 / 删除)
- **Status**: `todo` · **Depends**: M2-T03
- **Files**: `modify app/api/routes/api/data.py``app/services/location.py``app/services/poo.py``create tests/test_api_record_crud.py`
- **Steps**: `PATCH`/`DELETE` locationPK person+datetime)与 pooPK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
- **Acceptance**:
- [ ] PATCH 改单行字段、DELETE 删单行,行数变化精确为 1。
- [ ] 不存在的 PK → 404。
- [ ] 缺 CSRF → 403。
- [ ] 没有任何"批量删/清表"路径。
- [ ] 校验闸门全绿。
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
### M2-T05 — SMTP 测试 / 动作类 JSON API
- **Status**: `todo` · **Depends**: M2-T01
- **Files**: `modify app/api/routes/api/config.py``modify tests/test_api_config.py`
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
- **Acceptance**:
- [ ] 三种结果都有明确 JSON 状态码/字段;session + CSRF 保护。
- [ ] 校验闸门全绿。
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
- **Status**: `todo` · **Depends**: M2-T01..T05OpenAPI 已稳定)
- **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。
- **Files**: `create frontend/`Vite+React+TS 脚手架、`package.json``tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md``npm run codegen` 脚本
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
- **Acceptance**:
- [ ] `npm ci && npm run build` 成功产出 `frontend/dist`
- [ ] `npm run lint``npm run typecheck``npm run test` 全绿(哪怕只有 1 个 smoke 测试)。
- [ ] `npm run codegen` 生成物与当前 `openapi/openapi.json` 一致(CI 可校验)。
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
- **Status**: `todo` · **Depends**: M2-T06
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
### M2-T08 — 配置 UI(取代 Jinja config 页)
- **Status**: `todo` · **Depends**: M2-T06
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
### M2-T09 — 数据可视化 UI(地图 + 热力图)
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03
- **Context**: 接管 Grafana 原职责:location 轨迹/热力图、poo 点位。
- **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
- **Status**: `todo` · **Depends**: M2-T06CRUD 来自 T04
- **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。
### M2-T11 — FastAPI 托管 SPA + 移除 Jinja
- **Status**: `todo` · **Depends**: M2-T07, T08, T09, T10
- **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/``app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试)
- **Acceptance**:
- [ ] `/config` 等路径返回 SPA`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。
- [ ] 旧 Jinja 模板与 pages 路由移除后 `pytest` 全绿。
- [ ] 校验闸门全绿(含 OpenAPI 重导出)。
- **Reviewer**: fallback 不拦截 `/api``/docs``/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。
### M2-T12 — 多阶段 Dockerfile + CI/compose
- **Status**: `todo` · **Depends**: M2-T11
- **Files**: `modify Dockerfile`node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新)
- **Acceptance**:
- [ ] 镜像构建成功且运行镜像不含 node 运行时。
- [ ] CI 跑前端闸门 + 后端 `pytest`
- [ ] 校验闸门全绿。
### M2-T13 — 文档 + OpenAPI 收尾
- **Status**: `todo` · **Depends**: M2-T12
- **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2`openapi/` 已同步入库。
---
## 8. 前端校验闸门(前端任务每次结束都要全绿)
`frontend/` 下:
```bash
npm ci
npm run codegen # 生成类型化 client;产物须与 openapi/openapi.json 同步
npm run lint
npm run typecheck
npm run test
npm run build # 必须产出 dist
```
- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`
- "codegen 产物与 OpenAPI 同步"应在 CI 校验(生成后 `git diff --exit-code`)。
## 9. 里程碑完成定义(DoD
- 访问应用得到 React SPA;配置、可视化、记录增删改都在 SPA 内完成。
- 所有浏览器交互走 `/api` JSON 端点,session + CSRF 保护;ingestion 裸端点维持现状(留给 M3)。
- Jinja `templates/` 与 pages 路由移除;FastAPI 同源托管 SPA。
- 多阶段镜像构建通过;CI 含前端闸门。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `build/lint/typecheck/test` 全绿。
+109
View File
@@ -0,0 +1,109 @@
# M3 — Token 鉴权与移动端(远期试水)
> 阅读前提:先读 [`README.md`](./README.md)。M3 依赖 M2(已有 `/api` JSON 契约与 session 鉴权)。
>
> **定位**:远期、低投入、探索性。React Native 部分主要是"没做过、试试水"。范围**可能收缩**——其中**token 鉴权 + ingestion 端点收口**是有持久价值的安全改进,应优先;RN app 是加分项。Orchestrator 可只取前半。
## 1. 目标
1. 引入 **bearer token 鉴权**,让非浏览器客户端(移动端、设备脚本)能安全访问。
2. 把 M2 暂时维持裸奔的 **ingestion 端点**`POST /location/record``POST /poo/record`)收口到 token 鉴权下。
3. 做一个 **React Native** 移动端,用类 OAuth 流程拿 token 后消费现有 `/api`
## 2. 现状(M2 完成后)
- `/api/*` 走 session cookie + `X-CSRF-Token`
- `app/services/auth.py` 有 server-side session`auth_sessions` 表,token_hash 存储)。
- `POST /location/record``POST /poo/record` 仍**无鉴权**(设备/脚本裸调用)。
## 3. 目标架构
### 3.1 Token 模型
- 新表 `auth_tokens``id``user_id``token_hash`(仅存哈希)、`label`(设备名)、`created_at``expires_at`(可空=长期)、`revoked_at`
- bearer 校验:`Authorization: Bearer <token>` → 哈希比对 `auth_tokens` → 命中且未撤销未过期则认定身份。
### 3.2 类 OAuth 签发流程(无第三方的 Authorization Code 简化版)
1. 移动端在**内置浏览器**打开 `/authorize?...`
2. 用户账号密码登录(走现有 session),页面展示"授权此设备"。
3. 批准后服务端生成**一次性 authorization code**,重定向到 app 深链 `homeautomation://callback?code=...`
4. app 用 code 调 `POST /api/auth/token` 换取 bearer token 并存本地。
> 简化兜底:批准页直接展示一次性 token 由 app 捕获。优先实现重定向 + code 交换的正规版。
### 3.3 统一鉴权依赖
- `/api` 的数据/CRUD 端点接受**session cookie 或 bearer**两者之一(同一套端点同时服务 Web 与移动端)。
- ingestion 端点(location/poo record)改为**要求 bearer**。
### 3.4 React Native
- **Expo + TypeScript**,复用 M2 的 OpenAPI 类型化 client(共享契约)。
- 内置浏览器走 §3.2 流程拿 token;之后所有请求带 `Authorization: Bearer`
## 4. 迁移注意(重要)
- ingestion 端点一旦要求 bearer,**现有调用方(HA/设备脚本)必须先配置 token**,否则记录会中断。
- 上线顺序:先签发 token 能力(T01–T02)→ 给现有设备配 token → 再对 ingestion 端点强制 bearer(T03),避免断流。可设一个过渡开关或灰度。
## 5. 任务依赖图
```
M3-T01 token 模型 + 迁移
└─► M3-T02 签发流程(authorize + code 交换)
└─► M3-T03 统一鉴权依赖 + ingestion 端点收口(含过渡开关)
├─► M3-T04 Web 端 token 管理 UI(列出/撤销设备)
└─► M3-T05 React Native app(试水)
└─► M3-T06 文档收尾
```
## 6. 原子任务(任务卡)
### M3-T01 — token 数据模型 + Alembic 迁移
- **Status**: `todo` · **Depends**: noneM2 完成后)
- **Files**: `create app/models/token.py``alembic_app/versions/<new>_auth_tokens.py``modify app/models/__init__.py``create tests/test_token_model.py`
- **Acceptance**:
- [ ] 迁移在全新库 upgrade 后建出 `auth_tokens` 表;downgrade 可回滚。
- [ ] token 仅以哈希存储(与 `auth_sessions` 同等强度),明文不入库。
- [ ] 校验闸门全绿。
- **Reviewer**: 哈希算法/长度与现有 session token 一致;`expires_at` 可空语义明确。
### M3-T02 — 签发流程:authorize 页 + code 交换端点
- **Status**: `todo` · **Depends**: M3-T01
- **Files**: `create app/api/routes/api/token.py``app/schemas/token.py`;前端 `/authorize` 页(M2 SPA 内);`create tests/test_api_token.py`
- **Acceptance**:
- [ ] 登录用户在 `/authorize` 批准后得到一次性 code`POST /api/auth/token` 用 code 换取 bearercode 一次性且短时效。
- [ ] 未登录访问 `/authorize` 跳登录;无效/过期 code 换取失败。
- [ ] 返回的 bearer 仅此一次明文出现,库中只存哈希。
- [ ] 校验闸门全绿。
- **Reviewer**: code 一次性、绑定用户、短 TTL;深链 redirect 白名单校验,防开放重定向。
### M3-T03 — 统一鉴权依赖 + ingestion 端点收口
- **Status**: `todo` · **Depends**: M3-T02
- **Files**: `modify app/dependencies.py`(新增"cookie 或 bearer"统一身份依赖);`modify app/api/routes/location.py``poo.py`(要求 bearer,带过渡开关);`modify tests`
- **Acceptance**:
- [ ] `/api` 数据/CRUD 端点用合法 bearer 可访问(等价于 session)。
- [ ] ingestion 端点:带合法 bearer 通过,缺/错 token 在强制模式下 401;过渡开关可临时放行(默认关)。
- [ ] 撤销的 token 立即失效。
- [ ] 校验闸门全绿。
- **Reviewer**: 过渡开关默认安全(强制);bearer 与 session 两路鉴权不产生绕过;ingestion 行为变更有测试覆盖。
### M3-T04 — Web 端 token 管理 UI
- **Status**: `todo` · **Depends**: M3-T03
- **Acceptance**: 在 SPA 内可列出已签发设备 token(label/创建时间/最近使用)、可撤销;撤销后该 token 立即失效;前端闸门全绿。
### M3-T05 — React Native app(试水)
- **Status**: `todo` · **Depends**: M3-T03 · `[experimental]`
- **Files**: `create mobile/`Expo + TS,复用 OpenAPI 类型化 client
- **Acceptance**:
- [ ] 内置浏览器走签发流程拿到 token 并安全存储(Keychain/Keystore)。
- [ ] 至少跑通:登录拿 token → 拉取一类数据展示 → 记一条 ingestion。
- [ ] `npm run lint`/`typecheck`/`build`(或 Expo 等价) 全绿。
- **Reviewer**: token 存安全存储而非明文;client 基于共享 OpenAPI 类型。
### M3-T06 — 文档收尾
- **Status**: `todo` · **Depends**: M3-T05
- **Acceptance**: README/architecture 增 token 鉴权与移动端说明;roadmap 勾选 M3;`openapi/` 同步。
## 7. 里程碑完成定义(DoD
- 存在 bearer token 鉴权与签发流程;token 仅哈希存储、可撤销。
- ingestion 端点已收口到 bearer(过渡完成后强制)。
- `/api` 同时支持 session 与 bearer。
- (加分)React Native app 能拿 token 并消费 `/api`
- 后端 + 前端 + 移动端各自校验闸门全绿,`openapi/` 入库。
> 提醒:本里程碑探索性强,T05 可作为独立试水随时叫停,不影响 T01–T04 带来的安全收口价值。
+67
View File
@@ -0,0 +1,67 @@
# Home Assistant Inbound Gateway
本文档说明当前 Python 项目中已经迁入的 Home Assistant inbound gateway 第一版。
这里的 inbound 指:
- Home Assistant 主动调用当前 app 的入口
当前已恢复的入口是:
- `POST /homeassistant/publish`
## Request Envelope
当前沿用 legacy Go 的 envelope 形状:
```json
{
"target": "location_recorder",
"action": "record",
"content": "{'person': 'alice', 'latitude': '1.23', 'longitude': '4.56'}"
}
```
说明:
- `target``action``content` 均为必填
- unknown field 会被拒绝
- `content` 当前仍兼容 legacy 常见的单引号 JSON 字符串风格
## 当前已支持的 Target / Action
当前已接回的路径:
- `location_recorder / record`
- `ticktick / create_action_task`
其中:
- `location_recorder / record` 会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑
- `ticktick / create_action_task` 会沿用 legacy 行为,把 `content` 解析为:
- `action: string`
- `due_hour: int`
- 可选 `title` 字段会被忽略
- TickTick task title 仍使用 `action`
- due date 仍按 legacy 语义计算:先取 `now + due_hour`,再落到该日期的“次日零点”,最后转成 UTC 后写给 TickTick
- 具体 project 仍由 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` 提供
## 当前尚未接回
以下 legacy 路径在当前阶段还没有迁入:
- `poo_recorder / get_latest`
- 其他未定义 target/action
这些请求当前会返回:
- `500 internal server error`
## 错误处理
当前策略保持简洁:
- envelope 非法、缺字段、unknown field、`content` 非法:返回 `400 bad request`
- target/action 当前未迁入:返回 `500 internal server error`
对 caller 的响应体保持简洁,不暴露过多内部细节;更详细原因只写日志。
+51
View File
@@ -0,0 +1,51 @@
# Home Assistant Outbound Integration
本文档说明当前 Python 项目中已经迁入的 Home Assistant outbound integration layer。
这里的 outbound 指:
- 由当前 app 主动调用 Home Assistant
当前不包含:
- `/homeassistant/publish`
- Home Assistant inbound command gateway
- Home Assistant 驱动当前 app 的入站消息路由
## 当前已支持能力
当前 `app/integrations/homeassistant.py` 提供一个轻量的 `HomeAssistantClient`,已支持:
- 发布 / 更新 sensor state
- `POST /api/states/{entity_id}`
- 触发 Home Assistant webhook
- `POST /api/webhook/{webhook_id}`
这两项能力是按 legacy Go 中 `util/homeassistantutil/homeassistantutil.go` 的出站行为迁入的。
## 当前配置
当前 outbound adapter 依赖以下配置:
- `HOME_ASSISTANT_BASE_URL`
- `HOME_ASSISTANT_AUTH_TOKEN`
- `HOME_ASSISTANT_TIMEOUT_SECONDS`
如果缺少必要配置,client 会直接抛出配置错误,而不是静默跳过。
## 错误处理策略
当前策略保持保守和简单:
- 配置缺失:抛出 `HomeAssistantConfigError`
- 参数明显非法:抛出 `ValueError`
- Home Assistant 返回非 200/201:抛出 `HomeAssistantRequestError`
- 网络请求失败:抛出 `HomeAssistantRequestError`
当前还没有做:
- 自动重试
- 熔断
- 更复杂的 backoff 策略
这一轮重点是先把 app -> Home Assistant 的出站契约和可复用结构迁进来。
+88 -8
View File
@@ -1,6 +1,13 @@
# Location Recorder # Location Recorder
本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略。 本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略,以及 legacy SQLite 接管 runbook
当前 Python 版本的 `POST /location/record` 请求校验策略是:
- `latitude``longitude` 为必填,缺失或无法解析成合法数值时返回 `400 bad request`
- `altitude` 为可选,缺失或非法时按 `0` 处理
- unknown field 仍返回 `400 bad request`
- 对 caller 的错误响应保持简洁,不直接暴露底层校验细节;详细原因只写日志
## Legacy 事实基线 ## Legacy 事实基线
@@ -31,16 +38,51 @@ PRAGMA user_version = 2;
1. 把上述 `location` schema 视为 Alembic baseline 1. 把上述 `location` schema 视为 Alembic baseline
2. 新数据库通过 Alembic `upgrade head` 初始化 2. 新数据库通过 Alembic `upgrade head` 初始化
3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,通过 `alembic stamp` 接管 3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,通过 `alembic stamp` 接管
4. 未来不再以 `PRAGMA user_version` 作为主 migration 机制 4. 如果数据库已经存在 `alembic_version`,则必须先确认当前 revision 与项目预期 baseline 一致
5. 只有 revision 一致时,才视为该库已经被正确接管
6. 未来不再以 `PRAGMA user_version` 作为主 migration 机制
当前 baseline revision 是: 当前 baseline revision 是:
- `20260419_01_location_baseline` - `20260419_01_location_baseline`
当前提供的最小脚本入口是:
```bash
python scripts/location_db_adopt.py
```
如果你更喜欢模块方式运行,也可以用:
```bash
python -m scripts.location_db_adopt
```
它只针对 `LOCATION_DATABASE_URL` 工作,并且遵守保守接管原则:
- 本地已有 DB 文件:先校验,再接管
- 本地没有 DB 文件:按新库初始化
- 任一校验不通过:立即报错并停止
应用本身在启动时不会自动替你初始化 `location` 数据库。
应用启动时会对 `LOCATION_DATABASE_URL` 做只读校验:
- 文件不存在:直接报错,并提示先运行接管脚本
- 文件存在但还没有 `alembic_version`:直接报错,要求先完成 legacy 接管
- 文件已被 Alembic 管理但 revision 不匹配:直接报错并拒绝启动
这是有意为之,用来避免应用在错误路径上静默创建新库,或带着错误数据库版本继续跑业务。
## 新数据库初始化 ## 新数据库初始化
对于一个全新 SQLite 数据库,执行 如果本地不存在 `LOCATION_DATABASE_URL` 指向的 DB 文件
- 脚本会先创建父目录
- 然后执行 Alembic `upgrade head`
- 最终建立 `location` 表与 `alembic_version`
手工执行时也等价于:
```bash ```bash
alembic upgrade head alembic upgrade head
@@ -52,9 +94,15 @@ alembic upgrade head
对于已经存在的 legacy SQLite 数据库: 对于已经存在的 legacy SQLite 数据库:
1. 先确认`location` 表 schema 与 baseline 一致 1. 先确认 DB 文件存在
2. 旧库里的 `PRAGMA user_version = 2` 仅视为历史事实,不再继续沿用 2. 如果已经存在 `alembic_version` 表,则先读取当前 revision
3. 确认无误后,对该数据库执行 `stamp`,而不是重新跑创建表 migration 3. 如果 revision 等于 `20260419_01_location_baseline`,则视为该库已经被 Alembic 正确接管
4. 如果 revision 不匹配,立即报错并停止,不做任何自动修复
5. 如果还没有 `alembic_version` 表,则读取当前 DB 中 `location` 表的实际 schema
6. 与 baseline schema 做严格比对
7. 再检查 `PRAGMA user_version`
8. 只有 schema 匹配且 `user_version = 2` 时,才执行 Alembic `stamp`
9. 接管完成后,后续 migration 才交给 Alembic 管理
示例: 示例:
@@ -62,12 +110,38 @@ alembic upgrade head
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 20260419_01_location_baseline LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 20260419_01_location_baseline
``` ```
或直接执行脚本:
```bash
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db python scripts/location_db_adopt.py
```
这样做的含义是: 这样做的含义是:
- 告诉 Alembic:这个数据库已经处于 baseline 结构 - 告诉 Alembic:这个数据库已经处于 baseline 结构
- 不修改已有 `location` 表数据 - 不修改已有 `location` 表数据
- 后续 migration 由 Alembic 接管 - 后续 migration 由 Alembic 接管
## Fail Closed 原则
当前策略是保守接管,不做未知 legacy 状态的自动修复。
如果出现以下任一情况,脚本会直接报错并停止:
- 找不到 `location`
- `location` 表 schema 与 baseline 不一致
- `PRAGMA user_version` 不等于 `2`
- 已有 `alembic_version`,但 revision 与预期 baseline 不一致
- 目标 DB 不是 SQLite URL
当前不会尝试:
- 自动修表
- 自动调整 `user_version`
- 自动推断未知 legacy 状态
如果发生这些情况,应先人工确认数据库状态,再决定是否需要单独迁移或修复。
## 关于 `data/locationRecorder.db` ## 关于 `data/locationRecorder.db`
你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于: 你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于:
@@ -91,6 +165,12 @@ LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 2026041
- 构造一个“legacy 风格”的临时 SQLite 文件 - 构造一个“legacy 风格”的临时 SQLite 文件
- 建出同样的 `location` - 建出同样的 `location`
- 设置 `PRAGMA user_version = 2` - 设置 `PRAGMA user_version = 2`
- 再执行 Alembic `stamp` - 再执行接管脚本中的 adopt 逻辑
同时也覆盖:
- DB 文件不存在时的新库初始化路径
- schema 不匹配时的失败路径
- `user_version` 不匹配时的失败路径
这样可以验证接管路径,同时不污染真实样本库。 这样可以验证接管路径,同时不污染真实样本库。
-79
View File
@@ -1,79 +0,0 @@
# Migration Notes
本文档记录 Python skeleton 阶段的迁移说明,帮助后续继续推进时快速恢复上下文。
## 当前阶段完成内容
- 建立 FastAPI 应用骨架
- 建立环境变量配置体系
- 接入 SQLAlchemy 与 Alembic
- 建立 Jinja2 模板基础
- 建立 pytest 基础设施
- 建立 Docker / Compose 基础骨架
- 建立 OpenAPI 导出脚本
- 迁入 `location recorder` 第一版
## 数据库配置现状
当前系统在配置层上已明确保留两个独立 SQLite DB 文件:
- `LOCATION_DATABASE_URL`
- `POO_DATABASE_URL`
当前阶段不打算把这两个数据库合并。
其中:
- `location` 模块已经实际接到 `LOCATION_DATABASE_URL`
- `poo` 目前只保留 `POO_DATABASE_URL` 配置占位,等待模块迁入
## 当前阶段未做内容
- 未迁移 TickTick 业务逻辑
- 未迁移 Home Assistant 业务逻辑
- 未迁移 poo records
- 未实现真实 OAuth 流程
- 未做数据迁移
## Location recorder 说明
当前 Python 项目已经接入 `POST /location/record`,并对齐 legacy SQLite schema
```sql
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime)
);
```
当前已经补上最小 Alembic baseline / 接管策略:
- `location` 当前 schema 被视为 Alembic baseline
- 新数据库通过 `alembic upgrade head` 初始化
- 已有 legacy SQLite 数据库通过 `alembic stamp` 接管
- `PRAGMA user_version = 2` 仅保留为历史事实,不再作为新的主 migration 机制
详见:
- [location-recorder.md](location-recorder.md)
## 后续建议顺序
建议继续沿用既有迁移文档中的顺序:
1. 先迁 `location recorder`
2. 再迁 Home Assistant 出站适配层
3. 再迁 TickTick adapter
4. 再迁 Home Assistant 命令网关
5. 最后迁 `poo recorder`
## 开发约束提醒
- 保持对当前 Go 外部行为的兼容意识
- 不要把旧 Python 版本当作设计基线
- 不要重新引入 Notion 作为 Python 主系统能力
- 在迁业务模块时,优先补 contract tests
-238
View File
@@ -1,238 +0,0 @@
# 迁移风险清单
本文档列出将当前 Go 后端重构为 Python 时的主要风险点。这里的风险判断,默认都是以“当前 Go 行为需要尽量兼容”为前提。
## 最高风险区域
### 1. `POST /homeassistant/publish` 存在隐式行为契约
风险:
- 当前 Home Assistant 网关使用 `target``action``content` 这种 envelope
- `content` 不是标准嵌套 JSON,而是字符串化 payload
- 有些 payload 明显依赖单引号 pseudo-JSON,再在代码中做归一化
为什么重要:
- 如果 Python 版过早改成“严格、干净、标准化的嵌套 JSON”,现有 Home Assistant automations 可能直接失效
当前证据:
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82)
- 测试见 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:129)
缓解建议:
- 在重构前先收集真实线上 payload 样例
- 第一阶段保留兼容性解析
- 为所有当前支持的 `target/action` 增加回归测试
### 2. TickTick OAuth 与 token 持久化流程
风险:
- OAuth `state` 当前只保存在进程内
- token 获取后会直接写回 YAML 配置文件
为什么重要:
- Python 重构很容易在不自觉的情况下改变操作流程
- token 持久化语义一变,可能会带来难排查的鉴权失败
当前证据:
- callback 与配置回写逻辑见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103)
- 授权 URL 初始化见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:275)
缓解建议:
- 在编码前先确定 token storage 方案
- 保持 callback 契约稳定
- 在 staging 环境用真实 TickTick app 做端到端验证
### 3. Poo recorder 的副作用比 API 表面看起来更复杂
风险:
- `POST /poo/record` 不只是写一条 DB
- 它还会镜像到 Notion、发布 Home Assistant sensor、并且可能触发 Home Assistant webhook
为什么重要:
- 即使 Python 版 API 看起来兼容,如果漏掉这些副作用,也会导致真实自动化行为偏差
当前证据:
- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57)
- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:97)
- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:339)
缓解建议:
- 把 endpoint 契约和 side-effect 契约分开写清楚
- 在 Python 中通过显式 service / adapter 接口承接这些行为
- 用 mock Home Assistant / TickTick / Notion 的方式做测试
### 4. 移除 Notion 会改变当前运行预期
风险:
- Notion 虽然已经被识别为“不计划继续保留”,但它现在并不是边缘代码
- 它当前参与启动、请求处理以及每日同步
为什么重要:
- 去掉 Notion 会实质改变数据流
- 也可能影响历史镜像、人工运维方式以及启动要求
当前证据:
- 启动时强依赖 `notion.token`,见 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:41)
- 每日同步逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191)
- helper CLI 见 [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21)
缓解建议:
- 在文档里显式说明 Notion 将被下线 / 不迁移
- 先决定是否需要一次性历史导出或回填
- 确保移除 Notion 后,`pooRecorder.tableId``notion.token` 不再阻塞服务启动
## 中等风险区域
### 5. SQLite 兼容性与时间戳格式
风险:
- 当前代码把时间戳以文本形式存储
- `location``poo` 两个模块使用的时间格式并不相同
为什么重要:
- Python 重构若擅自统一时间格式,可能会破坏旧 DB 的可兼容读取
当前证据:
- poo 时间戳写入逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:344)
- location 时间戳写入逻辑见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:61)
缓解建议:
- 先决定 Python 第一阶段是否直接复用现有 DB 文件
- 如果要复用,就要保留当前时间戳序列化行为
- 为数据层建立回归样例
### 6. 输入校验行为可能与 FastAPI 默认习惯冲突
风险:
- FastAPI / Pydantic 通常更倾向严格校验
- 当前 Go 代码的校验行为并不一致:
- 有些接口拒绝 unknown fields
- `location` 数值解析错误会被静默忽略
- 很多成功响应是空响应体
为什么重要:
- 更“正确”的校验,也可能是破坏兼容性的改动
当前证据:
- 多个 handler 都开启了严格字段检查
- `location` 的静默 float parsing 见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:54)
缓解建议:
- 先明确哪些怪异行为是必须兼容的,哪些可以修正
- 第一阶段如果要保持兼容,可以在 Python 里用自定义校验逻辑模拟当前行为
### 7. 定时任务行为漂移
风险:
- 当前应用内嵌了一个每天 `0 5 * * *` 执行的 Notion 同步任务
为什么重要:
- 如果 Python 版仍保留类似行为,时区、执行时机、幂等性处理差异都可能导致重复或漏同步
当前证据:
- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180)
缓解建议:
- 如果 Notion 被移除,就应有意识地同步移除 scheduler 相关逻辑,并写清楚原因
- 如果在过渡期暂时保留,就要明确 timezone 与幂等语义
### 8. self-HTTP 编排改成 direct service calls 的差异
风险:
- 当前 Home Assistant 网关通过调用 `localhost` 上的本地接口来驱动其它模块
为什么重要:
- 改成直接函数 / service 调用本身是合理的,但可能改变 timeout、错误传播和日志行为
当前证据:
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:88)
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:115)
缓解建议:
- 对外 HTTP 行为保持不变
- 但在内部重写后,补足状态码和失败语义测试
## 风险较低但仍重要的区域
### 9. 部署模型变化
风险:
- 当前部署方式是 supervisor-based
- 未来目标是容器化
为什么重要:
- 启动文件路径、配置文件写入位置、token persistence 方式,在容器环境下都可能出问题
当前证据:
- 安装脚本见 [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45)
缓解建议:
- 把运行时状态从镜像内容中解耦
- 预先定义 DB/config 是否需要挂载 volume
- 在 cutover 前先写清楚 container env vars 与文件挂载约定
### 10. 现有测试过少
风险:
- 当前大多数模块没有自动化测试
为什么重要:
- 没有安全网时,重构很容易改坏行为而不自知
当前证据:
- 当前只发现 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1)
缓解建议:
- 把 contract test 建设视作迁移工作的一部分,而不是迁移后的补票
- 每迁一个模块,就同步补该模块的测试
## 总结
这次重构最大的风险,不是“Go 改 Python”本身,而是几个隐藏得很深的行为契约:
- Home Assistant 命令 payload 的真实格式
- TickTick 的 OAuth / token 生命周期
- poo recorder 的一组副作用行为
- 当前仍活跃、但计划下线的 Notion 耦合
只要先把这些契约写清楚、测清楚,再开始 Python 实现,整个重构路线就会可控很多。
+140
View File
@@ -0,0 +1,140 @@
# Poo Recorder
本文档说明 `poo recorder` 在 Python 项目中的当前行为边界,以及 poo SQLite 的 Alembic 接管策略。
## 当前基线
当前生产版本中的真实 SQLite schema 为:
```sql
CREATE TABLE poo_records (
timestamp TEXT NOT NULL,
status TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
PRIMARY KEY (timestamp)
);
```
历史上 legacy Go 实现使用:
```sql
PRAGMA user_version = 1;
```
当前 Python 迁移以这套 schema 为事实基线,不重新设计表结构。
## 当前已迁入的 API
当前 Python 项目已经接入:
- `POST /poo/record`
- `GET /poo/latest`
### `POST /poo/record`
用途:
- 记录一条 poo event
- 最佳努力地刷新 Home Assistant sensor
- 如果配置了 `POO_WEBHOOK_ID`,最佳努力地触发 Home Assistant webhook
请求体:
```json
{
"status": "done",
"latitude": "1.23",
"longitude": "4.56"
}
```
当前策略:
- unknown field`400 bad request`
- 数值非法:`400 bad request`
- 记录成功后,即使 Home Assistant side effect 失败,也不会回滚本地 DB 写入
### `GET /poo/latest`
用途:
- 读取最新一条 poo 记录
- 将其重新发布到 Home Assistant sensor
当前外部行为与 legacy 保持一致:
- 成功:空响应体,HTTP 200
- 如果当前 DB 里还没有任何 poo 记录:仍返回空响应体,HTTP 200,但不会发布 sensor
- 真正的发布失败:简洁 `internal server error`
## Home Assistant side effects
当前已复用 Python 项目中已有的 Home Assistant outbound adapter。
当前支持:
- 发布 / 更新 poo status sensor
- 可选触发 webhook
相关配置:
- `HOME_ASSISTANT_BASE_URL`
- `HOME_ASSISTANT_AUTH_TOKEN`
- `HOME_ASSISTANT_TIMEOUT_SECONDS`
- `POO_SENSOR_ENTITY_NAME`
- `POO_SENSOR_FRIENDLY_NAME`
- `POO_WEBHOOK_ID`
## Alembic 接管策略
poo 的接管逻辑刻意保持与 location 一致。
当前 baseline revision
- `20260420_01_poo_baseline`
当前提供的脚本入口:
```bash
python scripts/poo_db_adopt.py
```
或:
```bash
python -m scripts.poo_db_adopt
```
规则如下:
1. 如果本地不存在 poo DB 文件:
- 视为新库初始化
- 通过 `alembic_poo upgrade head` 创建新库
2. 如果本地已经存在 legacy DB:
- 先检查 `poo_records` 表 schema
- 再检查 `PRAGMA user_version = 1`
- 只有完全匹配,才通过 Alembic `stamp` 接管
3. 如果 schema 或 `user_version` 不匹配:
- 直接失败
- 不自动修复
4. 如果数据库已经存在 `alembic_version`
- 只有 revision 与当前 baseline 一致才接受
- 否则直接失败
同时,应用启动时也会对 `POO_DATABASE_URL` 做只读校验:
- 文件不存在:拒绝启动
- DB 尚未被 Alembic 接管:拒绝启动
- revision 不匹配:拒绝启动
## 明确移除 Notion
这一轮不会迁入任何 Notion 逻辑。
也就是说,当前 Python 版的 poo recorder
- 不保留 Notion adapter
- 不保留 Notion sync
- 不保留 `tableId` 依赖
- 不因为 legacy 中存在 Notion 就继续保留兼容层
+126
View File
@@ -0,0 +1,126 @@
# Public IPv4 Monitor 与邮件通知
本文档说明当前 public IPv4 monitor 与 SMTP 邮件通知能力的职责边界和运行方式。
## 当前范围
当前实现只覆盖一个很小的通知能力:
- 定期或手动检查当前公网 IPv4
- 将当前状态和变化历史持久化到 app DB
- 仅在公网 IPv4 发生变化时发送一封英文纯文本邮件
当前明确不包含:
- Namecheap API 自动更新
- IPv6 检查
- 错误告警邮件
- 重复提醒 / 升级告警
- Telegram / Slack / Discord 通知
- 完整通知中心或模板系统
## 数据存储
当前数据全部进入 app DB。
相关表:
- `public_ip_state`
- 保存当前状态
- 逻辑上通常只有一行
- `public_ip_history`
- 保存首次发现和变化历史
当前不会把 public IP 状态放进 `app_config`
## 检查结果语义
一次检查会返回以下四种结果之一:
- `first_seen`
- `unchanged`
- `changed`
- `error`
行为约束:
- `first_seen`:写入当前 IP 和首条 history,但不发通知邮件
- `unchanged`:只更新时间和状态,不写 history,不发邮件
- `changed`:更新 `previous_ipv4` / `current_ipv4` / `last_changed_at`,写入 history,并发送邮件
- `error`:保留已有有效 IP,不写伪 history,也不发邮件
## 手动检查与定时检查
手动检查入口:
- `GET /public-ip/check`
约束:
- 需要现有鉴权
- 响应不暴露 IP 本身
- 只返回非敏感检查结果
定时检查:
- 应用启动时注册 APScheduler job
- 默认每 4 小时执行一次
- 与手动检查复用同一套 public IP check + notify 逻辑
## SMTP 通知
当前通知发信复用现有 SMTP sender。
依赖的配置项:
- `SMTP_ENABLED`
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_USERNAME`
- `SMTP_PASSWORD`
- `SMTP_FROM_NAME`
- `SMTP_FROM_ADDRESS`
- `SMTP_TO_ADDRESS`
- `SMTP_USE_STARTTLS`
其中:
- `SMTP_FROM_NAME` 用于邮件头显示名
- `From` 头会渲染成 `Name <mail@domain>`
- SMTP envelope sender 仍然使用纯邮箱地址,保持兼容性
## 通知触发条件
只有在 `changed` 时发邮件。
不会发邮件的情况:
- `first_seen`
- `unchanged`
- `error`
这使得同一 IP 状态不会被重复通知,因为在首次变更之后,后续重复检查会变成 `unchanged`
## 邮件内容
当前邮件标题固定为:
- `Public IP changed`
正文为英文纯文本,至少包含:
- previous IP
- current IP
- detected time
当前正文还会附带一句 Namecheap trusted IP 的人工更新提示。
## 失败处理
当前通知发送是“尽力而为”的附加动作:
- public IP 状态持久化先完成
- 邮件发送失败不会回滚 public IP 状态
- 失败只记录 warning 日志
这样可以避免通知链路反过来影响主检查流程。
-314
View File
@@ -1,314 +0,0 @@
# Python 重构方案
本文档基于当前 Go 实现,给出迁移到 Python + FastAPI 的设计输入与建议顺序。本文档只讨论迁移方案,不代表已经开始实现。
## 重构原则
- 以当前 Go 实现为唯一事实来源
- 先保持对外行为兼容,再考虑内部清理和优化
- 明确区分“行为兼容”和“内部实现升级”
- 默认不把 Notion 纳入新的 Python 主版本目标,除非后续重新决策
- 尽量按模块迁移,并在模块之间建立清晰契约
## 目标形态
建议的 Python 目标架构:
- 用 FastAPI 提供 HTTP 路由,并自然生成 OpenAPI
- 业务逻辑按模块拆分到 service layer
- 外部系统对接放到 adapter layer
- SQLite 访问放到 repository layer
- 配置采用显式 settings model
- scheduler 是否内嵌在应用进程内,需要在启动语义明确后再决定
- 仅保留极轻量的服务端页面,用于 OAuth 跳转或简单配置
## 建议的 Python 模块边界
### 应用外壳层
- 职责:
- 读取 settings
- 依赖注入与对象装配
- 路由注册
- lifespan hooks
- scheduler 启停
- 在 FastAPI 中可对应:
- `main.py` 或 app factory
- settings 类
### Poo 领域模块
- 职责:
- 校验 poo record 输入
- 持久化 poo record
- 查询 latest poo
- 通过接口触发外部副作用
- 建议把副作用依赖抽象为端口:
- `PooRepository`
- `HomeAssistantPublisher`
- `HomeAssistantWebhookClient`
- 如有需要,可保留临时 `LegacyPooMirror` 作为 Notion 过渡适配器
### Location 领域模块
- 职责:
- 校验并持久化位置点
- 可保持简洁:
- `LocationRepository`
- `LocationService`
### Home Assistant 命令网关
- 职责:
- 暴露 `/homeassistant/publish`
- 解析 `target/action/content`
- 将命令分发到内部服务
- 兼容性注意:
- 第一阶段应保留当前 `content` 的处理习惯,包括对字符串 payload 的兼容解析
### TickTick 集成模块
- 职责:
- OAuth start / callback
- token 存储
- task 查询
- 去重
- task 创建
- 建议:
- 把 token persistence 抽象成独立能力,而不是把“改写配置文件”直接塞进业务逻辑
### Home Assistant 出站适配层
- 职责:
- 发布 sensor state
- 触发 webhook
- 该层小而独立,适合较早迁移
### Legacy Notion 适配层
- 职责:
- 只在分析或过渡阶段表示当前行为
- 默认建议:
- 不放入 Python 第一版正式目标
- 如 cutover 期间确有需要,可以 feature flag 或独立迁移工具的方式暂存
## 实现前需要冻结的兼容契约
在正式编码前,建议先把以下当前行为写成明确契约:
### API 契约
- `POST /poo/record` 的请求字段与当前“成功时空响应体”的行为
- `POST /location/record` 的请求字段与数值解析行为
- `POST /homeassistant/publish` 的 envelope 格式与支持的 `target/action`
- `GET /ticktick/auth/code` 的成功 / 失败语义
### 副作用契约
- `POST /poo/record` 在什么时机会发布 Home Assistant sensor
- `POST /poo/record` 在什么条件下会触发 Home Assistant webhook
- Home Assistant 消息如何映射为 TickTick task title 与 due date
- Home Assistant sensor payload 的结构
### 持久化契约
- 当前 SQLite 表名与主键
- 当前磁盘上的时间戳格式
- Python 第一阶段是否直接复用现有 DB 文件,还是做显式迁移
## 建议的迁移决策
### 决策 1:第一阶段保持对外 API 形状不变
原因:
- 当前 API 面很小
- 保持兼容能显著降低切换风险
- 即使保持兼容,FastAPI 仍然可以生成 OpenAPI 文档
### 决策 2:把内部 self-HTTP 改成直接服务调用
原因:
- 当前 Go 代码中的 `localhost` 自调用,本质上是内部编排手段
- 这不是一个必须暴露给外部的契约
- Python 版改为直接函数 / service 调用,可以提升清晰度和可测试性
### 决策 3:先继续使用 SQLite
原因:
- 当前系统已经使用 SQLite
- 数据模型规模很小
- PostgreSQL 更适合作为 parity 之后的下一阶段演进
### 决策 4:默认不迁 Notion,但要明确记录影响
原因:
- 你已经明确表示 Notion 很可能不继续保留
- 当前 Notion 不是“代码里有但没在用”,而是真正参与运行逻辑
- 所以不能静默删除,而要在方案中写清楚删掉后有什么影响
### 决策 5:把 token / auth persistence 做成显式设计
原因:
- 当前 TickTick token 处理虽然可用,但运维上比较脆弱
- Python 重构是一个把这件事规范化的机会
## 建议迁移顺序
### Phase 0:盘点与契约确认
- 完成当前系统 inventory
- 确认哪些当前行为是“必须兼容的契约”,哪些只是历史偶然实现
- 明确把 Notion 标为 non-migration scope,除非后续重新决定
### Phase 1Python 骨架与通用基础设施
- 建立 FastAPI app shell
- 定义 settings / config model
- 定义日志方案
- 定义 SQLite 访问方式
- 定义测试框架与 fixture 策略
- 定义 OpenAPI 生成与导出方式
这一阶段不需要大量迁移业务逻辑,只要搭好后续模块可持续迁入的基础即可。
### Phase 2:先迁最独立、最稳定的业务模块
推荐优先迁移:`location recorder`
原因:
- 独立 SQLite 表
- 没有复杂外部副作用
- 没有 OAuth
- 没有 scheduler
这一阶段的交付物可以包括:
- `POST /location/record`
- 与现有 SQLite 兼容的写入逻辑
- 校验与 repository 的单元测试
- 基于临时 SQLite 的 integration test
### Phase 3:迁移 Home Assistant 出站适配层
原因:
- 功能面小
- 能为后面的 poo 迁移做铺垫
这一阶段的交付物可以包括:
- sensor publish client
- webhook trigger client
- 针对请求格式与错误处理的 mock tests
### Phase 4:迁移 TickTick adapter
原因:
- 相对自洽
- 在完成 Home Assistant 命令网关前就需要它
这一阶段的交付物可以包括:
- OAuth callback endpoint
- token persistence abstraction
- task 创建与去重行为
- 基于 mock HTTP 的集成式测试
### Phase 5:迁移 Home Assistant 命令网关
原因:
- 这是外部 automations 的核心编排入口
- 在 location 与 TickTick adapter 准备好后,网关迁移会顺很多
这一阶段的交付物可以包括:
- `/homeassistant/publish`
- 兼容当前 `target/action` 的分发逻辑
- 用进程内 service 调用替代 self-HTTP
- 把现有 Go 测试场景迁成 Python contract tests
### Phase 6:迁移 poo recorder 核心,但默认不带 Notion
原因:
- 这是最复杂的模块
- 它既有本地 DB,又有 Home Assistant 副作用,当前还耦合 Notion
建议拆成两个子阶段:
- phase 6a
- 本地 poo DB
- latest poo 查询
- sensor publish
- 可选 webhook trigger
- `/poo/record`
- `/poo/latest`
- phase 6b
- 如果 cutover 期间必须保留旧逻辑,再做一个临时 legacy Notion 兼容层
### Phase 7:运维加固与切换验证
- 做 Go / Python 路由级契约比对
- 用现有 SQLite 文件或其副本做兼容验证
- 在 staging 环境手动验证 TickTick OAuth
- 用真实 Home Assistant automation payload 做验证
- 导出 OpenAPI YAML
- 再补容器化与部署方案
## 哪些模块适合先迁,哪些适合后迁
### 适合优先迁移
- location recorder
- Home Assistant 出站 client
- TickTick adapter
### 更适合后迁
- Home Assistant 命令网关
- poo recorder 核心
### 现状存在,但建议不迁
- Notion sync adapter
- `poo_recorder_helper` 的 Notion 表反转 CLI
- `location_recorder` helper CLI 脚手架
## 建议的验证策略
### Contract tests
- 基于当前 Go 行为建立 request / response fixtures
- 先把现有 `homeassistant` 测试案例迁成 Python
- 补上 `poo``location` API 的契约测试
### Integration tests
- 每个模块使用临时 SQLite DB
- Home Assistant 与 TickTick 出站流量通过 mock HTTP 替代
- 若仍保留 scheduler,则为其补定时行为测试
### 手工 staging 验证
- TickTick OAuth callback
- Home Assistant sensor 更新
- Home Assistant webhook 触发
- 当前真实自动化 payload 样例
## 开始实现前仍需明确的问题
- Python 第一阶段是否还要保留“缺少 `notion.token` 就启动失败”的行为,还是直接把 Notion 变成可关闭能力?
- `POST /location/record` 是否要继续保留“非法数字静默变成 0”的兼容行为?
- TickTick token 在第一阶段是否继续写回 YAML,还是立即切到独立 token store
- 当前 Home Assistant automations 是否真实依赖 `content` 中的单引号 pseudo-JSON
这些问题不影响当前 inventory,但会影响第一阶段“兼容到什么程度”的具体定义。
+148
View File
@@ -0,0 +1,148 @@
# Roadmap
本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
> 每个里程碑的**可执行原子任务**展开在 [`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)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
## 当前基线(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。
## 里程碑总览
| 里程碑 | 主题 | 一句话 |
| --- | --- | --- |
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
| **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。
---
## 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。
---
## 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。
+43
View File
@@ -0,0 +1,43 @@
# TickTick Integration
当前 Python 项目里的 TickTick 迁移先恢复 legacy 的最核心能力,不额外扩成更大的集成层。
## 当前已支持
- 运行时从 config 表读取 TickTick 配置,缺失时仍可 fallback `.env`
- `GET /ticktick/auth/start`
- 需要已登录 session
- 生成 OAuth `state`
- 直接重定向到 TickTick 授权页
- `GET /ticktick/auth/code`
- 校验进程内保存的 `state`
- 用 authorization code 换取 access token
-`TICKTICK_TOKEN` 持久化到 `app_config`
- TickTick Open API 基础调用:
- 列 project
- 列 project 下 task
- 创建 task
- 按 title 精确匹配做重复创建保护
- Home Assistant inbound 已重新接回 `ticktick / create_action_task`
## 当前配置项
- `APP_HOSTNAME`
- `TICKTICK_CLIENT_ID`
- `TICKTICK_CLIENT_SECRET`
- `TICKTICK_TOKEN`
- `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
## 兼容性说明
- 仍保留 legacy 的 OAuth authorization code flow
- OAuth callback URI 现在由 `APP_HOSTNAME` 和当前环境自动推导:`development` 使用 `http`,其他环境使用 `https`
- `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始
- 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表
- 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为
## 后续适合单独拆分的工作
- 给 config 页面增加明确的 TickTick 授权入口
- 增加 project 探测或选择能力,减少手工填写 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
- 如果后续发现 OAuth/token 生命周期需要更强健,再补 refresh token 或持久化 auth state
-18
View File
@@ -1,18 +0,0 @@
# Legacy Code
这个目录用于收纳 Python 重构开始之前就已存在的旧实现与配套资产,方便在重构完成后整块删除。
当前已迁入:
- `go-backend/src/`
- 旧 Go 后端实现
- `go-backend/helper/`
- 旧 Go 部署与辅助脚本
- `go-backend/.github/workflows/`
- 旧 Go 版本对应的 GitHub Actions workflows
原则上:
- 新的 Python 实现继续在仓库根目录的 `app/``tests/``alembic/` 等目录演进
- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础
- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/`
-22
View File
@@ -1,22 +0,0 @@
name: Run nightly tests
on:
schedule:
- cron: '0 20 * * *' # Every day at 20:00 UTC
push:
branches:
- main
jobs:
nightly-tests:
runs-on: [ubuntu-latest, cloud]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Test
working-directory: ./src
run: go test -v --short ./...
-21
View File
@@ -1,21 +0,0 @@
name: Run short tests
on:
push:
pull_request:
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
- name: Run short tests with coverage
working-directory: ./src
run: | # TODO: at this moment only Home Assistant component is tested
go test -v --short ./components/homeassistant/... -cover -coverprofile=cover.out
@@ -1,15 +0,0 @@
[program:home_automation_backend]
command=
directory=
user=
group=
environment=
autostart=true
autorestart=true
startsecs=15
startretries=100
stopwaitsecs=30
redirect_stderr=true
stdout_logfile=/var/log/supervisor/%(program_name)s.log
stdout_logfile_maxbytes=5MB
stdout_logfile_backups=5
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/bash
# Argument parsing
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [--install|--uninstall|--help]"
echo " --install Install the automation backend"
echo " --uninstall Uninstall the automation backend"
echo " --update Update the installation"
echo " --help Show this help message"
exit 0
fi
key="$1"
case $key in
--install)
INSTALL=true
;;
--uninstall)
UNINSTALL=true
;;
--update)
UPDATE=true
;;
--help)
echo "Usage: $0 [--install|--uninstall|--update|--help]"
echo " --install Install the automation backend"
echo " --uninstall Uninstall the automation backend"
echo " --update Update the installation"
echo " --help Show this help message"
exit 0
;;
*)
echo "Invalid argument: $key"
exit 1
;;
esac
TARGET_DIR="$HOME/.local/home-automation-backend"
SUPERVISOR_CFG_NAME="home_automation_backend"
APP_NAME="home-automation-backend"
SUPERVISOR_CFG="$SUPERVISOR_CFG_NAME.conf"
BASEDIR=$(dirname "$(realpath "$0")")
# Install or uninstall based on arguments
install_backend() {
# Installation code here
echo "Installing..."
sudo supervisorctl stop $SUPERVISOR_CFG_NAME
mkdir -p $TARGET_DIR
cd $BASEDIR"/../src/" && go build -o $TARGET_DIR/$APP_NAME
cp $BASEDIR/"$SUPERVISOR_CFG_NAME"_template.conf $BASEDIR/$SUPERVISOR_CFG
sed -i "s+command=+command=$TARGET_DIR/$APP_NAME serve+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+directory=+directory=$TARGET_DIR+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+user=+user=$USER+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+group=+group=$USER+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+environment=+environment=HOME=\"$HOME\"+g" $BASEDIR/$SUPERVISOR_CFG
sudo mv $BASEDIR/$SUPERVISOR_CFG /etc/supervisor/conf.d/$SUPERVISOR_CFG
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start $SUPERVISOR_CFG_NAME
echo "Installation complete."
}
uninstall_backend() {
# Uninstallation code here
echo "Uninstalling..."
sudo supervisorctl stop $SUPERVISOR_CFG_NAME
sudo supervisorctl remove $SUPERVISOR_CFG_NAME
sudo rm /etc/supervisor/conf.d/$SUPERVISOR_CFG
rm -rf $TARGET_DIR/
echo "Uninstallation complete."
echo "Config files and db is stored in $HOME/.config/home-automation"
}
update_backend() {
uninstall_backend
install_backend
}
if [[ $INSTALL ]]; then
install_backend
elif [[ $UNINSTALL ]]; then
uninstall_backend
elif [[ $UPDATE ]]; then
update_backend
else
echo "Invalid argument: $key"
exit 1
fi
View File
-41
View File
@@ -1,41 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "home-automation-backend",
Short: "This is the entry point of the home automation backend",
Long: `Home automation backend is a RESTful API server that provides
automation features for may devices.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.home-automation-backend.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
}
-161
View File
@@ -1,161 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/gorilla/mux"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/t-liu93/home-automation-backend/components/homeassistant"
"github.com/t-liu93/home-automation-backend/components/locationRecorder"
"github.com/t-liu93/home-automation-backend/components/pooRecorder"
"github.com/t-liu93/home-automation-backend/util/notion"
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
)
var (
port string
scheduler gocron.Scheduler
ticktick ticktickutil.TicktickUtil
ha *homeassistant.HomeAssistant
)
// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Server automation backend",
Run: serve,
}
func initUtil() {
// init notion
if viper.InConfig("notion.token") {
notion.Init(viper.GetString("notion.token"))
} else {
slog.Error("Notion token not found in config file, exiting..")
os.Exit(1)
}
// init ticktick
ticktick = ticktickutil.Init()
}
func initComponent() {
// init pooRecorder
pooRecorder.Init(&scheduler)
// init location recorder
locationRecorder.Init()
// init homeassistant
ha = homeassistant.NewHomeAssistant(ticktick)
}
func serve(cmd *cobra.Command, args []string) {
slog.Info("Starting server..")
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml")
viper.AddConfigPath(".") // . is used for dev
viper.AddConfigPath("$HOME/.config/home-automation")
err := viper.ReadInConfig()
if err != nil {
slog.Error(fmt.Sprintf("Cannot read config file, %s, exiting..", err))
os.Exit(1)
}
viper.WatchConfig()
viper.SetDefault("logLevel", "info")
logLevelCfg := viper.GetString("logLevel")
switch logLevelCfg {
case "debug":
slog.SetLogLoggerLevel(slog.LevelDebug)
case "info":
slog.SetLogLoggerLevel(slog.LevelInfo)
case "warn":
slog.SetLogLoggerLevel(slog.LevelWarn)
case "error":
slog.SetLogLoggerLevel(slog.LevelError)
}
if viper.InConfig("port") {
port = viper.GetString("port")
} else {
slog.Error("Port not found in config file, exiting..")
os.Exit(1)
}
scheduler, err = gocron.NewScheduler()
defer scheduler.Shutdown()
if err != nil {
slog.Error(fmt.Sprintf("Cannot create scheduler, %s, exiting..", err))
os.Exit(1)
}
initUtil()
initComponent()
scheduler.Start()
// routing
router := mux.NewRouter()
router.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}).Methods("GET")
router.HandleFunc("/poo/latest", pooRecorder.HandleNotifyLatestPoo).Methods("GET")
router.HandleFunc("/poo/record", pooRecorder.HandleRecordPoo).Methods("POST")
router.HandleFunc("/homeassistant/publish", ha.HandleHaMessage).Methods("POST")
router.HandleFunc("/location/record", locationRecorder.HandleRecordLocation).Methods("POST")
router.HandleFunc("/ticktick/auth/code", ticktick.HandleAuthCode).Methods("GET")
srv := &http.Server{
Addr: ":" + port,
Handler: router,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error(fmt.Sprintf("ListenAndServe error: %v", err))
os.Exit(1)
}
}()
slog.Info(fmt.Sprintln("Server started on port", port))
<-stop
slog.Info(fmt.Sprintln("Shutting down the server..."))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error(fmt.Sprintf("Server Shutdown Failed:%+v", err))
os.Exit(1)
}
slog.Info(fmt.Sprintln("Server gracefully stopped"))
}
func init() {
rootCmd.AddCommand(serveCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// serveCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
serveCmd.Flags().StringVarP(&port, "port", "p", "18881", "Port to listen on")
}
@@ -1,152 +0,0 @@
package homeassistant
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/spf13/viper"
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
)
type haMessage struct {
Target string `json:"target"`
Action string `json:"action"`
Content string `json:"content"`
}
type HomeAssistant struct {
ticktickUtil ticktickutil.TicktickUtil
}
type actionTask struct {
Action string `json:"action"`
DueHour int `json:"due_hour"`
}
func NewHomeAssistant(ticktick ticktickutil.TicktickUtil) *HomeAssistant {
return &HomeAssistant{
ticktickUtil: ticktick,
}
}
func (ha *HomeAssistant) HandleHaMessage(w http.ResponseWriter, r *http.Request) {
var message haMessage
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&message)
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error decoding request body", err))
http.Error(w, "", http.StatusInternalServerError)
return
}
switch message.Target {
case "poo_recorder":
res := ha.handlePooRecorderMsg(message)
if !res {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling poo recorder message"))
http.Error(w, "", http.StatusInternalServerError)
}
case "location_recorder":
res := ha.handleLocationRecorderMsg(message)
if !res {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling location recorder message"))
http.Error(w, "", http.StatusInternalServerError)
}
case "ticktick":
res := ha.handleTicktickMsg(message)
if !res {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling ticktick message"))
http.Error(w, "", http.StatusInternalServerError)
}
default:
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Unknown target", message.Target))
http.Error(w, "", http.StatusInternalServerError)
}
}
func (ha *HomeAssistant) handlePooRecorderMsg(message haMessage) bool {
switch message.Action {
case "get_latest":
return ha.handleGetLatestPoo()
default:
slog.Warn(fmt.Sprintln("homeassistant.handlePooRecorderMsg: Unknown action", message.Action))
return false
}
}
func (ha *HomeAssistant) handleLocationRecorderMsg(message haMessage) bool {
if message.Action == "record" {
port := viper.GetString("port")
client := &http.Client{
Timeout: time.Second * 1,
}
_, err := client.Post("http://localhost:"+port+"/location/record", "application/json", strings.NewReader(strings.ReplaceAll(message.Content, "'", "\"")))
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Error sending request to location recorder", err))
return false
}
} else {
slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Unknown action", message.Action))
return false
}
return true
}
func (ha *HomeAssistant) handleTicktickMsg(message haMessage) bool {
switch message.Action {
case "create_action_task":
return ha.createActionTask(message)
default:
slog.Warn(fmt.Sprintln("homeassistant.handleTicktickMsg: Unknown action", message.Action))
return false
}
}
func (ha *HomeAssistant) handleGetLatestPoo() bool {
client := &http.Client{
Timeout: time.Second * 1,
}
port := viper.GetString("port")
_, err := client.Get("http://localhost:" + port + "/poo/latest")
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.handleGetLatestPoo: Error sending request to poo recorder", err))
return false
}
return true
}
func (ha *HomeAssistant) createActionTask(message haMessage) bool {
if !viper.IsSet("homeassistant.actionTaskProjectId") {
slog.Warn("homeassistant.createActionTask: actionTaskProjectId not found in config file")
return false
}
projectId := viper.GetString("homeassistant.actionTaskProjectId")
detail := strings.ReplaceAll(message.Content, "'", "\"")
var task actionTask
err := json.Unmarshal([]byte(detail), &task)
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.createActionTask: Error unmarshalling", err))
return false
}
dueHour := task.DueHour
due := time.Now().Add(time.Hour * time.Duration(dueHour))
dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
dueTicktick := dueNextMidnight.UTC().Format(ticktickutil.DateTimeLayout)
ticktickTask := ticktickutil.Task{
ProjectId: projectId,
Title: task.Action,
DueDate: dueTicktick,
}
err = ha.ticktickUtil.CreateTask(ticktickTask)
if err != nil {
slog.Warn(fmt.Sprintf("homeassistant.createActionTask: Error creating task %s", err))
return false
}
return true
}
@@ -1,280 +0,0 @@
package homeassistant
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
)
var (
loggerText = new(bytes.Buffer)
)
type MockTicktickUtil struct {
mock.Mock
}
func (m *MockTicktickUtil) HandleAuthCode(w http.ResponseWriter, r *http.Request) {
m.Called(w, r)
}
func (m *MockTicktickUtil) GetTasks(projectId string) []ticktickutil.Task {
args := m.Called(projectId)
return args.Get(0).([]ticktickutil.Task)
}
func (m *MockTicktickUtil) HasDuplicateTask(projectId string, taskTitile string) bool {
args := m.Called(projectId, taskTitile)
return args.Bool(0)
}
func (m *MockTicktickUtil) CreateTask(task ticktickutil.Task) error {
args := m.Called(task)
return args.Error(0)
}
func SetupTearDown(t *testing.T) (func(), *HomeAssistant) {
loggertearDown := loggerSetupTeardown()
mockTicktick := &MockTicktickUtil{}
ha := NewHomeAssistant(mockTicktick)
return func() {
loggertearDown()
viper.Reset()
}, ha
}
func loggerSetupTeardown() func() {
logger := slog.New(slog.NewTextHandler(loggerText, nil))
defaultLogger := slog.Default()
slog.SetDefault(logger)
return func() {
slog.SetDefault(defaultLogger)
loggerText.Reset()
}
}
func TestHandleHaMessageJsonDecodeError(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
invalidRequestBody := ` { "target": "poo_recorder", "action": "get_latest", "content": " }`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Error decoding request body")
}
func TestHandlePooRecorderMsgGetLatest(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/poo/latest", r.URL.Path)
}))
defer server.Close()
port := strings.Split(server.URL, ":")[2]
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, loggerText.String())
}
func TestHandlePooRecorderMsgUnknownAction(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "poo_recorder", "action": "unknown_action", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handlePooRecorderMsg: Unknown action")
}
func TestHandlePooRecorderMsgGetLatestError(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
port := "invalid port"
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleGetLatestPoo: Error sending request to poo recorder")
}
func TestHandleLocationRecorderMsg(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/location/record", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
}))
defer server.Close()
port := strings.Split(server.URL, ":")[2]
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, loggerText.String())
}
func TestHandleLocationRecorderMsgUnknownAction(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "location_recorder", "action": "unknown_action", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Unknown action")
}
func TestHandleLocationRecorderMsgRequestErr(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
port := "invalid port"
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Error sending request to location recorder")
}
func TestHandleTicktickMsgCreateActionTask(t *testing.T) {
teardown, _ := SetupTearDown(t)
defer teardown()
const expectedProjectId = "test_project_id"
const dueHour = 12
due := time.Now().Add(time.Hour * time.Duration(dueHour))
dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
dueTicktick := dueNextMidnight.UTC().Format(ticktickutil.DateTimeLayout)
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
mockTicktick := &MockTicktickUtil{}
mockTicktick.On("CreateTask", mock.Anything).Return(nil)
ha := NewHomeAssistant(mockTicktick)
viper.Set("homeassistant.actionTaskProjectId", expectedProjectId)
ha.HandleHaMessage(w, req)
expectedTask := ticktickutil.Task{
Title: "test_action",
DueDate: dueTicktick,
ProjectId: expectedProjectId,
}
mockTicktick.AssertCalled(t, "CreateTask", expectedTask)
mockTicktick.AssertNumberOfCalls(t, "CreateTask", 1)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, loggerText.String())
}
func TestHandleTicktickMsgUnknownAction(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "ticktick", "action": "unknown_action", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleTicktickMsg: Unknown action")
}
func TestHandleTicktickMsgProjectIdUnset(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: actionTaskProjectId not found in config file")
}
func TestHandleTicktickMsgJsonError(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
invalidRequestBody := ` { "target": "ticktick", "action": "create_action_task", "content": "{'title': 'tes, 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody))
w := httptest.NewRecorder()
viper.Set("homeassistant.actionTaskProjectId", "some project id")
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error unmarshalling")
}
func TestHandleTicktickMsgTicktickUtilErr(t *testing.T) {
teardown, _ := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
mockedTicktickUtil := &MockTicktickUtil{}
viper.Set("homeassistant.actionTaskProjectId", "some project id")
mockedTicktickUtil.On("CreateTask", mock.Anything).Return(errors.New("some error"))
ha := NewHomeAssistant(mockedTicktickUtil)
ha.HandleHaMessage(w, req)
mockedTicktickUtil.AssertCalled(t, "CreateTask", mock.Anything)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error creating task")
}
func TestHandleHaMessageUnknownTarget(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "unknown_target", "action": "record", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Unknown target")
}
@@ -1,194 +0,0 @@
package locationRecorder
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"github.com/spf13/viper"
)
var (
db *sql.DB
)
const (
currentDBVersion = 2
)
type Location struct {
Person string `json:"person"`
DateTime string `json:"datetime"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude sql.NullFloat64 `json:"altitude,omitempty"`
}
type LocationContent struct {
Person string `json:"person"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
Altitude string `json:"altitude,omitempty"`
}
func Init() {
initDb()
}
func HandleRecordLocation(w http.ResponseWriter, r *http.Request) {
var location LocationContent
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&location)
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordLocation Error decoding request body", err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
latiF64, _ := strconv.ParseFloat(location.Latitude, 64)
longiF64, _ := strconv.ParseFloat(location.Longitude, 64)
altiF64, _ := strconv.ParseFloat(location.Altitude, 64)
InsertLocationNow(location.Person, latiF64, longiF64, altiF64)
}
func InsertLocation(person string, datetime time.Time, latitude float64, longitude float64, altitude float64) {
_, err := db.Exec(`INSERT OR IGNORE INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`,
person, datetime.UTC().Format(time.RFC3339), latitude, longitude, altitude)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorder.InsertLocation Error inserting location", err))
}
}
func InsertLocationNow(person string, latitude float64, longitude float64, altitude float64) {
InsertLocation(person, time.Now(), latitude, longitude, altitude)
}
func initDb() {
if !viper.InConfig("locationRecorder.dbPath") {
slog.Info("LocationRecorderInit dbPath not found in config file, using default: location_recorder.db")
viper.SetDefault("locationRecorder.dbPath", "location_recorder.db")
}
dbPath := viper.GetString("locationRecorder.dbPath")
err := error(nil)
db, err = sql.Open("sqlite", dbPath)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error opening database", err))
os.Exit(1)
}
err = db.Ping()
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error pinging database", err))
os.Exit(1)
}
migrateDb()
}
func migrateDb() {
var userVersion int
err := db.QueryRow("PRAGMA user_version").Scan(&userVersion)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error getting db user version", err))
os.Exit(1)
}
if userVersion == 0 {
migrateDb0To1(&userVersion)
}
if userVersion == 1 {
migrateDb1To2(&userVersion)
}
if userVersion != currentDBVersion {
slog.Error(fmt.Sprintln("LocationRecorderInit Error unsupported database version", userVersion))
os.Exit(1)
}
}
func migrateDb0To1(userVersion *int) {
// this is actually create new db
slog.Info("Creating location recorder database version 1..")
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime))`)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error creating table", err))
os.Exit(1)
}
_, err = db.Exec(`PRAGMA user_version = 1`)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error setting user version to 1", err))
os.Exit(1)
}
*userVersion = 1
}
func migrateDb1To2(userVersion *int) {
// this will change the datetime format into Real RFC3339
slog.Info("Migrating location recorder database version 1 to 2..")
dbTx, err := db.Begin()
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit DB1To2 Error beginning transaction", err))
os.Exit(1)
}
fail := func(err error, step string) {
slog.Error(fmt.Sprintf("LocationRecorderInit DB1To2 Error %s: %s", step, err))
dbTx.Rollback()
os.Exit(1)
}
_, err = dbTx.Exec(`ALTER TABLE location RENAME TO location_old`)
if err != nil {
fail(err, "renaming table")
}
_, err = dbTx.Exec(`CREATE TABLE IF NOT EXISTS location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime))`)
if err != nil {
fail(err, "creating new table")
}
row, err := dbTx.Query(`SELECT person, datetime, latitude, longitude, altitude FROM location_old`)
if err != nil {
fail(err, "selecting from old table")
}
defer row.Close()
for row.Next() {
var location Location
err = row.Scan(&location.Person, &location.DateTime, &location.Latitude, &location.Longitude, &location.Altitude)
if err != nil {
fail(err, "scanning row")
}
dateTime, err := time.Parse("2006-01-02T15:04:05-0700", location.DateTime)
if err != nil {
fail(err, "parsing datetime")
}
_, err = dbTx.Exec(`INSERT INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, location.Person, dateTime.UTC().Format(time.RFC3339), location.Latitude, location.Longitude, location.Altitude)
if err != nil {
fail(err, "inserting new row")
}
}
_, err = dbTx.Exec(`DROP TABLE location_old`)
if err != nil {
fail(err, "dropping old table")
}
_, err = dbTx.Exec(`PRAGMA user_version = 2`)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error setting user version to 2", err))
os.Exit(1)
}
dbTx.Commit()
*userVersion = 2
}
@@ -1,366 +0,0 @@
package pooRecorder
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/jomei/notionapi"
"github.com/spf13/viper"
"github.com/t-liu93/home-automation-backend/util/homeassistantutil"
"github.com/t-liu93/home-automation-backend/util/notion"
_ "modernc.org/sqlite"
)
var (
db *sql.DB
scheduler *gocron.Scheduler
)
type recordDetail struct {
Status string `json:"status"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
}
type pooStatusSensorAttributes struct {
LastPoo string `json:"last_poo"`
FriendlyName string `json:"friendly_name,"`
}
type pooStatusWebhookBody struct {
Status string `json:"status"`
}
type pooStatusDbEntry struct {
Timestamp string
Status string
Latitude float64
Longitude float64
}
func Init(mainScheduler *gocron.Scheduler) {
initDb()
initScheduler(mainScheduler)
notionDbSync()
publishLatestPooSensor()
}
func HandleRecordPoo(w http.ResponseWriter, r *http.Request) {
var record recordDetail
if !viper.InConfig("pooRecorder.tableId") {
slog.Warn("HandleRecordPoo Table ID not found in config file")
http.Error(w, "Table ID not found in config file", http.StatusInternalServerError)
return
}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&record)
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Error decoding request body", err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
now := time.Now()
err = storeStatus(record, now)
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Error storing status", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
publishLatestPooSensor()
if viper.InConfig("pooRecorder.webhookId") {
homeassistantutil.TriggerWebhook(viper.GetString("pooRecorder.webhookId"), pooStatusWebhookBody{Status: record.Status})
} else {
slog.Warn("HandleRecordPoo Webhook ID not found in config file")
}
}
func HandleNotifyLatestPoo(w http.ResponseWriter, r *http.Request) {
err := publishLatestPooSensor()
if err != nil {
slog.Warn(fmt.Sprintln("HandleNotifyLatestPoo Error publishing latest poo", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
slog.Debug(fmt.Sprintln("HandleGetLatestPoo Latest poo"))
}
func publishLatestPooSensor() error {
var latest pooStatusDbEntry
err := db.QueryRow(`SELECT timestamp, status, latitude, longitude FROM poo_records ORDER BY timestamp DESC LIMIT 1`).Scan(&latest.Timestamp, &latest.Status, &latest.Latitude, &latest.Longitude)
if err != nil {
slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error getting latest poo", err))
return err
}
recordTime, err := time.Parse("2006-01-02T15:04Z07:00", latest.Timestamp)
if err != nil {
slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error parsing timestamp", err))
return err
}
viper.SetDefault("pooRecorder.sensorEntityName", "sensor.test_poo_status")
viper.SetDefault("pooRecorder.sensorFriendlyName", "Poo Status")
sensorEntityName := viper.GetString("pooRecorder.sensorEntityName")
sensorFriendlyName := viper.GetString("pooRecorder.sensorFriendlyName")
recordTime = recordTime.Local()
pooStatus := homeassistantutil.HttpSensor{
EntityId: sensorEntityName,
State: latest.Status,
Attributes: pooStatusSensorAttributes{
LastPoo: recordTime.Format("Mon | 2006-01-02 | 15:04"),
FriendlyName: sensorFriendlyName,
},
}
homeassistantutil.PublishSensor(pooStatus)
return nil
}
func initDb() {
if !viper.InConfig("pooRecorder.dbPath") {
slog.Info("PooRecorderInit dbPath not found in config file, using default: pooRecorder.db")
viper.SetDefault("pooRecorder.dbPath", "pooRecorder.db")
}
dbPath := viper.GetString("pooRecorder.dbPath")
err := error(nil)
db, err = sql.Open("sqlite", dbPath)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error opening database", err))
os.Exit(1)
}
err = db.Ping()
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error pinging database", err))
os.Exit(1)
}
migrateDb()
}
func migrateDb() {
var userVersion int
err := db.QueryRow("PRAGMA user_version").Scan(&userVersion)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error getting db user version", err))
os.Exit(1)
}
if userVersion == 0 {
migrateDb0To1(&userVersion)
}
}
func migrateDb0To1(userVersion *int) {
// this is actually create new db
slog.Info("Creating database version 1..")
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS poo_records (
timestamp TEXT NOT NULL,
status TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
PRIMARY KEY (timestamp))`)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error creating table", err))
os.Exit(1)
}
_, err = db.Exec(`PRAGMA user_version = 1`)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error setting user version to 1", err))
os.Exit(1)
}
*userVersion = 1
}
func initScheduler(mainScheduler *gocron.Scheduler) {
scheduler = mainScheduler
_, err := (*scheduler).NewJob(gocron.CronJob("0 5 * * *", false), gocron.NewTask(
notionDbSync,
))
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error creating scheduled task", err))
os.Exit(1)
}
}
func notionDbSync() {
slog.Info("PooRecorder Running DB sync with Notion..")
if !viper.InConfig("pooRecorder.tableId") {
slog.Warn("PooRecorder Table ID not found in config file, sync aborted")
return
}
tableId := viper.GetString("pooRecorder.tableId")
rowsNotion, err := notion.GetAllTableRows(tableId)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get table header", err))
return
}
header := rowsNotion[0]
rowsNotion = rowsNotion[1:] // remove header
rowsDb, err := db.Query(`SELECT * FROM poo_records`)
rowsDbMap := make(map[string]pooStatusDbEntry)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err))
return
}
defer rowsDb.Close()
for rowsDb.Next() {
var row pooStatusDbEntry
err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err))
return
}
rowsDbMap[row.Timestamp] = row
}
// notion to db
syncNotionToDb(rowsNotion, rowsDbMap)
// db to notion
syncDbToNotion(header.GetID().String(), tableId, rowsNotion)
}
func syncNotionToDb(rowsNotion []notionapi.TableRowBlock, rowsDbMap map[string]pooStatusDbEntry) {
counter := 0
for _, rowNotion := range rowsNotion {
rowNotionTimestamp := rowNotion.TableRow.Cells[0][0].PlainText + "T" + rowNotion.TableRow.Cells[1][0].PlainText
rowNotionTime, err := time.ParseInLocation("2006-01-02T15:04", rowNotionTimestamp, time.Now().Location())
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse timestamp", err))
return
}
rowNotionTimeInDbFormat := rowNotionTime.UTC().Format("2006-01-02T15:04Z07:00")
_, exists := rowsDbMap[rowNotionTimeInDbFormat]
if !exists {
locationNotion := rowNotion.TableRow.Cells[3][0].PlainText
latitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[0], 64)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse latitude to float", err))
return
}
longitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[1], 64)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse longitude to float", err))
return
}
_, err = db.Exec(`INSERT INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`,
rowNotionTimeInDbFormat, rowNotion.TableRow.Cells[2][0].PlainText, latitude, longitude)
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to insert new row", err))
return
}
counter++
}
}
slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from Notion to DB"))
}
func syncDbToNotion(headerId string, tableId string, rowsNotion []notionapi.TableRowBlock) {
counter := 0
var rowsDbSlice []pooStatusDbEntry
rowsDb, err := db.Query(`SELECT * FROM poo_records ORDER BY timestamp DESC`)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err))
return
}
defer rowsDb.Close()
for rowsDb.Next() {
var row pooStatusDbEntry
err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err))
return
}
rowsDbSlice = append(rowsDbSlice, row)
}
startFromId := headerId
for iNotion, iDb := 0, 0; iNotion < len(rowsNotion) && iDb < len(rowsDbSlice); {
notionTimeStamp := rowsNotion[iNotion].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion].TableRow.Cells[1][0].PlainText
notionTime, err := time.ParseInLocation("2006-01-02T15:04", notionTimeStamp, time.Now().Location())
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse notion timestamp", err))
return
}
notionTimeStampInDbFormat := notionTime.UTC().Format("2006-01-02T15:04Z07:00")
dbTimeStamp := rowsDbSlice[iDb].Timestamp
dbTime, err := time.Parse("2006-01-02T15:04Z07:00", dbTimeStamp)
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse db timestamp", err))
return
}
dbTimeLocal := dbTime.Local()
dbTimeDate := dbTimeLocal.Format("2006-01-02")
dbTimeTime := dbTimeLocal.Format("15:04")
if notionTimeStampInDbFormat == dbTimeStamp {
startFromId = rowsNotion[iNotion].GetID().String()
iNotion++
iDb++
continue
}
if iNotion != len(rowsNotion)-1 {
notionNextTimeStamp := rowsNotion[iNotion+1].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion+1].TableRow.Cells[1][0].PlainText
notionNextTime, err := time.ParseInLocation("2006-01-02T15:04", notionNextTimeStamp, time.Now().Location())
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse next notion timestamp", err))
return
}
if notionNextTime.After(notionTime) {
slog.Error(fmt.Sprintf("PooRecorderSyncDb Notion timestamp %s is after next timestamp %s, checking, aborting", notionTimeStamp, notionNextTimeStamp))
return
}
}
id, err := notion.WriteTableRow([]string{
dbTimeDate,
dbTimeTime,
rowsDbSlice[iDb].Status,
fmt.Sprintf("%s,%s",
strconv.FormatFloat(rowsDbSlice[iDb].Latitude, 'f', -1, 64),
strconv.FormatFloat(rowsDbSlice[iDb].Longitude, 'f', -1, 64))},
tableId,
startFromId)
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to write row to Notion", err))
return
}
startFromId = id
iDb++
counter++
time.Sleep(400 * time.Millisecond)
}
slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from DB to Notion"))
}
func storeStatus(record recordDetail, timestamp time.Time) error {
tableId := viper.GetString("pooRecorder.tableId")
recordDate := timestamp.Format("2006-01-02")
recordTime := timestamp.Format("15:04")
slog.Debug(fmt.Sprintln("Recording poo", record.Status, "at", record.Latitude, record.Longitude))
_, err := db.Exec(`INSERT OR IGNORE INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`,
timestamp.UTC().Format("2006-01-02T15:04Z07:00"), record.Status, record.Latitude, record.Longitude)
if err != nil {
return err
}
go func() {
header, err := notion.GetTableRows(tableId, 1, "")
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to get table header", err))
return
}
if len(header) == 0 {
slog.Warn("HandleRecordPoo Table header not found")
return
}
headerId := header[0].GetID()
_, err = notion.WriteTableRow([]string{recordDate, recordTime, record.Status, record.Latitude + "," + record.Longitude}, tableId, headerId.String())
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to write table row", err))
}
}()
return nil
}
-54
View File
@@ -1,54 +0,0 @@
module github.com/t-liu93/home-automation-backend
go 1.23.0
require (
github.com/go-co-op/gocron/v2 v2.11.0
github.com/gorilla/mux v1.8.1
github.com/jomei/notionapi v1.13.2
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0
golang.org/x/term v0.24.0
modernc.org/sqlite v1.33.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
-140
View File
@@ -1,140 +0,0 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jomei/notionapi v1.13.2 h1:YpHKNpkoTMlUfWTlVIodOmQDgRKjfwmtSNVa6/6yC9E=
github.com/jomei/notionapi v1.13.2/go.mod h1:BqzP6JBddpBnXvMSIxiR5dCoCjKngmz5QNl1ONDlDoM=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -1,40 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// addgpxCmd represents the addgpx command
var addgpxCmd = &cobra.Command{
Use: "addgpx",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("addgpx called")
},
}
func init() {
rootCmd.AddCommand(addgpxCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// addgpxCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// addgpxCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
@@ -1,51 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "location_recorder",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.location_recorder.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
@@ -1,11 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/helper/location_recorder/cmd"
func main() {
cmd.Execute()
}
@@ -1,127 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/jomei/notionapi"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var notionToken string
var notionTableId string
// reverseCmd represents the reverse command
var reverseCmd = &cobra.Command{
Use: "reverse",
Short: "Reverse given poo recording table",
Long: `Reverse the given poo recording table. Provide the Notion API token and the table ID to reverse.
The Notion API token can be obtained from https://www.notion.so/my-integrations. The table ID can be obtained from the URL of the table.
The token and table ID will be input in the following prompt.
`,
Run: readCredentials,
}
func readCredentials(cmd *cobra.Command, args []string) {
if notionToken == "" || notionTableId == "" {
fmt.Print("Enter Notion API token: ")
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatalf("failed to read NOTION API Token: %v", err)
}
notionToken = string(pw)
fmt.Print("\nEnter Notion table ID: ")
tableId, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatalf("failed to read NOTION table ID: %v", err)
}
notionTableId = string(tableId)
}
reverseRun()
}
func reverseRun() {
client := notionapi.NewClient(notionapi.Token(notionToken))
rows := []notionapi.Block{}
fmt.Println("Reverse table ID: ", notionTableId)
block, err := client.Block.Get(context.Background(), notionapi.BlockID(notionTableId))
if err != nil {
log.Fatalf("Failed to get table detail: %v", err)
}
if block.GetType().String() != "table" {
log.Fatalf("Block ID %s is not a table", notionTableId)
}
headerBlock, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), &notionapi.Pagination{
StartCursor: "",
PageSize: 100,
})
headerId := headerBlock.Results[0].GetID()
nextCursor := headerId.String()
hasMore := true
for hasMore {
blockChildren, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), &notionapi.Pagination{
StartCursor: notionapi.Cursor(nextCursor),
PageSize: 100,
})
rows = append(rows, blockChildren.Results...)
hasMore = blockChildren.HasMore
nextCursor = blockChildren.NextCursor
}
rows = rows[1:]
rowsR := reverseTable(rows)
nrRowsToDelete := len(rowsR)
for index, row := range rowsR {
client.Block.Delete(context.Background(), row.GetID())
if index%10 == 0 || index == nrRowsToDelete-1 {
fmt.Printf("Deleted %d/%d rows\n", index, nrRowsToDelete)
}
time.Sleep(400 * time.Millisecond)
}
after := headerId
fmt.Println("Writing rows back to table")
for len(rowsR) > 0 {
var rowsToWrite []notionapi.Block
if len(rowsR) > 100 {
rowsToWrite = rowsR[:100]
} else {
rowsToWrite = rowsR
}
client.Block.AppendChildren(context.Background(), notionapi.BlockID(notionTableId), &notionapi.AppendBlockChildrenRequest{
After: after,
Children: rowsToWrite,
})
after = rowsToWrite[len(rowsToWrite)-1].GetID()
rowsR = rowsR[len(rowsToWrite):]
}
}
func reverseTable[T any](rows []T) []T {
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
rows[i], rows[j] = rows[j], rows[i]
}
return rows
}
func init() {
rootCmd.AddCommand(reverseCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// reverseCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// reverseCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
reverseCmd.Flags().StringVar(&notionToken, "token", "", "Notion API token")
reverseCmd.Flags().StringVar(&notionTableId, "table-id", "", "Notion table id to reverse")
}
@@ -1,39 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "poo_recorder_helper",
Short: "Poo recorder helper executables.",
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.poo_recorder_helper.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
}
@@ -1,11 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/helper/poo_recorder_helper/cmd"
func main() {
cmd.Execute()
}
-11
View File
@@ -1,11 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/cmd"
func main() {
cmd.Execute()
}
@@ -1,96 +0,0 @@
package homeassistantutil
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/spf13/viper"
)
const (
ipField string = "homeassistant.ip"
portField string = "homeassistant.port"
authTokenField string = "homeassistant.authToken"
webhookPath string = "/api/webhook/"
sensorPath string = "/api/states/"
)
type HttpSensor struct {
EntityId string `json:"entity_id"`
State string `json:"state"`
Attributes interface{} `json:"attributes"`
}
type WebhookBody interface{}
func TriggerWebhook(webhookId string, body WebhookBody) {
if viper.InConfig(ipField) &&
viper.InConfig(portField) &&
viper.InConfig(authTokenField) {
url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), webhookPath, webhookId)
payload, err := json.Marshal(body)
if err != nil {
slog.Warn(fmt.Sprintln("TriggerWebhook Error marshalling", err))
return
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
slog.Warn(fmt.Sprintln("TriggerWebhook Error creating request", err))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField))
client := &http.Client{
Timeout: time.Second * 1,
}
go func() {
resp, err := client.Do(req)
if err != nil {
slog.Warn(fmt.Sprintln("TriggerWebhook Error sending request", err))
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
slog.Warn(fmt.Sprintln("TriggerWebhook Unexpected response status", resp.StatusCode))
}
defer resp.Body.Close()
}()
} else {
slog.Warn("TriggerWebhook Home Assistant IP, port, or token not found in config file")
}
}
func PublishSensor(sensor HttpSensor) {
if viper.InConfig(ipField) &&
viper.InConfig(portField) &&
viper.InConfig(authTokenField) {
url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), sensorPath, sensor.EntityId)
payload, err := json.Marshal(sensor)
if err != nil {
slog.Warn(fmt.Sprintln("PublishSensor Error marshalling", err))
return
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
slog.Warn(fmt.Sprintln("PublishSensor Error creating request", err))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField))
client := &http.Client{
Timeout: time.Second * 1,
}
resp, err := client.Do(req)
if err != nil {
slog.Warn(fmt.Sprintln("PublishSensor Error sending request", err))
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
slog.Warn(fmt.Sprintln("PublishSensor Unexpected response status", resp.StatusCode))
}
defer resp.Body.Close()
} else {
slog.Warn("PublishSensor Home Assistant IP, port, or token not found in config file")
}
}
-129
View File
@@ -1,129 +0,0 @@
package notion
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jomei/notionapi"
)
var client *notionapi.Client
func Init(token string) {
client = notionapi.NewClient(notionapi.Token(token))
}
func GetClient() *notionapi.Client {
return client
}
func GetTableRows(tableId string, numberOfRows int, startFromId string) ([]notionapi.TableRowBlock, error) {
if client == nil {
return nil, errors.New("notion client not initialized")
}
var rows []notionapi.TableRowBlock
var nextNumberToGet int
if numberOfRows > 100 {
nextNumberToGet = 100
} else {
nextNumberToGet = numberOfRows
}
for numberOfRows > 0 {
block, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(tableId), &notionapi.Pagination{
StartCursor: notionapi.Cursor(startFromId),
PageSize: nextNumberToGet,
})
if err != nil {
return nil, err
}
for _, block := range block.Results {
if block.GetType().String() == "table_row" {
tableRow, ok := block.(*notionapi.TableRowBlock)
if !ok {
slog.Error("Notion.GetTableRows Failed to cast block to table row")
return nil, errors.New("Notion.GetTableRows failed to cast block to table row")
}
rows = append(rows, *tableRow)
} else {
slog.Error(fmt.Sprintf("Block ID %s is not a table row", block.GetID()))
return nil, errors.New("Notion.GetAllTableRows block ID is not a table row")
}
}
numberOfRows -= nextNumberToGet
if numberOfRows > 100 {
nextNumberToGet = 100
} else {
nextNumberToGet = numberOfRows
}
}
return rows, nil
}
func GetAllTableRows(tableId string) ([]notionapi.TableRowBlock, error) {
if client == nil {
return nil, errors.New("notion client not initialized")
}
rows := []notionapi.TableRowBlock{}
nextCursor := ""
hasMore := true
for hasMore {
blockChildren, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(tableId), &notionapi.Pagination{
StartCursor: notionapi.Cursor(nextCursor),
PageSize: 100,
})
if err != nil {
return nil, err
}
for _, block := range blockChildren.Results {
if block.GetType().String() == "table_row" {
tableRow, ok := block.(*notionapi.TableRowBlock)
if !ok {
slog.Error("Notion.GetAllTableRows Failed to cast block to table row")
return nil, errors.New("Notion.GetAllTableRows failed to cast block to table row")
}
rows = append(rows, *tableRow)
} else {
slog.Error(fmt.Sprintf("Block ID %s is not a table row", block.GetID()))
return nil, errors.New("Notion.GetAllTableRows block ID is not a table row")
}
}
nextCursor = blockChildren.NextCursor
hasMore = blockChildren.HasMore
}
return rows, nil
}
func WriteTableRow(content []string, tableId string, after string) (string, error) {
if client == nil {
return "", errors.New("notion client not initialized")
}
rich := [][]notionapi.RichText{}
for _, c := range content {
rich = append(rich, []notionapi.RichText{
{
Type: "text",
Text: &notionapi.Text{
Content: c,
},
},
})
}
tableRow := notionapi.TableRowBlock{
BasicBlock: notionapi.BasicBlock{
Object: "block",
Type: "table_row",
},
TableRow: notionapi.TableRow{
Cells: rich,
},
}
res, err := client.Block.AppendChildren(context.Background(), notionapi.BlockID(tableId), &notionapi.AppendBlockChildrenRequest{
After: notionapi.BlockID(after),
Children: []notionapi.Block{tableRow},
})
return res.Results[0].GetID().String(), err
}

Some files were not shown because too many files have changed in this diff Show More