Finalize first Python release
This commit is contained in:
+23
-13
@@ -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=
|
||||
|
||||
+2
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Application package for the Python rewrite skeleton."""
|
||||
"""Application package for the home automation backend."""
|
||||
|
||||
|
||||
+4
-3
@@ -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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
|
||||
+6
-8
@@ -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
|
||||
|
||||
Executable
+9
@@ -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
|
||||
@@ -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 配置文件
|
||||
@@ -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
|
||||
@@ -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 实现,整个重构路线就会可控很多。
|
||||
@@ -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,但会影响第一阶段“兼容到什么程度”的具体定义。
|
||||
@@ -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/`
|
||||
-22
@@ -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 ./...
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Run short tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run short tests with coverage
|
||||
working-directory: ./src
|
||||
run: | # TODO: at this moment only Home Assistant component is tested
|
||||
go test -v --short ./components/homeassistant/... -cover -coverprofile=cover.out
|
||||
@@ -1,15 +0,0 @@
|
||||
[program:home_automation_backend]
|
||||
command=
|
||||
directory=
|
||||
user=
|
||||
group=
|
||||
environment=
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=15
|
||||
startretries=100
|
||||
stopwaitsecs=30
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/supervisor/%(program_name)s.log
|
||||
stdout_logfile_maxbytes=5MB
|
||||
stdout_logfile_backups=5
|
||||
@@ -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
|
||||
@@ -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.
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/t-liu93/home-automation-backend/components/homeassistant"
|
||||
"github.com/t-liu93/home-automation-backend/components/locationRecorder"
|
||||
"github.com/t-liu93/home-automation-backend/components/pooRecorder"
|
||||
"github.com/t-liu93/home-automation-backend/util/notion"
|
||||
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
|
||||
)
|
||||
|
||||
var (
|
||||
port string
|
||||
scheduler gocron.Scheduler
|
||||
ticktick ticktickutil.TicktickUtil
|
||||
ha *homeassistant.HomeAssistant
|
||||
)
|
||||
|
||||
// serveCmd represents the serve command
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Server automation backend",
|
||||
Run: serve,
|
||||
}
|
||||
|
||||
func initUtil() {
|
||||
// init notion
|
||||
if viper.InConfig("notion.token") {
|
||||
notion.Init(viper.GetString("notion.token"))
|
||||
} else {
|
||||
slog.Error("Notion token not found in config file, exiting..")
|
||||
os.Exit(1)
|
||||
}
|
||||
// init ticktick
|
||||
ticktick = ticktickutil.Init()
|
||||
}
|
||||
|
||||
func initComponent() {
|
||||
// init pooRecorder
|
||||
pooRecorder.Init(&scheduler)
|
||||
// init location recorder
|
||||
locationRecorder.Init()
|
||||
// init homeassistant
|
||||
ha = homeassistant.NewHomeAssistant(ticktick)
|
||||
}
|
||||
|
||||
func serve(cmd *cobra.Command, args []string) {
|
||||
slog.Info("Starting server..")
|
||||
|
||||
viper.SetConfigName("config") // name of config file (without extension)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(".") // . is used for dev
|
||||
viper.AddConfigPath("$HOME/.config/home-automation")
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("Cannot read config file, %s, exiting..", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
viper.WatchConfig()
|
||||
viper.SetDefault("logLevel", "info")
|
||||
logLevelCfg := viper.GetString("logLevel")
|
||||
switch logLevelCfg {
|
||||
case "debug":
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
case "info":
|
||||
slog.SetLogLoggerLevel(slog.LevelInfo)
|
||||
case "warn":
|
||||
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||
case "error":
|
||||
slog.SetLogLoggerLevel(slog.LevelError)
|
||||
}
|
||||
|
||||
if viper.InConfig("port") {
|
||||
port = viper.GetString("port")
|
||||
} else {
|
||||
slog.Error("Port not found in config file, exiting..")
|
||||
os.Exit(1)
|
||||
}
|
||||
scheduler, err = gocron.NewScheduler()
|
||||
defer scheduler.Shutdown()
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("Cannot create scheduler, %s, exiting..", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
initUtil()
|
||||
initComponent()
|
||||
scheduler.Start()
|
||||
|
||||
// routing
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
}).Methods("GET")
|
||||
|
||||
router.HandleFunc("/poo/latest", pooRecorder.HandleNotifyLatestPoo).Methods("GET")
|
||||
router.HandleFunc("/poo/record", pooRecorder.HandleRecordPoo).Methods("POST")
|
||||
router.HandleFunc("/homeassistant/publish", ha.HandleHaMessage).Methods("POST")
|
||||
|
||||
router.HandleFunc("/location/record", locationRecorder.HandleRecordLocation).Methods("POST")
|
||||
|
||||
router.HandleFunc("/ticktick/auth/code", ticktick.HandleAuthCode).Methods("GET")
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error(fmt.Sprintf("ListenAndServe error: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info(fmt.Sprintln("Server started on port", port))
|
||||
|
||||
<-stop
|
||||
|
||||
slog.Info(fmt.Sprintln("Shutting down the server..."))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error(fmt.Sprintf("Server Shutdown Failed:%+v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info(fmt.Sprintln("Server gracefully stopped"))
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
|
||||
// Here you will define your flags and configuration settings.
|
||||
|
||||
// Cobra supports Persistent Flags which will work for this command
|
||||
// and all subcommands, e.g.:
|
||||
// serveCmd.PersistentFlags().String("foo", "", "A help for foo")
|
||||
|
||||
// Cobra supports local flags which will only run when this command
|
||||
// is called directly, e.g.:
|
||||
// serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||
serveCmd.Flags().StringVarP(&port, "port", "p", "18881", "Port to listen on")
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package homeassistant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
|
||||
)
|
||||
|
||||
type haMessage struct {
|
||||
Target string `json:"target"`
|
||||
Action string `json:"action"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type HomeAssistant struct {
|
||||
ticktickUtil ticktickutil.TicktickUtil
|
||||
}
|
||||
|
||||
type actionTask struct {
|
||||
Action string `json:"action"`
|
||||
DueHour int `json:"due_hour"`
|
||||
}
|
||||
|
||||
func NewHomeAssistant(ticktick ticktickutil.TicktickUtil) *HomeAssistant {
|
||||
return &HomeAssistant{
|
||||
ticktickUtil: ticktick,
|
||||
}
|
||||
}
|
||||
|
||||
func (ha *HomeAssistant) HandleHaMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var message haMessage
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(&message)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error decoding request body", err))
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch message.Target {
|
||||
case "poo_recorder":
|
||||
res := ha.handlePooRecorderMsg(message)
|
||||
if !res {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling poo recorder message"))
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
}
|
||||
case "location_recorder":
|
||||
res := ha.handleLocationRecorderMsg(message)
|
||||
if !res {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling location recorder message"))
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
}
|
||||
case "ticktick":
|
||||
res := ha.handleTicktickMsg(message)
|
||||
if !res {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling ticktick message"))
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
}
|
||||
default:
|
||||
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Unknown target", message.Target))
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (ha *HomeAssistant) handlePooRecorderMsg(message haMessage) bool {
|
||||
switch message.Action {
|
||||
case "get_latest":
|
||||
return ha.handleGetLatestPoo()
|
||||
default:
|
||||
slog.Warn(fmt.Sprintln("homeassistant.handlePooRecorderMsg: Unknown action", message.Action))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (ha *HomeAssistant) handleLocationRecorderMsg(message haMessage) bool {
|
||||
if message.Action == "record" {
|
||||
port := viper.GetString("port")
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 1,
|
||||
}
|
||||
_, err := client.Post("http://localhost:"+port+"/location/record", "application/json", strings.NewReader(strings.ReplaceAll(message.Content, "'", "\"")))
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Error sending request to location recorder", err))
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Unknown action", message.Action))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (ha *HomeAssistant) handleTicktickMsg(message haMessage) bool {
|
||||
switch message.Action {
|
||||
case "create_action_task":
|
||||
return ha.createActionTask(message)
|
||||
default:
|
||||
slog.Warn(fmt.Sprintln("homeassistant.handleTicktickMsg: Unknown action", message.Action))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (ha *HomeAssistant) handleGetLatestPoo() bool {
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 1,
|
||||
}
|
||||
port := viper.GetString("port")
|
||||
_, err := client.Get("http://localhost:" + port + "/poo/latest")
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.handleGetLatestPoo: Error sending request to poo recorder", err))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (ha *HomeAssistant) createActionTask(message haMessage) bool {
|
||||
if !viper.IsSet("homeassistant.actionTaskProjectId") {
|
||||
slog.Warn("homeassistant.createActionTask: actionTaskProjectId not found in config file")
|
||||
return false
|
||||
}
|
||||
projectId := viper.GetString("homeassistant.actionTaskProjectId")
|
||||
detail := strings.ReplaceAll(message.Content, "'", "\"")
|
||||
var task actionTask
|
||||
err := json.Unmarshal([]byte(detail), &task)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("homeassistant.createActionTask: Error unmarshalling", err))
|
||||
return false
|
||||
}
|
||||
dueHour := task.DueHour
|
||||
due := time.Now().Add(time.Hour * time.Duration(dueHour))
|
||||
dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
|
||||
dueTicktick := dueNextMidnight.UTC().Format(ticktickutil.DateTimeLayout)
|
||||
ticktickTask := ticktickutil.Task{
|
||||
ProjectId: projectId,
|
||||
Title: task.Action,
|
||||
DueDate: dueTicktick,
|
||||
}
|
||||
err = ha.ticktickUtil.CreateTask(ticktickTask)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintf("homeassistant.createActionTask: Error creating task %s", err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
package homeassistant
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
|
||||
)
|
||||
|
||||
var (
|
||||
loggerText = new(bytes.Buffer)
|
||||
)
|
||||
|
||||
type MockTicktickUtil struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockTicktickUtil) HandleAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
m.Called(w, r)
|
||||
}
|
||||
|
||||
func (m *MockTicktickUtil) GetTasks(projectId string) []ticktickutil.Task {
|
||||
args := m.Called(projectId)
|
||||
return args.Get(0).([]ticktickutil.Task)
|
||||
}
|
||||
|
||||
func (m *MockTicktickUtil) HasDuplicateTask(projectId string, taskTitile string) bool {
|
||||
args := m.Called(projectId, taskTitile)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
func (m *MockTicktickUtil) CreateTask(task ticktickutil.Task) error {
|
||||
args := m.Called(task)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func SetupTearDown(t *testing.T) (func(), *HomeAssistant) {
|
||||
loggertearDown := loggerSetupTeardown()
|
||||
mockTicktick := &MockTicktickUtil{}
|
||||
ha := NewHomeAssistant(mockTicktick)
|
||||
|
||||
return func() {
|
||||
loggertearDown()
|
||||
viper.Reset()
|
||||
}, ha
|
||||
}
|
||||
|
||||
func loggerSetupTeardown() func() {
|
||||
logger := slog.New(slog.NewTextHandler(loggerText, nil))
|
||||
defaultLogger := slog.Default()
|
||||
slog.SetDefault(logger)
|
||||
|
||||
return func() {
|
||||
slog.SetDefault(defaultLogger)
|
||||
loggerText.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHaMessageJsonDecodeError(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
invalidRequestBody := ` { "target": "poo_recorder", "action": "get_latest", "content": " }`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Error decoding request body")
|
||||
}
|
||||
|
||||
func TestHandlePooRecorderMsgGetLatest(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/poo/latest", r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
port := strings.Split(server.URL, ":")[2]
|
||||
viper.Set("port", port)
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Empty(t, loggerText.String())
|
||||
}
|
||||
|
||||
func TestHandlePooRecorderMsgUnknownAction(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "poo_recorder", "action": "unknown_action", "content": ""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.handlePooRecorderMsg: Unknown action")
|
||||
}
|
||||
|
||||
func TestHandlePooRecorderMsgGetLatestError(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
port := "invalid port"
|
||||
viper.Set("port", port)
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.handleGetLatestPoo: Error sending request to poo recorder")
|
||||
}
|
||||
|
||||
func TestHandleLocationRecorderMsg(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/location/record", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
port := strings.Split(server.URL, ":")[2]
|
||||
viper.Set("port", port)
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Empty(t, loggerText.String())
|
||||
}
|
||||
|
||||
func TestHandleLocationRecorderMsgUnknownAction(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "location_recorder", "action": "unknown_action", "content": ""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Unknown action")
|
||||
}
|
||||
|
||||
func TestHandleLocationRecorderMsgRequestErr(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
port := "invalid port"
|
||||
viper.Set("port", port)
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Error sending request to location recorder")
|
||||
}
|
||||
|
||||
func TestHandleTicktickMsgCreateActionTask(t *testing.T) {
|
||||
teardown, _ := SetupTearDown(t)
|
||||
defer teardown()
|
||||
const expectedProjectId = "test_project_id"
|
||||
const dueHour = 12
|
||||
due := time.Now().Add(time.Hour * time.Duration(dueHour))
|
||||
dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
|
||||
dueTicktick := dueNextMidnight.UTC().Format(ticktickutil.DateTimeLayout)
|
||||
|
||||
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
mockTicktick := &MockTicktickUtil{}
|
||||
mockTicktick.On("CreateTask", mock.Anything).Return(nil)
|
||||
ha := NewHomeAssistant(mockTicktick)
|
||||
viper.Set("homeassistant.actionTaskProjectId", expectedProjectId)
|
||||
ha.HandleHaMessage(w, req)
|
||||
expectedTask := ticktickutil.Task{
|
||||
Title: "test_action",
|
||||
DueDate: dueTicktick,
|
||||
ProjectId: expectedProjectId,
|
||||
}
|
||||
mockTicktick.AssertCalled(t, "CreateTask", expectedTask)
|
||||
mockTicktick.AssertNumberOfCalls(t, "CreateTask", 1)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Empty(t, loggerText.String())
|
||||
}
|
||||
|
||||
func TestHandleTicktickMsgUnknownAction(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "ticktick", "action": "unknown_action", "content": ""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.handleTicktickMsg: Unknown action")
|
||||
}
|
||||
|
||||
func TestHandleTicktickMsgProjectIdUnset(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: actionTaskProjectId not found in config file")
|
||||
}
|
||||
|
||||
func TestHandleTicktickMsgJsonError(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
invalidRequestBody := ` { "target": "ticktick", "action": "create_action_task", "content": "{'title': 'tes, 'action': 'test_action', 'due_hour': 12}"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody))
|
||||
w := httptest.NewRecorder()
|
||||
viper.Set("homeassistant.actionTaskProjectId", "some project id")
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error unmarshalling")
|
||||
}
|
||||
|
||||
func TestHandleTicktickMsgTicktickUtilErr(t *testing.T) {
|
||||
teardown, _ := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
mockedTicktickUtil := &MockTicktickUtil{}
|
||||
viper.Set("homeassistant.actionTaskProjectId", "some project id")
|
||||
|
||||
mockedTicktickUtil.On("CreateTask", mock.Anything).Return(errors.New("some error"))
|
||||
|
||||
ha := NewHomeAssistant(mockedTicktickUtil)
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
|
||||
mockedTicktickUtil.AssertCalled(t, "CreateTask", mock.Anything)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error creating task")
|
||||
}
|
||||
|
||||
func TestHandleHaMessageUnknownTarget(t *testing.T) {
|
||||
teardown, ha := SetupTearDown(t)
|
||||
defer teardown()
|
||||
|
||||
requestBody := `{"target": "unknown_target", "action": "record", "content": ""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ha.HandleHaMessage(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Unknown target")
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package locationRecorder
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
const (
|
||||
currentDBVersion = 2
|
||||
)
|
||||
|
||||
type Location struct {
|
||||
Person string `json:"person"`
|
||||
DateTime string `json:"datetime"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude sql.NullFloat64 `json:"altitude,omitempty"`
|
||||
}
|
||||
|
||||
type LocationContent struct {
|
||||
Person string `json:"person"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
Altitude string `json:"altitude,omitempty"`
|
||||
}
|
||||
|
||||
func Init() {
|
||||
initDb()
|
||||
}
|
||||
|
||||
func HandleRecordLocation(w http.ResponseWriter, r *http.Request) {
|
||||
var location LocationContent
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(&location)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleRecordLocation Error decoding request body", err))
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
latiF64, _ := strconv.ParseFloat(location.Latitude, 64)
|
||||
longiF64, _ := strconv.ParseFloat(location.Longitude, 64)
|
||||
altiF64, _ := strconv.ParseFloat(location.Altitude, 64)
|
||||
InsertLocationNow(location.Person, latiF64, longiF64, altiF64)
|
||||
}
|
||||
|
||||
func InsertLocation(person string, datetime time.Time, latitude float64, longitude float64, altitude float64) {
|
||||
_, err := db.Exec(`INSERT OR IGNORE INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`,
|
||||
person, datetime.UTC().Format(time.RFC3339), latitude, longitude, altitude)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorder.InsertLocation Error inserting location", err))
|
||||
}
|
||||
}
|
||||
|
||||
func InsertLocationNow(person string, latitude float64, longitude float64, altitude float64) {
|
||||
InsertLocation(person, time.Now(), latitude, longitude, altitude)
|
||||
}
|
||||
|
||||
func initDb() {
|
||||
if !viper.InConfig("locationRecorder.dbPath") {
|
||||
slog.Info("LocationRecorderInit dbPath not found in config file, using default: location_recorder.db")
|
||||
viper.SetDefault("locationRecorder.dbPath", "location_recorder.db")
|
||||
}
|
||||
|
||||
dbPath := viper.GetString("locationRecorder.dbPath")
|
||||
err := error(nil)
|
||||
db, err = sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit Error opening database", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit Error pinging database", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
migrateDb()
|
||||
}
|
||||
|
||||
func migrateDb() {
|
||||
var userVersion int
|
||||
err := db.QueryRow("PRAGMA user_version").Scan(&userVersion)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit Error getting db user version", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
if userVersion == 0 {
|
||||
migrateDb0To1(&userVersion)
|
||||
}
|
||||
if userVersion == 1 {
|
||||
migrateDb1To2(&userVersion)
|
||||
}
|
||||
if userVersion != currentDBVersion {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit Error unsupported database version", userVersion))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDb0To1(userVersion *int) {
|
||||
// this is actually create new db
|
||||
slog.Info("Creating location recorder database version 1..")
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS location (
|
||||
person TEXT NOT NULL,
|
||||
datetime TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
altitude REAL,
|
||||
PRIMARY KEY (person, datetime))`)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error creating table", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = db.Exec(`PRAGMA user_version = 1`)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error setting user version to 1", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
*userVersion = 1
|
||||
}
|
||||
|
||||
func migrateDb1To2(userVersion *int) {
|
||||
// this will change the datetime format into Real RFC3339
|
||||
slog.Info("Migrating location recorder database version 1 to 2..")
|
||||
dbTx, err := db.Begin()
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit DB1To2 Error beginning transaction", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
fail := func(err error, step string) {
|
||||
slog.Error(fmt.Sprintf("LocationRecorderInit DB1To2 Error %s: %s", step, err))
|
||||
dbTx.Rollback()
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = dbTx.Exec(`ALTER TABLE location RENAME TO location_old`)
|
||||
if err != nil {
|
||||
fail(err, "renaming table")
|
||||
}
|
||||
_, err = dbTx.Exec(`CREATE TABLE IF NOT EXISTS location (
|
||||
person TEXT NOT NULL,
|
||||
datetime TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
altitude REAL,
|
||||
PRIMARY KEY (person, datetime))`)
|
||||
if err != nil {
|
||||
fail(err, "creating new table")
|
||||
}
|
||||
row, err := dbTx.Query(`SELECT person, datetime, latitude, longitude, altitude FROM location_old`)
|
||||
if err != nil {
|
||||
fail(err, "selecting from old table")
|
||||
}
|
||||
defer row.Close()
|
||||
for row.Next() {
|
||||
var location Location
|
||||
err = row.Scan(&location.Person, &location.DateTime, &location.Latitude, &location.Longitude, &location.Altitude)
|
||||
if err != nil {
|
||||
fail(err, "scanning row")
|
||||
}
|
||||
dateTime, err := time.Parse("2006-01-02T15:04:05-0700", location.DateTime)
|
||||
if err != nil {
|
||||
fail(err, "parsing datetime")
|
||||
}
|
||||
_, err = dbTx.Exec(`INSERT INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, location.Person, dateTime.UTC().Format(time.RFC3339), location.Latitude, location.Longitude, location.Altitude)
|
||||
if err != nil {
|
||||
fail(err, "inserting new row")
|
||||
}
|
||||
}
|
||||
|
||||
_, err = dbTx.Exec(`DROP TABLE location_old`)
|
||||
if err != nil {
|
||||
fail(err, "dropping old table")
|
||||
}
|
||||
|
||||
_, err = dbTx.Exec(`PRAGMA user_version = 2`)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("LocationRecorderInit Error setting user version to 2", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
dbTx.Commit()
|
||||
*userVersion = 2
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package pooRecorder
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/jomei/notionapi"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/t-liu93/home-automation-backend/util/homeassistantutil"
|
||||
"github.com/t-liu93/home-automation-backend/util/notion"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
scheduler *gocron.Scheduler
|
||||
)
|
||||
|
||||
type recordDetail struct {
|
||||
Status string `json:"status"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
}
|
||||
|
||||
type pooStatusSensorAttributes struct {
|
||||
LastPoo string `json:"last_poo"`
|
||||
FriendlyName string `json:"friendly_name,"`
|
||||
}
|
||||
|
||||
type pooStatusWebhookBody struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type pooStatusDbEntry struct {
|
||||
Timestamp string
|
||||
Status string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
func Init(mainScheduler *gocron.Scheduler) {
|
||||
initDb()
|
||||
initScheduler(mainScheduler)
|
||||
notionDbSync()
|
||||
publishLatestPooSensor()
|
||||
}
|
||||
|
||||
func HandleRecordPoo(w http.ResponseWriter, r *http.Request) {
|
||||
var record recordDetail
|
||||
if !viper.InConfig("pooRecorder.tableId") {
|
||||
slog.Warn("HandleRecordPoo Table ID not found in config file")
|
||||
http.Error(w, "Table ID not found in config file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(&record)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleRecordPoo Error decoding request body", err))
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
err = storeStatus(record, now)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleRecordPoo Error storing status", err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
publishLatestPooSensor()
|
||||
if viper.InConfig("pooRecorder.webhookId") {
|
||||
homeassistantutil.TriggerWebhook(viper.GetString("pooRecorder.webhookId"), pooStatusWebhookBody{Status: record.Status})
|
||||
} else {
|
||||
slog.Warn("HandleRecordPoo Webhook ID not found in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func HandleNotifyLatestPoo(w http.ResponseWriter, r *http.Request) {
|
||||
err := publishLatestPooSensor()
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleNotifyLatestPoo Error publishing latest poo", err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Debug(fmt.Sprintln("HandleGetLatestPoo Latest poo"))
|
||||
}
|
||||
|
||||
func publishLatestPooSensor() error {
|
||||
var latest pooStatusDbEntry
|
||||
err := db.QueryRow(`SELECT timestamp, status, latitude, longitude FROM poo_records ORDER BY timestamp DESC LIMIT 1`).Scan(&latest.Timestamp, &latest.Status, &latest.Latitude, &latest.Longitude)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error getting latest poo", err))
|
||||
return err
|
||||
}
|
||||
recordTime, err := time.Parse("2006-01-02T15:04Z07:00", latest.Timestamp)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error parsing timestamp", err))
|
||||
return err
|
||||
}
|
||||
viper.SetDefault("pooRecorder.sensorEntityName", "sensor.test_poo_status")
|
||||
viper.SetDefault("pooRecorder.sensorFriendlyName", "Poo Status")
|
||||
sensorEntityName := viper.GetString("pooRecorder.sensorEntityName")
|
||||
sensorFriendlyName := viper.GetString("pooRecorder.sensorFriendlyName")
|
||||
recordTime = recordTime.Local()
|
||||
pooStatus := homeassistantutil.HttpSensor{
|
||||
EntityId: sensorEntityName,
|
||||
State: latest.Status,
|
||||
Attributes: pooStatusSensorAttributes{
|
||||
LastPoo: recordTime.Format("Mon | 2006-01-02 | 15:04"),
|
||||
FriendlyName: sensorFriendlyName,
|
||||
},
|
||||
}
|
||||
homeassistantutil.PublishSensor(pooStatus)
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDb() {
|
||||
if !viper.InConfig("pooRecorder.dbPath") {
|
||||
slog.Info("PooRecorderInit dbPath not found in config file, using default: pooRecorder.db")
|
||||
viper.SetDefault("pooRecorder.dbPath", "pooRecorder.db")
|
||||
}
|
||||
|
||||
dbPath := viper.GetString("pooRecorder.dbPath")
|
||||
err := error(nil)
|
||||
db, err = sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderInit Error opening database", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderInit Error pinging database", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
migrateDb()
|
||||
}
|
||||
|
||||
func migrateDb() {
|
||||
var userVersion int
|
||||
err := db.QueryRow("PRAGMA user_version").Scan(&userVersion)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderInit Error getting db user version", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
if userVersion == 0 {
|
||||
migrateDb0To1(&userVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDb0To1(userVersion *int) {
|
||||
// this is actually create new db
|
||||
slog.Info("Creating database version 1..")
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS poo_records (
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
PRIMARY KEY (timestamp))`)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderInit Error creating table", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = db.Exec(`PRAGMA user_version = 1`)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderInit Error setting user version to 1", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
*userVersion = 1
|
||||
}
|
||||
|
||||
func initScheduler(mainScheduler *gocron.Scheduler) {
|
||||
scheduler = mainScheduler
|
||||
_, err := (*scheduler).NewJob(gocron.CronJob("0 5 * * *", false), gocron.NewTask(
|
||||
notionDbSync,
|
||||
))
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderInit Error creating scheduled task", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func notionDbSync() {
|
||||
slog.Info("PooRecorder Running DB sync with Notion..")
|
||||
if !viper.InConfig("pooRecorder.tableId") {
|
||||
slog.Warn("PooRecorder Table ID not found in config file, sync aborted")
|
||||
return
|
||||
}
|
||||
tableId := viper.GetString("pooRecorder.tableId")
|
||||
rowsNotion, err := notion.GetAllTableRows(tableId)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get table header", err))
|
||||
return
|
||||
}
|
||||
header := rowsNotion[0]
|
||||
rowsNotion = rowsNotion[1:] // remove header
|
||||
rowsDb, err := db.Query(`SELECT * FROM poo_records`)
|
||||
rowsDbMap := make(map[string]pooStatusDbEntry)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err))
|
||||
return
|
||||
}
|
||||
defer rowsDb.Close()
|
||||
for rowsDb.Next() {
|
||||
var row pooStatusDbEntry
|
||||
err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err))
|
||||
return
|
||||
}
|
||||
rowsDbMap[row.Timestamp] = row
|
||||
}
|
||||
// notion to db
|
||||
syncNotionToDb(rowsNotion, rowsDbMap)
|
||||
|
||||
// db to notion
|
||||
syncDbToNotion(header.GetID().String(), tableId, rowsNotion)
|
||||
|
||||
}
|
||||
|
||||
func syncNotionToDb(rowsNotion []notionapi.TableRowBlock, rowsDbMap map[string]pooStatusDbEntry) {
|
||||
counter := 0
|
||||
for _, rowNotion := range rowsNotion {
|
||||
rowNotionTimestamp := rowNotion.TableRow.Cells[0][0].PlainText + "T" + rowNotion.TableRow.Cells[1][0].PlainText
|
||||
rowNotionTime, err := time.ParseInLocation("2006-01-02T15:04", rowNotionTimestamp, time.Now().Location())
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse timestamp", err))
|
||||
return
|
||||
}
|
||||
rowNotionTimeInDbFormat := rowNotionTime.UTC().Format("2006-01-02T15:04Z07:00")
|
||||
_, exists := rowsDbMap[rowNotionTimeInDbFormat]
|
||||
if !exists {
|
||||
locationNotion := rowNotion.TableRow.Cells[3][0].PlainText
|
||||
latitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[0], 64)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse latitude to float", err))
|
||||
return
|
||||
}
|
||||
longitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[1], 64)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse longitude to float", err))
|
||||
return
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`,
|
||||
rowNotionTimeInDbFormat, rowNotion.TableRow.Cells[2][0].PlainText, latitude, longitude)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to insert new row", err))
|
||||
return
|
||||
}
|
||||
counter++
|
||||
}
|
||||
}
|
||||
slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from Notion to DB"))
|
||||
}
|
||||
|
||||
func syncDbToNotion(headerId string, tableId string, rowsNotion []notionapi.TableRowBlock) {
|
||||
counter := 0
|
||||
var rowsDbSlice []pooStatusDbEntry
|
||||
rowsDb, err := db.Query(`SELECT * FROM poo_records ORDER BY timestamp DESC`)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err))
|
||||
return
|
||||
}
|
||||
defer rowsDb.Close()
|
||||
for rowsDb.Next() {
|
||||
var row pooStatusDbEntry
|
||||
err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err))
|
||||
return
|
||||
}
|
||||
rowsDbSlice = append(rowsDbSlice, row)
|
||||
}
|
||||
startFromId := headerId
|
||||
for iNotion, iDb := 0, 0; iNotion < len(rowsNotion) && iDb < len(rowsDbSlice); {
|
||||
notionTimeStamp := rowsNotion[iNotion].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion].TableRow.Cells[1][0].PlainText
|
||||
notionTime, err := time.ParseInLocation("2006-01-02T15:04", notionTimeStamp, time.Now().Location())
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse notion timestamp", err))
|
||||
return
|
||||
}
|
||||
notionTimeStampInDbFormat := notionTime.UTC().Format("2006-01-02T15:04Z07:00")
|
||||
dbTimeStamp := rowsDbSlice[iDb].Timestamp
|
||||
dbTime, err := time.Parse("2006-01-02T15:04Z07:00", dbTimeStamp)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse db timestamp", err))
|
||||
return
|
||||
}
|
||||
dbTimeLocal := dbTime.Local()
|
||||
dbTimeDate := dbTimeLocal.Format("2006-01-02")
|
||||
dbTimeTime := dbTimeLocal.Format("15:04")
|
||||
if notionTimeStampInDbFormat == dbTimeStamp {
|
||||
startFromId = rowsNotion[iNotion].GetID().String()
|
||||
iNotion++
|
||||
iDb++
|
||||
continue
|
||||
}
|
||||
if iNotion != len(rowsNotion)-1 {
|
||||
notionNextTimeStamp := rowsNotion[iNotion+1].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion+1].TableRow.Cells[1][0].PlainText
|
||||
notionNextTime, err := time.ParseInLocation("2006-01-02T15:04", notionNextTimeStamp, time.Now().Location())
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse next notion timestamp", err))
|
||||
return
|
||||
}
|
||||
if notionNextTime.After(notionTime) {
|
||||
slog.Error(fmt.Sprintf("PooRecorderSyncDb Notion timestamp %s is after next timestamp %s, checking, aborting", notionTimeStamp, notionNextTimeStamp))
|
||||
return
|
||||
}
|
||||
}
|
||||
id, err := notion.WriteTableRow([]string{
|
||||
dbTimeDate,
|
||||
dbTimeTime,
|
||||
rowsDbSlice[iDb].Status,
|
||||
fmt.Sprintf("%s,%s",
|
||||
strconv.FormatFloat(rowsDbSlice[iDb].Latitude, 'f', -1, 64),
|
||||
strconv.FormatFloat(rowsDbSlice[iDb].Longitude, 'f', -1, 64))},
|
||||
tableId,
|
||||
startFromId)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to write row to Notion", err))
|
||||
return
|
||||
}
|
||||
startFromId = id
|
||||
iDb++
|
||||
counter++
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from DB to Notion"))
|
||||
}
|
||||
|
||||
func storeStatus(record recordDetail, timestamp time.Time) error {
|
||||
tableId := viper.GetString("pooRecorder.tableId")
|
||||
recordDate := timestamp.Format("2006-01-02")
|
||||
recordTime := timestamp.Format("15:04")
|
||||
slog.Debug(fmt.Sprintln("Recording poo", record.Status, "at", record.Latitude, record.Longitude))
|
||||
_, err := db.Exec(`INSERT OR IGNORE INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`,
|
||||
timestamp.UTC().Format("2006-01-02T15:04Z07:00"), record.Status, record.Latitude, record.Longitude)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
header, err := notion.GetTableRows(tableId, 1, "")
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to get table header", err))
|
||||
return
|
||||
}
|
||||
if len(header) == 0 {
|
||||
slog.Warn("HandleRecordPoo Table header not found")
|
||||
return
|
||||
}
|
||||
headerId := header[0].GetID()
|
||||
_, err = notion.WriteTableRow([]string{recordDate, recordTime, record.Status, record.Latitude + "," + record.Longitude}, tableId, headerId.String())
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to write table row", err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,140 +0,0 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jomei/notionapi v1.13.2 h1:YpHKNpkoTMlUfWTlVIodOmQDgRKjfwmtSNVa6/6yC9E=
|
||||
github.com/jomei/notionapi v1.13.2/go.mod h1:BqzP6JBddpBnXvMSIxiR5dCoCjKngmz5QNl1ONDlDoM=
|
||||
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
||||
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addgpxCmd represents the addgpx command
|
||||
var addgpxCmd = &cobra.Command{
|
||||
Use: "addgpx",
|
||||
Short: "A brief description of your command",
|
||||
Long: `A longer description that spans multiple lines and likely contains examples
|
||||
and usage of using your command. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("addgpx called")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(addgpxCmd)
|
||||
|
||||
// Here you will define your flags and configuration settings.
|
||||
|
||||
// Cobra supports Persistent Flags which will work for this command
|
||||
// and all subcommands, e.g.:
|
||||
// addgpxCmd.PersistentFlags().String("foo", "", "A help for foo")
|
||||
|
||||
// Cobra supports local flags which will only run when this command
|
||||
// is called directly, e.g.:
|
||||
// addgpxCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "location_recorder",
|
||||
Short: "A brief description of your application",
|
||||
Long: `A longer description that spans multiple lines and likely contains
|
||||
examples and usage of using your application. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
// Uncomment the following line if your bare application
|
||||
// has an action associated with it:
|
||||
// Run: func(cmd *cobra.Command, args []string) { },
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Here you will define your flags and configuration settings.
|
||||
// Cobra supports persistent flags, which, if defined here,
|
||||
// will be global for your application.
|
||||
|
||||
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.location_recorder.yaml)")
|
||||
|
||||
// Cobra also supports local flags, which will only run
|
||||
// when this action is called directly.
|
||||
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
|
||||
*/
|
||||
package main
|
||||
|
||||
import "github.com/t-liu93/home-automation-backend/helper/location_recorder/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jomei/notionapi"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var notionToken string
|
||||
var notionTableId string
|
||||
|
||||
// reverseCmd represents the reverse command
|
||||
var reverseCmd = &cobra.Command{
|
||||
Use: "reverse",
|
||||
Short: "Reverse given poo recording table",
|
||||
Long: `Reverse the given poo recording table. Provide the Notion API token and the table ID to reverse.
|
||||
The Notion API token can be obtained from https://www.notion.so/my-integrations. The table ID can be obtained from the URL of the table.
|
||||
The token and table ID will be input in the following prompt.
|
||||
`,
|
||||
Run: readCredentials,
|
||||
}
|
||||
|
||||
func readCredentials(cmd *cobra.Command, args []string) {
|
||||
if notionToken == "" || notionTableId == "" {
|
||||
fmt.Print("Enter Notion API token: ")
|
||||
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read NOTION API Token: %v", err)
|
||||
}
|
||||
notionToken = string(pw)
|
||||
fmt.Print("\nEnter Notion table ID: ")
|
||||
tableId, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read NOTION table ID: %v", err)
|
||||
}
|
||||
notionTableId = string(tableId)
|
||||
}
|
||||
reverseRun()
|
||||
}
|
||||
|
||||
func reverseRun() {
|
||||
client := notionapi.NewClient(notionapi.Token(notionToken))
|
||||
rows := []notionapi.Block{}
|
||||
fmt.Println("Reverse table ID: ", notionTableId)
|
||||
block, err := client.Block.Get(context.Background(), notionapi.BlockID(notionTableId))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get table detail: %v", err)
|
||||
}
|
||||
if block.GetType().String() != "table" {
|
||||
log.Fatalf("Block ID %s is not a table", notionTableId)
|
||||
}
|
||||
headerBlock, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), ¬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")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "poo_recorder_helper",
|
||||
Short: "Poo recorder helper executables.",
|
||||
// Uncomment the following line if your bare application
|
||||
// has an action associated with it:
|
||||
// Run: func(cmd *cobra.Command, args []string) { },
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Here you will define your flags and configuration settings.
|
||||
// Cobra supports persistent flags, which, if defined here,
|
||||
// will be global for your application.
|
||||
|
||||
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.poo_recorder_helper.yaml)")
|
||||
|
||||
// Cobra also supports local flags, which will only run
|
||||
// when this action is called directly.
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
|
||||
*/
|
||||
package main
|
||||
|
||||
import "github.com/t-liu93/home-automation-backend/helper/poo_recorder_helper/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Copyright © 2024 Tianyu Liu
|
||||
|
||||
*/
|
||||
package main
|
||||
|
||||
import "github.com/t-liu93/home-automation-backend/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package homeassistantutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
ipField string = "homeassistant.ip"
|
||||
portField string = "homeassistant.port"
|
||||
authTokenField string = "homeassistant.authToken"
|
||||
webhookPath string = "/api/webhook/"
|
||||
sensorPath string = "/api/states/"
|
||||
)
|
||||
|
||||
type HttpSensor struct {
|
||||
EntityId string `json:"entity_id"`
|
||||
State string `json:"state"`
|
||||
Attributes interface{} `json:"attributes"`
|
||||
}
|
||||
|
||||
type WebhookBody interface{}
|
||||
|
||||
func TriggerWebhook(webhookId string, body WebhookBody) {
|
||||
if viper.InConfig(ipField) &&
|
||||
viper.InConfig(portField) &&
|
||||
viper.InConfig(authTokenField) {
|
||||
url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), webhookPath, webhookId)
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("TriggerWebhook Error marshalling", err))
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("TriggerWebhook Error creating request", err))
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField))
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 1,
|
||||
}
|
||||
go func() {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("TriggerWebhook Error sending request", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
slog.Warn(fmt.Sprintln("TriggerWebhook Unexpected response status", resp.StatusCode))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}()
|
||||
} else {
|
||||
slog.Warn("TriggerWebhook Home Assistant IP, port, or token not found in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func PublishSensor(sensor HttpSensor) {
|
||||
if viper.InConfig(ipField) &&
|
||||
viper.InConfig(portField) &&
|
||||
viper.InConfig(authTokenField) {
|
||||
url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), sensorPath, sensor.EntityId)
|
||||
payload, err := json.Marshal(sensor)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PublishSensor Error marshalling", err))
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PublishSensor Error creating request", err))
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField))
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 1,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintln("PublishSensor Error sending request", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
slog.Warn(fmt.Sprintln("PublishSensor Unexpected response status", resp.StatusCode))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
} else {
|
||||
slog.Warn("PublishSensor Home Assistant IP, port, or token not found in config file")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
+39
-1
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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:
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user