From 1805d5d8ea95b6f7eb6163c654f8865f74d57385 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 20:40:04 +0200 Subject: [PATCH] Finalize first Python release --- .env.example | 36 +- Dockerfile | 3 +- README.md | 86 +-- app/__init__.py | 2 +- app/main.py | 7 +- app/services/config_page.py | 12 + app/templates/base.html | 3 +- docker-compose.yml | 14 +- docker/entrypoint.sh | 9 + docs/current-system-inventory.md | 557 ------------------ docs/migration-notes.md | 134 ----- docs/migration-risks.md | 238 -------- docs/python-rewrite-plan.md | 314 ---------- legacy/README.md | 18 - .../go-backend/.github/workflows/nightly.yml | 22 - .../.github/workflows/short-tests.yml | 21 - .../home_automation_backend_template.conf | 15 - legacy/go-backend/helper/install.sh | 100 ---- legacy/go-backend/src/LICENSE | 0 legacy/go-backend/src/cmd/root.go | 41 -- legacy/go-backend/src/cmd/serve.go | 161 ----- .../components/homeassistant/homeassistant.go | 152 ----- .../homeassistant/homeassistant_test.go | 280 --------- .../locationRecorder/locationRecorder.go | 194 ------ .../src/components/pooRecorder/pooRecorder.go | 366 ------------ legacy/go-backend/src/go.mod | 54 -- legacy/go-backend/src/go.sum | 140 ----- .../src/helper/location_recorder/LICENSE | 0 .../helper/location_recorder/cmd/addgpx.go | 40 -- .../src/helper/location_recorder/cmd/root.go | 51 -- .../src/helper/location_recorder/main.go | 11 - .../src/helper/poo_recorder_helper/LICENSE | 0 .../helper/poo_recorder_helper/cmd/reverse.go | 127 ---- .../helper/poo_recorder_helper/cmd/root.go | 39 -- .../src/helper/poo_recorder_helper/main.go | 11 - legacy/go-backend/src/main.go | 11 - .../homeassistantutil/homeassistantutil.go | 96 --- legacy/go-backend/src/util/notion/notion.go | 129 ---- .../src/util/ticktickutil/ticktickutil.go | 297 ---------- openapi/openapi.json | 40 +- openapi/openapi.yaml | 28 +- pyproject.toml | 2 +- tests/test_app.py | 42 ++ 43 files changed, 215 insertions(+), 3688 deletions(-) create mode 100755 docker/entrypoint.sh delete mode 100644 docs/current-system-inventory.md delete mode 100644 docs/migration-notes.md delete mode 100644 docs/migration-risks.md delete mode 100644 docs/python-rewrite-plan.md delete mode 100644 legacy/README.md delete mode 100644 legacy/go-backend/.github/workflows/nightly.yml delete mode 100644 legacy/go-backend/.github/workflows/short-tests.yml delete mode 100644 legacy/go-backend/helper/home_automation_backend_template.conf delete mode 100755 legacy/go-backend/helper/install.sh delete mode 100644 legacy/go-backend/src/LICENSE delete mode 100644 legacy/go-backend/src/cmd/root.go delete mode 100644 legacy/go-backend/src/cmd/serve.go delete mode 100644 legacy/go-backend/src/components/homeassistant/homeassistant.go delete mode 100644 legacy/go-backend/src/components/homeassistant/homeassistant_test.go delete mode 100644 legacy/go-backend/src/components/locationRecorder/locationRecorder.go delete mode 100644 legacy/go-backend/src/components/pooRecorder/pooRecorder.go delete mode 100644 legacy/go-backend/src/go.mod delete mode 100644 legacy/go-backend/src/go.sum delete mode 100644 legacy/go-backend/src/helper/location_recorder/LICENSE delete mode 100644 legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go delete mode 100644 legacy/go-backend/src/helper/location_recorder/cmd/root.go delete mode 100644 legacy/go-backend/src/helper/location_recorder/main.go delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/LICENSE delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/main.go delete mode 100644 legacy/go-backend/src/main.go delete mode 100644 legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go delete mode 100644 legacy/go-backend/src/util/notion/notion.go delete mode 100644 legacy/go-backend/src/util/ticktickutil/ticktickutil.go diff --git a/.env.example b/.env.example index 17eb090..4156f17 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,32 @@ +# Required: bootstrap and core app settings. +# These values should be set before the container starts. APP_NAME=Home Automation Backend (Python) APP_ENV=production -APP_DEBUG=false APP_HOSTNAME=home-automation.example.com -APP_DATABASE_URL=sqlite:///./data/app.db +APP_DATABASE_URL=sqlite:////app/data/app.db +LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db +POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db AUTH_BOOTSTRAP_USERNAME=admin -AUTH_BOOTSTRAP_PASSWORD=admin -AUTH_SESSION_COOKIE_NAME=home_automation_session -AUTH_SESSION_TTL_HOURS=12 -AUTH_COOKIE_SECURE_OVERRIDE=true -LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db -POO_DATABASE_URL=sqlite:///./data/pooRecorder.db +AUTH_BOOTSTRAP_PASSWORD=change-me + +# 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=sensor.test_poo_status -POO_SENSOR_FRIENDLY_NAME=Poo Status +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_SECRET= TICKTICK_TOKEN= -HOME_ASSISTANT_BASE_URL=http://localhost:8123 -HOME_ASSISTANT_AUTH_TOKEN= -HOME_ASSISTANT_TIMEOUT_SECONDS=1.0 HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= diff --git a/Dockerfile b/Dockerfile index bc82a1f..d760173 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,10 @@ COPY alembic_location.ini ./ COPY alembic_poo ./alembic_poo COPY alembic_poo.ini ./ COPY scripts ./scripts +COPY docker ./docker COPY README.md ./ RUN mkdir -p /app/data EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/README.md b/README.md index 44742d4..6631f21 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,24 @@ # 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 持久化 +- location recorder +- poo recorder +- Home Assistant inbound / outbound integration +- TickTick OAuth 与 action task 集成 +- pytest 测试与 OpenAPI 导出脚本 +- Docker / Compose 部署入口 -- FastAPI 基础应用骨架 -- 环境变量配置体系 -- SQLite + SQLAlchemy + Alembic 基础设施 -- username/password + server-side session 基础鉴权 -- 极简 server-side templates -- location recorder 第一版迁移 -- poo recorder 第一版迁移 -- Home Assistant outbound integration layer -- Home Assistant inbound gateway 第一版 -- pytest 测试基础 -- OpenAPI 导出脚本 -- Docker / Compose 基础骨架 +当前明确不包含: -当前阶段明确不包含: - -- TickTick 业务逻辑迁移 - Notion 模块 -当前 Home Assistant inbound gateway 仅接回第一版: - -- 已支持 `location_recorder / record` -- 尚未接回 TickTick 路径 -- 尚未接回 poo recorder 路径 - -Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 - -旧 Go 代码位置: - -- `legacy/go-backend/src/` -- `legacy/go-backend/helper/` -- `legacy/go-backend/.github/workflows/` - ## 当前配置现实 当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库: @@ -68,14 +49,14 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco ## 当前目录 -Python 骨架的主要目录如下: +主要目录如下: - `app/`: FastAPI 应用代码 - `alembic_app/`: App DB 的 Alembic migration 环境 - `alembic_location/`: Location DB 的 Alembic migration 环境 - `alembic_poo/`: Poo DB 的 Alembic migration 环境 - `tests/`: pytest 测试 -- `docs/`: 架构说明与迁移文档 +- `docs/`: 当前系统说明文档 - `scripts/`: 辅助脚本,例如 OpenAPI 导出 ## 依赖管理 @@ -146,7 +127,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 数据库与 Alembic -当前默认仍使用 SQLite,但要明确区分三个数据库文件: +当前默认使用 SQLite,并区分三个数据库文件: - App DB:`sqlite:///./data/app.db` - Location DB:`sqlite:///./data/locationRecorder.db` @@ -166,7 +147,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 基础鉴权 -当前项目已经有一层小范围的基础鉴权,目标是先保护后续配置页面,而不是现在就做完整 admin system。 +当前项目提供一个单用户 admin 鉴权层,用于保护配置页面与管理能力。 - 认证模型:`username/password` - 会话模型:server-side session + cookie @@ -194,7 +175,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 -当前前端已经收敛为两条主路径: +当前前端主要有两条页面路径: - `/login` - `/config` @@ -203,7 +184,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## Config 持久化 -当前 config 页面已经不再把修改写回 `.env`。 +当前 config 页面不会把修改写回 `.env`。 当前原则是: @@ -219,6 +200,35 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中 - 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储 +## OpenAPI + +可使用下面的脚本重新导出当前 API 定义: + +```bash +python scripts/export_openapi.py +``` + +导出结果会写入: + +- `openapi/openapi.json` +- `openapi/openapi.yaml` + +## Docker Compose + +当前默认 Compose 服务名为 `app`,容器名固定为 `home-automation-app`。 + +启动方式: + +```bash +docker compose up -d --build +``` + +持续查看日志: + +```bash +docker compose logs -f app +``` + ## 运行测试 ```bash diff --git a/app/__init__.py b/app/__init__.py index 09cd07d..1780289 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,2 +1,2 @@ -"""Application package for the Python rewrite skeleton.""" +"""Application package for the home automation backend.""" diff --git a/app/main.py b/app/main.py index ea4e01d..7dddb19 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from app.api.routes.poo import router as poo_router from app.api.routes.ticktick import router as ticktick_router 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 +from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db @@ -28,6 +28,7 @@ def ensure_auth_db_ready() -> None: 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: @@ -82,8 +83,8 @@ def create_app() -> FastAPI: version="0.1.0", lifespan=lifespan, description=( - "Python rewrite skeleton for the home automation backend. " - "This stage provides only the foundation for future module migration." + "Home automation backend with auth, runtime config, Home Assistant " + "integrations, TickTick integration, and SQLite-backed recorders." ), ) diff --git a/app/services/config_page.py b/app/services/config_page.py index 38ff16e..5a68621 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -109,6 +109,18 @@ def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Set _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_auth_db_caches() + + def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings: overrides = _read_config_values(session) if not overrides: diff --git a/app/templates/base.html b/app/templates/base.html index 5c55712..e5c583f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,7 +4,8 @@ {% block title %}{{ app_name }}{% endblock %} - + +
diff --git a/docker-compose.yml b/docker-compose.yml index 2cab369..87db840 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,12 @@ -version: "3.9" - services: app: + container_name: home-automation-app build: . + user: "1000:1000" + restart: unless-stopped + init: true ports: - - "${APP_PORT:-8000}:8000" - env_file: - - .env - environment: - LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db - POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db + - "127.0.0.1:8881:8000" volumes: - ./data:/app/data + - ./.env:/app/.env:ro diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..e69c8d1 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eu + +python scripts/app_db_adopt.py +python scripts/location_db_adopt.py +python scripts/poo_db_adopt.py + +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/docs/current-system-inventory.md b/docs/current-system-inventory.md deleted file mode 100644 index c85cfb1..0000000 --- a/docs/current-system-inventory.md +++ /dev/null @@ -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 500;TickTick 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 配置文件 diff --git a/docs/migration-notes.md b/docs/migration-notes.md deleted file mode 100644 index ef64a5c..0000000 --- a/docs/migration-notes.md +++ /dev/null @@ -1,134 +0,0 @@ -# Migration Notes - -本文档记录 Python skeleton 阶段的迁移说明,帮助后续继续推进时快速恢复上下文。 - -## 当前阶段完成内容 - -- 建立 FastAPI 应用骨架 -- 建立环境变量配置体系 -- 接入 SQLAlchemy 与 Alembic -- 建立 Jinja2 模板基础 -- 建立 pytest 基础设施 -- 建立 Docker / Compose 基础骨架 -- 建立 OpenAPI 导出脚本 -- 迁入 `location recorder` 第一版 -- 迁入 `poo recorder` 第一版 - -## 数据库配置现状 - -当前系统在配置层上已明确保留两个独立 SQLite DB 文件: - -- `LOCATION_DATABASE_URL` -- `POO_DATABASE_URL` - -当前阶段不打算把这两个数据库合并。 - -其中: - -- `location` 模块已经实际接到 `LOCATION_DATABASE_URL` -- `poo` 模块已经实际接到 `POO_DATABASE_URL` - -## 当前阶段未做内容 - -- 未迁移 TickTick 业务逻辑 -- 未迁移 Home Assistant inbound / outbound 之外的其他业务逻辑 -- 未实现真实 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) - -当前还额外提供了一个最小 runbook / script 组合,用于保守接管 legacy location DB: - -- 先严格校验 schema -- 再严格校验 `PRAGMA user_version = 2` -- 只有全部匹配才执行 Alembic `stamp` -- 不匹配则直接失败,不自动修复 - -同时,应用启动阶段现在也会对 location DB 做保守的只读校验: - -- DB 文件不存在时拒绝启动 -- DB 尚未被 Alembic 接管时拒绝启动 -- DB revision 与当前应用预期不一致时拒绝启动 - -## Poo recorder 说明 - -当前 Python 项目已经接入: - -- `POST /poo/record` -- `GET /poo/latest` - -并对齐当前真实 baseline 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; -``` - -当前已经补上与 location 一致风格的 Alembic baseline / 接管策略: - -- `poo_records` 当前 schema 被视为 Alembic baseline -- 新数据库通过 `alembic_poo upgrade head` 初始化 -- 已有 legacy SQLite 数据库通过 `alembic stamp` 接管 -- `PRAGMA user_version = 1` 仅保留为历史事实,不再作为新的主 migration 机制 - -同时这一轮明确移除了 Notion: - -- 不迁 Notion sync -- 不迁 Notion adapter -- `POST /poo/record` 不再依赖 `tableId` 才能写入 - -详见: - -- [poo-recorder.md](poo-recorder.md) - -## 后续建议顺序 - -建议继续沿用既有迁移文档中的顺序: - -1. 先迁 `location recorder` -2. 再迁 Home Assistant 出站适配层 -3. 再迁 Home Assistant 命令网关 -4. 再迁 `poo recorder` -5. 最后迁 TickTick adapter - -## 开发约束提醒 - -- 保持对当前 Go 外部行为的兼容意识 -- 不要把旧 Python 版本当作设计基线 -- 不要重新引入 Notion 作为 Python 主系统能力 -- 在迁业务模块时,优先补 contract tests diff --git a/docs/migration-risks.md b/docs/migration-risks.md deleted file mode 100644 index 209cde0..0000000 --- a/docs/migration-risks.md +++ /dev/null @@ -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 实现,整个重构路线就会可控很多。 diff --git a/docs/python-rewrite-plan.md b/docs/python-rewrite-plan.md deleted file mode 100644 index fcec9c2..0000000 --- a/docs/python-rewrite-plan.md +++ /dev/null @@ -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 1:Python 骨架与通用基础设施 - -- 建立 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,但会影响第一阶段“兼容到什么程度”的具体定义。 diff --git a/legacy/README.md b/legacy/README.md deleted file mode 100644 index d2a6e33..0000000 --- a/legacy/README.md +++ /dev/null @@ -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_location/`、`alembic_poo/` 等目录演进 -- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础 -- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/` diff --git a/legacy/go-backend/.github/workflows/nightly.yml b/legacy/go-backend/.github/workflows/nightly.yml deleted file mode 100644 index 592bf55..0000000 --- a/legacy/go-backend/.github/workflows/nightly.yml +++ /dev/null @@ -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 ./... diff --git a/legacy/go-backend/.github/workflows/short-tests.yml b/legacy/go-backend/.github/workflows/short-tests.yml deleted file mode 100644 index 2f39588..0000000 --- a/legacy/go-backend/.github/workflows/short-tests.yml +++ /dev/null @@ -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 diff --git a/legacy/go-backend/helper/home_automation_backend_template.conf b/legacy/go-backend/helper/home_automation_backend_template.conf deleted file mode 100644 index 644c8a1..0000000 --- a/legacy/go-backend/helper/home_automation_backend_template.conf +++ /dev/null @@ -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 \ No newline at end of file diff --git a/legacy/go-backend/helper/install.sh b/legacy/go-backend/helper/install.sh deleted file mode 100755 index df0c559..0000000 --- a/legacy/go-backend/helper/install.sh +++ /dev/null @@ -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 \ No newline at end of file diff --git a/legacy/go-backend/src/LICENSE b/legacy/go-backend/src/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/go-backend/src/cmd/root.go b/legacy/go-backend/src/cmd/root.go deleted file mode 100644 index 14f8d34..0000000 --- a/legacy/go-backend/src/cmd/root.go +++ /dev/null @@ -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. -} diff --git a/legacy/go-backend/src/cmd/serve.go b/legacy/go-backend/src/cmd/serve.go deleted file mode 100644 index 6cb09b5..0000000 --- a/legacy/go-backend/src/cmd/serve.go +++ /dev/null @@ -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") -} diff --git a/legacy/go-backend/src/components/homeassistant/homeassistant.go b/legacy/go-backend/src/components/homeassistant/homeassistant.go deleted file mode 100644 index 5630b2f..0000000 --- a/legacy/go-backend/src/components/homeassistant/homeassistant.go +++ /dev/null @@ -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 -} diff --git a/legacy/go-backend/src/components/homeassistant/homeassistant_test.go b/legacy/go-backend/src/components/homeassistant/homeassistant_test.go deleted file mode 100644 index 7b28b22..0000000 --- a/legacy/go-backend/src/components/homeassistant/homeassistant_test.go +++ /dev/null @@ -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") -} diff --git a/legacy/go-backend/src/components/locationRecorder/locationRecorder.go b/legacy/go-backend/src/components/locationRecorder/locationRecorder.go deleted file mode 100644 index c88def6..0000000 --- a/legacy/go-backend/src/components/locationRecorder/locationRecorder.go +++ /dev/null @@ -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 -} diff --git a/legacy/go-backend/src/components/pooRecorder/pooRecorder.go b/legacy/go-backend/src/components/pooRecorder/pooRecorder.go deleted file mode 100644 index 3dd4bf6..0000000 --- a/legacy/go-backend/src/components/pooRecorder/pooRecorder.go +++ /dev/null @@ -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 -} diff --git a/legacy/go-backend/src/go.mod b/legacy/go-backend/src/go.mod deleted file mode 100644 index 33aa41a..0000000 --- a/legacy/go-backend/src/go.mod +++ /dev/null @@ -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 -) diff --git a/legacy/go-backend/src/go.sum b/legacy/go-backend/src/go.sum deleted file mode 100644 index 27cceef..0000000 --- a/legacy/go-backend/src/go.sum +++ /dev/null @@ -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= diff --git a/legacy/go-backend/src/helper/location_recorder/LICENSE b/legacy/go-backend/src/helper/location_recorder/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go b/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go deleted file mode 100644 index f9bdbb1..0000000 --- a/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go +++ /dev/null @@ -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") -} diff --git a/legacy/go-backend/src/helper/location_recorder/cmd/root.go b/legacy/go-backend/src/helper/location_recorder/cmd/root.go deleted file mode 100644 index 57b09c7..0000000 --- a/legacy/go-backend/src/helper/location_recorder/cmd/root.go +++ /dev/null @@ -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") -} - - diff --git a/legacy/go-backend/src/helper/location_recorder/main.go b/legacy/go-backend/src/helper/location_recorder/main.go deleted file mode 100644 index 64f4648..0000000 --- a/legacy/go-backend/src/helper/location_recorder/main.go +++ /dev/null @@ -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() -} diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/LICENSE b/legacy/go-backend/src/helper/poo_recorder_helper/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go deleted file mode 100644 index 2c03006..0000000 --- a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go +++ /dev/null @@ -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), ¬ionapi.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), ¬ionapi.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), ¬ionapi.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(¬ionToken, "token", "", "Notion API token") - reverseCmd.Flags().StringVar(¬ionTableId, "table-id", "", "Notion table id to reverse") -} diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go deleted file mode 100644 index a681b70..0000000 --- a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go +++ /dev/null @@ -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. -} diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/main.go b/legacy/go-backend/src/helper/poo_recorder_helper/main.go deleted file mode 100644 index 14e2910..0000000 --- a/legacy/go-backend/src/helper/poo_recorder_helper/main.go +++ /dev/null @@ -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() -} diff --git a/legacy/go-backend/src/main.go b/legacy/go-backend/src/main.go deleted file mode 100644 index a87c15d..0000000 --- a/legacy/go-backend/src/main.go +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu - -*/ -package main - -import "github.com/t-liu93/home-automation-backend/cmd" - -func main() { - cmd.Execute() -} diff --git a/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go b/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go deleted file mode 100644 index 280c5e8..0000000 --- a/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go +++ /dev/null @@ -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") - } -} diff --git a/legacy/go-backend/src/util/notion/notion.go b/legacy/go-backend/src/util/notion/notion.go deleted file mode 100644 index 5076ef7..0000000 --- a/legacy/go-backend/src/util/notion/notion.go +++ /dev/null @@ -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), ¬ionapi.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), ¬ionapi.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: ¬ionapi.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), ¬ionapi.AppendBlockChildrenRequest{ - After: notionapi.BlockID(after), - Children: []notionapi.Block{tableRow}, - }) - - return res.Results[0].GetID().String(), err -} diff --git a/legacy/go-backend/src/util/ticktickutil/ticktickutil.go b/legacy/go-backend/src/util/ticktickutil/ticktickutil.go deleted file mode 100644 index 23b1627..0000000 --- a/legacy/go-backend/src/util/ticktickutil/ticktickutil.go +++ /dev/null @@ -1,297 +0,0 @@ -package ticktickutil - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "time" - - "github.com/spf13/viper" -) - -const ( - DateTimeLayout = "2006-01-02T15:04:05-0700" -) - -type ( - TicktickUtil interface { - HandleAuthCode(w http.ResponseWriter, r *http.Request) - GetTasks(projectId string) []Task - HasDuplicateTask(projectId string, taskTitile string) bool - CreateTask(task Task) error - } - - TicktickUtilImpl struct { - authState string - } -) - -type ( - Project struct { - Id string `json:"id"` - Name string `json:"name"` - Color string `json:"color,omitempty"` - SortOrder int64 `json:"sortOrder,omitempty"` - Closed bool `json:"closed,omitempty"` - GroupId string `json:"groupId,omitempty"` - ViewMode string `json:"viewMode,omitempty"` - Permission string `json:"permission,omitempty"` - Kind string `json:"kind,omitempty"` - } - - Column struct { - Id string `json:"id"` - Name string `json:"name"` - ProjectId string `json:"projectId"` - SortOrder int64 `json:"sortOrder,omitempty"` - } - - Task struct { - Id string `json:"id"` - ProjectId string `json:"projectId"` - Title string `json:"title"` - IsAllDay bool `json:"isAllDay,omitempty"` - CompletedTime string `json:"completedTime,omitempty"` - Content string `json:"content,omitempty"` - Desc string `json:"desc,omitempty"` - DueDate string `json:"dueDate,omitempty"` - Items []interface{} `json:"items,omitempty"` - Priority int `json:"priority,omitempty"` - Reminders []string `json:"reminders,omitempty"` - RepeatFlag string `json:"repeatFlag,omitempty"` - SortOrder int64 `json:"sortOrder,omitempty"` - StartDate string `json:"startDate,omitempty"` - Status int32 `json:"status,omitempty"` - TimeZone string `json:"timeZone,omitempty"` - } - - ProjectData struct { - Project Project `json:"project"` - Tasks []Task `json:"tasks"` - Columns []Column `json:"columns,omitempty"` - } -) - -func Init() TicktickUtil { // TODO: Will modify Init to a proper behavior - ticktickUtilImpl := &TicktickUtilImpl{} - if !viper.InConfig("ticktick.clientId") { - slog.Error("TickTick clientId not found in config file, exiting..") - os.Exit(1) - } - if !viper.InConfig("ticktick.clientSecret") { - slog.Error("TickTick clientSecret not found in config file, exiting..") - os.Exit(1) - } - if viper.InConfig("ticktick.token") { - _, err := getProjects() - if err != nil { - if err.Error() == "error response from TickTick: 401 Unauthorized" { - } - } - } else { - ticktickUtilImpl.beginAuth() - } - return ticktickUtilImpl -} - -func (t *TicktickUtilImpl) HandleAuthCode(w http.ResponseWriter, r *http.Request) { - state := r.URL.Query().Get("state") - code := r.URL.Query().Get("code") - if state != t.authState { - slog.Warn(fmt.Sprintln("HandleAuthCode Invalid state", state)) - http.Error(w, "Invalid state", http.StatusBadRequest) - return - } - params := map[string]string{ - "code": code, - "grant_type": "authorization_code", - "scope": "tasks:read tasks:write", - "redirect_uri": viper.GetString("ticktick.redirectUri"), - } - formedParams := url.Values{} - for key, value := range params { - formedParams.Add(key, value) - } - - req, err := http.NewRequest("POST", "https://ticktick.com/oauth/token", bytes.NewBufferString(formedParams.Encode())) - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error creating request", err)) - http.Error(w, "Error creating request", http.StatusInternalServerError) - return - } - client := &http.Client{ - Timeout: time.Second * 10, - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(viper.GetString("ticktick.clientId"), viper.GetString("ticktick.clientSecret")) - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error sending request", err)) - http.Error(w, "Error sending request", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - slog.Warn(fmt.Sprintln("HandleAuthCode Unexpected response status", resp.StatusCode)) - http.Error(w, "Unexpected response status", http.StatusInternalServerError) - return - } - decoder := json.NewDecoder(resp.Body) - var tokenResponse map[string]interface{} - err = decoder.Decode(&tokenResponse) - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error decoding response", err)) - http.Error(w, "Error decoding response", http.StatusInternalServerError) - return - } - token := tokenResponse["access_token"].(string) - viper.Set("ticktick.token", token) - err = viper.WriteConfig() - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error writing config", err)) - http.Error(w, "Error writing config", http.StatusInternalServerError) - return - } - w.Write([]byte("Authorization successful")) -} - -func (t *TicktickUtilImpl) GetTasks(projectId string) []Task { - getTaskUrl := fmt.Sprintf("https://api.ticktick.com/open/v1/project/%s/data", projectId) - token := viper.GetString("ticktick.token") - req, err := http.NewRequest("GET", getTaskUrl, nil) - req.Header.Set("Authorization", "Bearer "+token) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error creating request to TickTick", err)) - return nil - } - var projectData ProjectData - client := &http.Client{ - Timeout: time.Second * 10, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error sending request to TickTick", err)) - return nil - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error response from TickTick", resp.Status)) - return nil - } - if resp.StatusCode == http.StatusNotFound { - return nil - } - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&projectData) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error decoding response from TickTick", err)) - return nil - } - - return projectData.Tasks -} - -func (t *TicktickUtilImpl) HasDuplicateTask(projectId string, taskTitile string) bool { - tasks := t.GetTasks(projectId) - for _, task := range tasks { - if task.Title == taskTitile { - return true - } - } - return false -} - -func (t *TicktickUtilImpl) CreateTask(task Task) error { - if t.HasDuplicateTask(task.ProjectId, task.Title) { - return nil - } - token := viper.GetString("ticktick.token") - createTaskUrl := "https://api.ticktick.com/open/v1/task" - payload, err := json.Marshal(task) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error marshalling", err)) - return err - } - req, err := http.NewRequest("POST", createTaskUrl, bytes.NewBuffer(payload)) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error creating request to TickTick", err)) - return err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Timeout: time.Second * 10, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error sending request to TickTick", err)) - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error response from TickTick", resp.Status)) - return fmt.Errorf("error response from TickTick: %s", resp.Status) - } - return nil -} - -func getProjects() ([]Project, error) { - token := viper.GetString("ticktick.token") - req, err := http.NewRequest("GET", "https://api.ticktick.com/open/v1/project/", nil) - req.Header.Set("Authorization", "Bearer "+token) - if err != nil { - slog.Warn(fmt.Sprintln("Error creating request to TickTick", err)) - return nil, err - } - client := &http.Client{ - Timeout: time.Second * 10, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("Error sending request to TickTick", err)) - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - slog.Warn(fmt.Sprintln("Error response from TickTick", resp.Status)) - return nil, fmt.Errorf("error response from TickTick: %s", resp.Status) - } - var projects []Project - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&projects) - if err != nil { - slog.Warn(fmt.Sprintln("Error decoding response from TickTick", err)) - return nil, err - } - return projects, nil -} - -func (t *TicktickUtilImpl) beginAuth() { - if !viper.InConfig("ticktick.redirectUri") { - slog.Error("TickTick redirectUri not found in config file, exiting..") - os.Exit(1) - } - baseUrl := "https://ticktick.com/oauth/authorize?" - authUrl, _ := url.Parse(baseUrl) - authStateBytes := make([]byte, 6) - _, err := rand.Read(authStateBytes) - if err != nil { - slog.Error(fmt.Sprintln("Error generating auth state", err)) - os.Exit(1) - } - t.authState = hex.EncodeToString(authStateBytes) - params := url.Values{} - params.Add("client_id", viper.GetString("ticktick.clientId")) - params.Add("response_type", "code") - params.Add("redirect_uri", viper.GetString("ticktick.redirectUri")) - params.Add("state", t.authState) - params.Add("scope", "tasks:read tasks:write") - authUrl.RawQuery = params.Encode() - slog.Info(fmt.Sprintln("Please visit the following URL to authorize TickTick:", authUrl.String())) -} diff --git a/openapi/openapi.json b/openapi/openapi.json index 2e87da7..b03671e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Home Automation Backend (Python)", - "description": "Python rewrite skeleton for the home automation backend. This stage provides only the foundation for future module migration.", + "description": "Home automation backend with auth, runtime config, Home Assistant integrations, TickTick integration, and SQLite-backed recorders.", "version": "0.1.0" }, "paths": { @@ -324,6 +324,44 @@ } } } + }, + "/ticktick/auth/start": { + "get": { + "tags": [ + "ticktick" + ], + "summary": "Start Ticktick Auth", + "operationId": "start_ticktick_auth_ticktick_auth_start_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/ticktick/auth/code": { + "get": { + "tags": [ + "ticktick" + ], + "summary": "Handle Ticktick Auth Code", + "operationId": "handle_ticktick_auth_code_ticktick_auth_code_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } } }, "components": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9c8f4cb..b0dde44 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,8 +1,8 @@ openapi: 3.1.0 info: title: Home Automation Backend (Python) - description: Python rewrite skeleton for the home automation backend. This stage - provides only the foundation for future module migration. + description: Home automation backend with auth, runtime config, Home Assistant integrations, + TickTick integration, and SQLite-backed recorders. version: 0.1.0 paths: /status: @@ -203,6 +203,30 @@ paths: content: application/json: schema: {} + /ticktick/auth/start: + get: + tags: + - ticktick + summary: Start Ticktick Auth + operationId: start_ticktick_auth_ticktick_auth_start_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + /ticktick/auth/code: + get: + tags: + - ticktick + summary: Handle Ticktick Auth Code + operationId: handle_ticktick_auth_code_ticktick_auth_code_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} components: schemas: Body_change_password_submit_config_change_password_post: diff --git a/pyproject.toml b/pyproject.toml index 3eb7047..9979338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-automation-python" version = "0.1.0" -description = "Python rewrite skeleton for the home automation backend." +description = "Home automation backend with auth, integrations, and SQLite-backed services." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_app.py b/tests/test_app.py index 9e7ba37..cd9900d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -124,6 +124,48 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va reset_auth_db_caches() +def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + app_database_url = _prepare_app_db(tmp_path) + location_database_path = tmp_path / "location_ready.db" + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head") + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + + app_database_path = tmp_path / "app_ready.db" + conn = sqlite3.connect(app_database_path) + conn.execute( + "INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + ("APP_HOSTNAME", "old.example.com"), + ) + conn.commit() + conn.close() + + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") + monkeypatch.setenv("APP_HOSTNAME", "new.example.com") + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") + get_settings.cache_clear() + reset_auth_db_caches() + + app = create_app() + anyio.run(_run_lifespan, app) + + conn = sqlite3.connect(app_database_path) + try: + rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall()) + finally: + conn.close() + + assert rows["APP_HOSTNAME"] == "new.example.com" + + get_settings.cache_clear() + reset_auth_db_caches() + + def test_app_start_fails_when_location_db_missing( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: