Compare commits
2 Commits
9c414d28ad
...
python_ver
| Author | SHA1 | Date | |
|---|---|---|---|
| c814f39810 | |||
| f14b7bf104 |
176
.gitignore
vendored
176
.gitignore
vendored
@@ -1,35 +1,153 @@
|
|||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
# Python
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
*.pyc
|
||||||
#
|
__pycache__/
|
||||||
# Binaries for programs and plugins
|
*.pyo
|
||||||
*.exe
|
*.pyd
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Virtual Environment
|
||||||
*.test
|
venv/
|
||||||
|
env/
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
env.bak/
|
||||||
*.out
|
env1/
|
||||||
|
env2/
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
|
||||||
go.work.sum
|
|
||||||
|
|
||||||
# env file
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
temp_data/
|
# IDEs and Editors
|
||||||
|
.idea/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
# py file for branch switching
|
# Dependency directories
|
||||||
.venv
|
env/
|
||||||
__pycache__/
|
lib/
|
||||||
|
libs/
|
||||||
|
lib64/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
egg-info/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
*.whl
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Testing
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
config.yaml
|
.coverage
|
||||||
bin/
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.cache
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
docs/_build/
|
||||||
|
target/
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
.Pipfile.lock
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
temp_data/
|
||||||
*.db
|
*.db
|
||||||
14
.vscode/extensions.json
vendored
Normal file
14
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||||
|
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||||
|
// List of extensions which should be recommended for users of this workspace.
|
||||||
|
"recommendations": [
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.debugpy",
|
||||||
|
"charliermarsh.ruff",
|
||||||
|
"ms-azuretools.vscode-docker"
|
||||||
|
],
|
||||||
|
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
31
.vscode/launch.json
vendored
31
.vscode/launch.json
vendored
@@ -5,31 +5,14 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Launch Package",
|
"name": "Python Debugger: Current File",
|
||||||
"type": "go",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"program": "${file}",
|
||||||
"program": "${workspaceFolder}"
|
"console": "integratedTerminal",
|
||||||
},
|
"env": {
|
||||||
{
|
"PYTHONPATH": "${workspaceFolder}"
|
||||||
"name": "Launch Poo Reverse",
|
},
|
||||||
"type": "go",
|
|
||||||
"request": "launch",
|
|
||||||
"mode": "auto",
|
|
||||||
"program": "${workspaceFolder}/src/helper/poo_recorder_helper/main.go",
|
|
||||||
"args": [
|
|
||||||
"reverse"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Launch Home Automation",
|
|
||||||
"type": "go",
|
|
||||||
"request": "launch",
|
|
||||||
"mode": "auto",
|
|
||||||
"program": "${workspaceFolder}/src/main.go",
|
|
||||||
"args": [
|
|
||||||
"serve"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
16
.vscode/settings.json
vendored
Normal file
16
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"[python]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||||
|
},
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"src/",
|
||||||
|
"${workspaceFolder}"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[program:home_automation_backend]
|
[program:home_automation_backend]
|
||||||
|
environment=PYTHONUNBUFFERED=1
|
||||||
command=
|
command=
|
||||||
directory=
|
directory=
|
||||||
user=
|
user=
|
||||||
group=
|
group=
|
||||||
environment=
|
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
startsecs=15
|
startsecs=1
|
||||||
startretries=100
|
startretries=100
|
||||||
stopwaitsecs=30
|
stopwaitsecs=30
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
|
|||||||
@@ -37,28 +37,33 @@ esac
|
|||||||
|
|
||||||
TARGET_DIR="$HOME/.local/home-automation-backend"
|
TARGET_DIR="$HOME/.local/home-automation-backend"
|
||||||
SUPERVISOR_CFG_NAME="home_automation_backend"
|
SUPERVISOR_CFG_NAME="home_automation_backend"
|
||||||
APP_NAME="home-automation-backend"
|
|
||||||
SUPERVISOR_CFG="$SUPERVISOR_CFG_NAME.conf"
|
SUPERVISOR_CFG="$SUPERVISOR_CFG_NAME.conf"
|
||||||
BASEDIR=$(dirname "$(realpath "$0")")
|
BASEDIR=$(dirname "$0")
|
||||||
|
|
||||||
# Install or uninstall based on arguments
|
# Install or uninstall based on arguments
|
||||||
install_backend() {
|
install_backend() {
|
||||||
# Installation code here
|
# Installation code here
|
||||||
echo "Installing..."
|
echo "Installing..."
|
||||||
|
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3 python3-venv supervisor
|
||||||
|
|
||||||
sudo supervisorctl stop $SUPERVISOR_CFG_NAME
|
sudo supervisorctl stop $SUPERVISOR_CFG_NAME
|
||||||
|
|
||||||
mkdir -p $TARGET_DIR
|
mkdir -p $TARGET_DIR
|
||||||
cd $BASEDIR"/../src/" && go build -o $TARGET_DIR/$APP_NAME
|
|
||||||
|
|
||||||
|
rm -rf `find $BASEDIR/../src -type d -name __pycache__`
|
||||||
|
cp -r $BASEDIR/../src $BASEDIR/../requirements.txt $TARGET_DIR
|
||||||
|
python3 -m venv "$TARGET_DIR/venv"
|
||||||
|
|
||||||
|
$TARGET_DIR/venv/bin/pip install -r $TARGET_DIR/requirements.txt
|
||||||
|
|
||||||
cp $BASEDIR/"$SUPERVISOR_CFG_NAME"_template.conf $BASEDIR/$SUPERVISOR_CFG
|
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+command=+command=$TARGET_DIR/venv/bin/fastapi run $TARGET_DIR/src/main.py --port 8881+g" $BASEDIR/$SUPERVISOR_CFG
|
||||||
sed -i "s+directory=+directory=$TARGET_DIR+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+user=+user=$USER+g" $BASEDIR/$SUPERVISOR_CFG
|
||||||
sed -i "s+group=+group=$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 mv $BASEDIR/$SUPERVISOR_CFG /etc/supervisor/conf.d/$SUPERVISOR_CFG
|
||||||
|
|
||||||
@@ -78,10 +83,9 @@ uninstall_backend() {
|
|||||||
|
|
||||||
sudo rm /etc/supervisor/conf.d/$SUPERVISOR_CFG
|
sudo rm /etc/supervisor/conf.d/$SUPERVISOR_CFG
|
||||||
|
|
||||||
rm -rf $TARGET_DIR/
|
rm -rf $TARGET_DIR/src $TARGET_DIR/requirements.txt $TARGET_DIR/venv
|
||||||
|
|
||||||
echo "Uninstallation complete."
|
echo "Uninstallation complete."
|
||||||
echo "Config files and db is stored in $HOME/.config/home-automation"
|
|
||||||
}
|
}
|
||||||
update_backend() {
|
update_backend() {
|
||||||
uninstall_backend
|
uninstall_backend
|
||||||
|
|||||||
49
requirements.txt
Normal file
49
requirements.txt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
aiosqlite==0.20.0
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.4.0
|
||||||
|
certifi==2024.7.4
|
||||||
|
charset-normalizer==3.3.2
|
||||||
|
click==8.1.7
|
||||||
|
dnspython==2.6.1
|
||||||
|
email_validator==2.2.0
|
||||||
|
fastapi==0.112.1
|
||||||
|
fastapi-cli==0.0.5
|
||||||
|
fastapi-mqtt==2.2.0
|
||||||
|
gmqtt==0.6.16
|
||||||
|
gpxpy==1.6.2
|
||||||
|
greenlet==3.0.3
|
||||||
|
h11==0.14.0
|
||||||
|
httpcore==1.0.5
|
||||||
|
httptools==0.6.1
|
||||||
|
httpx==0.27.0
|
||||||
|
idna==3.7
|
||||||
|
iniconfig==2.0.0
|
||||||
|
Jinja2==3.1.4
|
||||||
|
markdown-it-py==3.0.0
|
||||||
|
MarkupSafe==2.1.5
|
||||||
|
mdurl==0.1.2
|
||||||
|
notion-client==2.2.1
|
||||||
|
packaging==24.1
|
||||||
|
pip-review==1.3.0
|
||||||
|
pluggy==1.5.0
|
||||||
|
pydantic==2.8.2
|
||||||
|
pydantic_core==2.20.1
|
||||||
|
Pygments==2.18.0
|
||||||
|
pytest==8.3.2
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
PyYAML==6.0.2
|
||||||
|
requests==2.32.3
|
||||||
|
rich==13.7.1
|
||||||
|
shellingham==1.5.4
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==2.0.32
|
||||||
|
starlette==0.38.2
|
||||||
|
typer==0.12.4
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
urllib3==2.2.2
|
||||||
|
uvicorn==0.30.6
|
||||||
|
uvloop==0.20.0
|
||||||
|
watchfiles==0.23.0
|
||||||
|
websockets==12.0
|
||||||
10
ruff.toml
Normal file
10
ruff.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
target-version = "py39"
|
||||||
|
line-length = 144
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
fixable = ["UP034", "I001"]
|
||||||
|
ignore = ["T201", "D", "ANN101", "TD002", "TD003"]
|
||||||
|
|
||||||
|
[lint.extend-per-file-ignores]
|
||||||
|
"test*.py" = ["S101"]
|
||||||
@@ -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.
|
|
||||||
}
|
|
||||||
156
src/cmd/serve.go
156
src/cmd/serve.go
@@ -1,156 +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/ticktick"
|
|
||||||
)
|
|
||||||
|
|
||||||
var port string
|
|
||||||
var scheduler gocron.Scheduler
|
|
||||||
|
|
||||||
// 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.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func initComponent() {
|
|
||||||
// init pooRecorder
|
|
||||||
pooRecorder.Init(&scheduler)
|
|
||||||
// init location recorder
|
|
||||||
locationRecorder.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
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", homeassistant.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,121 +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/ticktick"
|
|
||||||
)
|
|
||||||
|
|
||||||
type haMessage struct {
|
|
||||||
Target string `json:"target"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type actionTask struct {
|
|
||||||
Action string `json:"action"`
|
|
||||||
DueHour int `json:"due_hour"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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("HandleHaMessage Error decoding request body", err))
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch message.Target {
|
|
||||||
case "poo_recorder":
|
|
||||||
handlePooRecorderMsg(message)
|
|
||||||
case "location_recorder":
|
|
||||||
handleLocationRecorderMsg(message)
|
|
||||||
case "ticktick":
|
|
||||||
handleTicktickMsg(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlePooRecorderMsg(message haMessage) {
|
|
||||||
switch message.Action {
|
|
||||||
case "get_latest":
|
|
||||||
handleGetLatestPoo()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLocationRecorderMsg(message haMessage) {
|
|
||||||
if message.Action == "record" {
|
|
||||||
port := viper.GetString("port")
|
|
||||||
req, err := http.NewRequest("POST", "http://localhost:"+port+"/location/record", strings.NewReader(strings.ReplaceAll(message.Content, "'", "\"")))
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn(fmt.Sprintln("handleLocationRecorderMsg Error creating request to location recorder", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: time.Second * 1,
|
|
||||||
}
|
|
||||||
_, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn(fmt.Sprintln("handleLocationRecorderMsg Error sending request to location recorder", err))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Warn(fmt.Sprintln("handleLocationRecorderMsg Unknown action", message.Action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTicktickMsg(message haMessage) {
|
|
||||||
switch message.Action {
|
|
||||||
case "create_action_task":
|
|
||||||
createActionTask(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetLatestPoo() {
|
|
||||||
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("handleGetLatestPoo Error sending request to poo recorder", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createActionTask(message haMessage) {
|
|
||||||
if !viper.InConfig("homeassistant.actionTaskProjectId") {
|
|
||||||
slog.Warn("Homeassistant.createActionTask actionTaskProjectId not found in config file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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(ticktick.DateTimeLayout)
|
|
||||||
ticktickTask := ticktick.Task{
|
|
||||||
ProjectId: projectId,
|
|
||||||
Title: task.Action,
|
|
||||||
DueDate: dueTicktick,
|
|
||||||
}
|
|
||||||
err = ticktick.CreateTask(ticktickTask)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn(fmt.Sprintf("Homeassistant.createActionTask Error creating task %s", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
81
src/components/poo_recorder.py
Normal file
81
src/components/poo_recorder.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
from src.util.homeassistant import HomeAssistant
|
||||||
|
from src.util.mqtt import MQTT
|
||||||
|
from src.util.notion import NotionAsync
|
||||||
|
|
||||||
|
|
||||||
|
class PooRecorder:
|
||||||
|
CONFIG_TOPIC = "homeassistant/text/poo_recorder/config"
|
||||||
|
AVAILABILITY_TOPIC = "studiotj/poo_recorder/status"
|
||||||
|
COMMAND_TOPIC = "studiotj/poo_recorder/update_text"
|
||||||
|
STATE_TOPIC = "studiotj/poo_recorder/text"
|
||||||
|
JSON_TOPIC = "studiotj/poo_recorder/attributes"
|
||||||
|
ONLINE = "online"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
class RecordField(BaseModel):
|
||||||
|
status: str
|
||||||
|
latitude: str
|
||||||
|
longitude: str
|
||||||
|
|
||||||
|
def __init__(self, mqtt: MQTT, notion: NotionAsync, homeassistant: HomeAssistant) -> None:
|
||||||
|
print("Poo Recorder Initialization...")
|
||||||
|
self._notion = notion
|
||||||
|
self._table_id = Config.get_env("POO_RECORD_NOTION_TABLE_ID")
|
||||||
|
self._mqtt = mqtt
|
||||||
|
self._mqtt.publish(PooRecorder.CONFIG_TOPIC, PooRecorder.compose_config(), retain=True)
|
||||||
|
self._mqtt.publish(PooRecorder.AVAILABILITY_TOPIC, PooRecorder.ONLINE, retain=True)
|
||||||
|
self._homeassistant = homeassistant
|
||||||
|
|
||||||
|
async def _note(self, now: datetime, status: str, latitude: str, longitude: str) -> None:
|
||||||
|
formatted_date = now.strftime("%Y-%m-%d")
|
||||||
|
formatted_time = now.strftime("%H:%M")
|
||||||
|
status.strip()
|
||||||
|
await self._notion.append_table_row_text_after_header(
|
||||||
|
self._table_id,
|
||||||
|
[formatted_date, formatted_time, status, latitude + "," + longitude],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def record(self, record_detail: RecordField) -> None:
|
||||||
|
webhook_id: str = Config.get_env("HOMEASSISTANT_POO_TRIGGER_ID")
|
||||||
|
self._publish_text(record_detail.status)
|
||||||
|
now = datetime.now(tz=datetime.now().astimezone().tzinfo)
|
||||||
|
self._publish_time(now)
|
||||||
|
await self._note(now, record_detail.status, record_detail.latitude, record_detail.longitude)
|
||||||
|
await self._homeassistant.trigger_webhook(payload={"status": record_detail.status}, webhook_id=webhook_id)
|
||||||
|
|
||||||
|
def _publish_text(self, new_text: str) -> None:
|
||||||
|
self._mqtt.publish(PooRecorder.AVAILABILITY_TOPIC, PooRecorder.ONLINE, retain=True)
|
||||||
|
self._mqtt.publish(PooRecorder.STATE_TOPIC, new_text, retain=True)
|
||||||
|
|
||||||
|
def _publish_time(self, time: datetime) -> None:
|
||||||
|
formatted_time = time.strftime("%a | %Y-%m-%d | %H:%M")
|
||||||
|
self._mqtt.publish(PooRecorder.AVAILABILITY_TOPIC, PooRecorder.ONLINE, retain=True)
|
||||||
|
json_string = {"last_poo": formatted_time}
|
||||||
|
self._mqtt.publish(PooRecorder.JSON_TOPIC, json_string, retain=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compose_config() -> dict:
|
||||||
|
return {
|
||||||
|
"device": {
|
||||||
|
"name": "Dog Poop Recorder",
|
||||||
|
"model": "poop-recorder-backend",
|
||||||
|
"sw_version": Config.VERSION,
|
||||||
|
"identifiers": ["poo_recorder"],
|
||||||
|
"manufacturer": "Studio TJ",
|
||||||
|
},
|
||||||
|
"unique_id": "poo_recorder",
|
||||||
|
"name": "Poo Status",
|
||||||
|
"availability_topic": PooRecorder.AVAILABILITY_TOPIC,
|
||||||
|
"availability_template": "{{ value_json.availability }}",
|
||||||
|
"json_attributes_topic": PooRecorder.JSON_TOPIC,
|
||||||
|
"min": 0,
|
||||||
|
"max": 255,
|
||||||
|
"mode": "text",
|
||||||
|
"command_topic": PooRecorder.COMMAND_TOPIC,
|
||||||
|
"state_topic": PooRecorder.STATE_TOPIC,
|
||||||
|
}
|
||||||
40
src/config.py
Normal file
40
src/config.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from dotenv import dotenv_values, set_key, unset_key
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
config_path = Path(__file__).parent.resolve()
|
||||||
|
DOT_ENV_PATH = Path(config_path, ".env")
|
||||||
|
DOT_ENV_PATH.touch(mode=0o600, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_dict: ClassVar[OrderedDict[str, str]] = {}
|
||||||
|
dot_env_path = DOT_ENV_PATH
|
||||||
|
VERSION = "2.0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def init(dotenv_path: str = DOT_ENV_PATH) -> None:
|
||||||
|
Config.dot_env_path = dotenv_path
|
||||||
|
Config.env_dict = dotenv_values(dotenv_path=dotenv_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_env(key: str) -> str | None:
|
||||||
|
if key in Config.env_dict:
|
||||||
|
return Config.env_dict[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_env(key: str, value: str) -> None:
|
||||||
|
set_key(Config.dot_env_path, key, value)
|
||||||
|
Config.env_dict = dotenv_values(dotenv_path=Config.dot_env_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_env(key: str) -> None:
|
||||||
|
unset_key(Config.dot_env_path, key)
|
||||||
|
Config.env_dict = dotenv_values(dotenv_path=Config.dot_env_path)
|
||||||
50
src/go.mod
50
src/go.mod
@@ -1,50 +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
|
|
||||||
golang.org/x/term v0.24.0
|
|
||||||
modernc.org/sqlite v1.33.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
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/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/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
|
|
||||||
)
|
|
||||||
138
src/go.sum
138
src/go.sum
@@ -1,138 +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/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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.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=
|
|
||||||
0
src/helper/location_recorder/__init__.py
Normal file
0
src/helper/location_recorder/__init__.py
Normal file
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
61
src/helper/location_recorder/google_location_reader.py
Normal file
61
src/helper/location_recorder/google_location_reader.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
current_file_path = Path(__file__).resolve().parent
|
||||||
|
sys.path.append(str(current_file_path / ".." / ".." / ".."))
|
||||||
|
from src.util.location_recorder import LocationData, LocationRecorder # noqa: E402
|
||||||
|
|
||||||
|
# Create an argument parser
|
||||||
|
parser = argparse.ArgumentParser(description="Google Location Reader")
|
||||||
|
|
||||||
|
# Add an argument for the JSON file path
|
||||||
|
parser.add_argument("--json-file", type=str, help="Path to the JSON file")
|
||||||
|
|
||||||
|
# Parse the command-line arguments
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
json_file_path: str = args.json_file
|
||||||
|
|
||||||
|
db_path = current_file_path / ".." / ".." / ".." / "temp_data" / "test.db"
|
||||||
|
location_recorder = LocationRecorder(db_path=str(db_path))
|
||||||
|
|
||||||
|
# Open the JSON file
|
||||||
|
with Path.open(json_file_path) as json_file:
|
||||||
|
data = json.load(json_file)
|
||||||
|
|
||||||
|
|
||||||
|
locations: list[dict] = data["locations"]
|
||||||
|
print(type(locations), len(locations))
|
||||||
|
|
||||||
|
|
||||||
|
async def insert() -> None:
|
||||||
|
nr_waypoints = 0
|
||||||
|
await location_recorder.create_db_engine()
|
||||||
|
locations_dict: dict[datetime, LocationData] = {}
|
||||||
|
for location in locations:
|
||||||
|
nr_waypoints += 1
|
||||||
|
try:
|
||||||
|
latitude: float = location["latitudeE7"] / 1e7
|
||||||
|
longitude: float = location["longitudeE7"] / 1e7
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
altitude: float = location.get("altitude", None)
|
||||||
|
try:
|
||||||
|
date_time = datetime.strptime(location["timestamp"], "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||||
|
except ValueError:
|
||||||
|
date_time = datetime.strptime(location["timestamp"], "%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
locations_dict[date_time] = LocationData(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
altitude=altitude,
|
||||||
|
)
|
||||||
|
await location_recorder.insert_locations("Tianyu", locations=locations_dict)
|
||||||
|
print(nr_waypoints)
|
||||||
|
await location_recorder.dispose_db_engine()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(insert())
|
||||||
44
src/helper/location_recorder/gpx_location_reader.py
Normal file
44
src/helper/location_recorder/gpx_location_reader.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import UTC
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gpxpy
|
||||||
|
import gpxpy.gpx
|
||||||
|
|
||||||
|
current_file_path = Path(__file__).resolve().parent
|
||||||
|
sys.path.append(str(current_file_path / ".." / ".." / ".."))
|
||||||
|
from src.util.location_recorder import LocationData, LocationRecorder # noqa: E402
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="GPX Location Reader")
|
||||||
|
|
||||||
|
parser.add_argument("--gpx-file", type=str, help="Path to the GPX file")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
gpx_location = args.gpx_file
|
||||||
|
|
||||||
|
gpx_file = Path.open(gpx_location)
|
||||||
|
gpx = gpxpy.parse(gpx_file)
|
||||||
|
|
||||||
|
db_path = current_file_path / ".." / ".." / ".." / "temp_data" / "test.db"
|
||||||
|
location_recorder = LocationRecorder(db_path=str(db_path))
|
||||||
|
|
||||||
|
|
||||||
|
async def iterate_and_insert() -> None:
|
||||||
|
nr_waypoints = 0
|
||||||
|
await location_recorder.create_db_engine()
|
||||||
|
for track in gpx.tracks:
|
||||||
|
for segment in track.segments:
|
||||||
|
for point in segment.points:
|
||||||
|
nr_waypoints += 1
|
||||||
|
print(f"Point at ({point.latitude},{point.longitude}) -> {point.time}")
|
||||||
|
point.time = point.time.replace(tzinfo=UTC)
|
||||||
|
location_data = LocationData(latitude=point.latitude, longitude=point.longitude, altitude=point.elevation)
|
||||||
|
await location_recorder.insert_location(person="Tianyu", date_time=point.time, location=location_data)
|
||||||
|
await location_recorder.dispose_db_engine()
|
||||||
|
print(nr_waypoints)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(iterate_and_insert())
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
0
src/helper/poo_recorder/__init__.py
Normal file
0
src/helper/poo_recorder/__init__.py
Normal file
41
src/helper/poo_recorder/old_poo_record_importer.py
Normal file
41
src/helper/poo_recorder/old_poo_record_importer.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
from src.util.notion import NotionAsync
|
||||||
|
|
||||||
|
Config.init()
|
||||||
|
|
||||||
|
notion = NotionAsync(token=Config.get_env("NOTION_TOKEN"))
|
||||||
|
|
||||||
|
current_file_path = Path(__file__).resolve()
|
||||||
|
|
||||||
|
current_dir = str(current_file_path.parent)
|
||||||
|
|
||||||
|
|
||||||
|
rows: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def update_rows() -> None:
|
||||||
|
header: dict = await notion.get_block_children(block_id=Config.get_env("POO_RECORD_NOTION_TABLE_ID"), page_size=1)
|
||||||
|
header_id = header["results"][0]["id"]
|
||||||
|
with Path.open(current_dir + "/../../../temp_data/old_poo_record.txt") as file:
|
||||||
|
content = file.read()
|
||||||
|
rows = content.split("\n")
|
||||||
|
rows.reverse()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
t = row[0:5]
|
||||||
|
date = row[8:19]
|
||||||
|
formatted_date = datetime.datetime.strptime(date, "%a, %d %b").astimezone().replace(year=2024).strftime("%Y-%m-%d")
|
||||||
|
status = row[20:]
|
||||||
|
print(f"{formatted_date} {t} {status}")
|
||||||
|
await notion.append_table_row_text(
|
||||||
|
table_id=Config.get_env("POO_RECORD_NOTION_TABLE_ID"),
|
||||||
|
text_list=[formatted_date, t, status, "0,0"],
|
||||||
|
after=header_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(update_rows())
|
||||||
62
src/helper/poo_recorder/poo_record_reverse.py
Normal file
62
src/helper/poo_recorder/poo_record_reverse.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
from src.util.notion import NotionAsync
|
||||||
|
|
||||||
|
Config.init()
|
||||||
|
|
||||||
|
notion = NotionAsync(token=Config.get_env("NOTION_TOKEN"))
|
||||||
|
|
||||||
|
current_file_path = Path(__file__).resolve()
|
||||||
|
|
||||||
|
current_dir = str(current_file_path.parent)
|
||||||
|
|
||||||
|
|
||||||
|
rows: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Column:
|
||||||
|
id: str
|
||||||
|
date: str
|
||||||
|
time: str
|
||||||
|
status: str
|
||||||
|
location: str
|
||||||
|
|
||||||
|
|
||||||
|
async def reverse_rows() -> None:
|
||||||
|
header: dict = await notion.get_block_children(block_id=Config.get_env("POO_RECORD_NOTION_TABLE_ID"), page_size=1)
|
||||||
|
header_id = header["results"][0]["id"]
|
||||||
|
start_cursor = header_id
|
||||||
|
rows: list[Column] = []
|
||||||
|
while start_cursor is not None:
|
||||||
|
children: dict = await notion.get_block_children(block_id=Config.get_env("POO_RECORD_NOTION_TABLE_ID"), start_cursor=start_cursor)
|
||||||
|
for entry in children["results"]:
|
||||||
|
row = Column(
|
||||||
|
id=entry["id"],
|
||||||
|
date=entry["table_row"]["cells"][0][0]["plain_text"],
|
||||||
|
time=entry["table_row"]["cells"][1][0]["plain_text"],
|
||||||
|
status=entry["table_row"]["cells"][2][0]["plain_text"],
|
||||||
|
location=entry["table_row"]["cells"][3][0]["plain_text"],
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
start_cursor = children["next_cursor"]
|
||||||
|
rows = rows[1:]
|
||||||
|
for row in rows:
|
||||||
|
print("delete block", row.date, row.time)
|
||||||
|
await notion.delete_block(row.id)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
print("add block", row.date, row.time)
|
||||||
|
await notion.append_table_row_text(
|
||||||
|
table_id=Config.get_env("POO_RECORD_NOTION_TABLE_ID"),
|
||||||
|
text_list=[row.date, row.time, row.status, row.location],
|
||||||
|
after=header_id,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(reverse_rows())
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
11
src/main.go
11
src/main.go
@@ -1,11 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright © 2024 Tianyu Liu
|
|
||||||
|
|
||||||
*/
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "github.com/t-liu93/home-automation-backend/cmd"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cmd.Execute()
|
|
||||||
}
|
|
||||||
60
src/main.py
Normal file
60
src/main.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from src.components.poo_recorder import PooRecorder
|
||||||
|
from src.config import Config
|
||||||
|
from src.util.homeassistant import HomeAssistant
|
||||||
|
from src.util.location_recorder import LocationRecorder
|
||||||
|
from src.util.mqtt import MQTT
|
||||||
|
from src.util.notion import NotionAsync
|
||||||
|
from src.util.ticktick import TickTick
|
||||||
|
|
||||||
|
Config.init()
|
||||||
|
|
||||||
|
location_recorder_db = str(Path(__file__).resolve().parent / ".." / "location_recorder.db")
|
||||||
|
|
||||||
|
ticktick = TickTick()
|
||||||
|
notion = NotionAsync(token=Config.get_env(key="NOTION_TOKEN"))
|
||||||
|
mqtt = MQTT()
|
||||||
|
location_recorder = LocationRecorder(db_path=location_recorder_db)
|
||||||
|
homeassistant = HomeAssistant(ticktick=ticktick, location_recorder=location_recorder)
|
||||||
|
poo_recorder = PooRecorder(mqtt=mqtt, notion=notion, homeassistant=homeassistant)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lifespan(_app: FastAPI): # noqa: ANN202
|
||||||
|
await mqtt.start()
|
||||||
|
await location_recorder.create_db_engine()
|
||||||
|
yield
|
||||||
|
await mqtt.stop()
|
||||||
|
await location_recorder.dispose_db_engine()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=_lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/homeassistant/status")
|
||||||
|
async def get_status() -> dict:
|
||||||
|
return {"Status": "Ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/homeassistant/publish")
|
||||||
|
async def homeassistant_publish(payload: HomeAssistant.Message) -> dict:
|
||||||
|
return await homeassistant.process_message(message=payload)
|
||||||
|
|
||||||
|
|
||||||
|
# Poo recorder
|
||||||
|
@app.post("/poo/record")
|
||||||
|
async def record(record_detail: PooRecorder.RecordField) -> PooRecorder.RecordField:
|
||||||
|
await poo_recorder.record(record_detail)
|
||||||
|
return record_detail
|
||||||
|
|
||||||
|
|
||||||
|
# ticktick
|
||||||
|
@app.get("/ticktick/auth/code")
|
||||||
|
async def ticktick_auth(code: str, state: str) -> dict:
|
||||||
|
if await ticktick.retrieve_access_token(code, state):
|
||||||
|
return {"State": "Token Retrieved"}
|
||||||
|
return {"State": "Token Retrieval Failed"}
|
||||||
0
src/tests/__init__.py
Normal file
0
src/tests/__init__.py
Normal file
70
src/tests/test_config.py
Normal file
70
src/tests/test_config.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dotenv import dotenv_values, set_key
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
|
||||||
|
CONFIG_PATH = Path(__file__).parent.resolve()
|
||||||
|
TEST_DOT_ENV_PATH = Path(CONFIG_PATH, ".env_test")
|
||||||
|
|
||||||
|
EXPECTED_ENV_DICT: OrderedDict[str, str] = OrderedDict(
|
||||||
|
{
|
||||||
|
"KEY_1": "VALUE_1",
|
||||||
|
"KEY_2": "VALUE_2",
|
||||||
|
"NOTION_TOKEN": "1234454_234324",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _prepare_test_dot_env() -> any:
|
||||||
|
TEST_DOT_ENV_PATH.touch(mode=0o600, exist_ok=True)
|
||||||
|
for key, value in EXPECTED_ENV_DICT.items():
|
||||||
|
set_key(TEST_DOT_ENV_PATH, key, value)
|
||||||
|
yield
|
||||||
|
TEST_DOT_ENV_PATH.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _load_test_dot_env(_prepare_test_dot_env: any) -> None:
|
||||||
|
Config.init(dotenv_path=TEST_DOT_ENV_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_prepare_test_dot_env")
|
||||||
|
def test_init_config() -> None:
|
||||||
|
assert Config.env_dict == {}
|
||||||
|
Config.init(dotenv_path=TEST_DOT_ENV_PATH)
|
||||||
|
assert Config.env_dict == EXPECTED_ENV_DICT
|
||||||
|
dict_from_file = dotenv_values(dotenv_path=TEST_DOT_ENV_PATH)
|
||||||
|
assert dict_from_file == EXPECTED_ENV_DICT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_load_test_dot_env")
|
||||||
|
def test_get_config() -> None:
|
||||||
|
assert Config.get_env("NON_EXISTING_KEY") is None
|
||||||
|
key_1 = "KEY_1"
|
||||||
|
assert Config.get_env(key_1) == EXPECTED_ENV_DICT[key_1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_load_test_dot_env")
|
||||||
|
def test_update_config() -> None:
|
||||||
|
key = "KEY_1"
|
||||||
|
value = EXPECTED_ENV_DICT[key]
|
||||||
|
new_value = "NEW_VALUE"
|
||||||
|
assert Config.get_env(key) == value
|
||||||
|
Config.update_env(key, new_value)
|
||||||
|
assert Config.get_env(key) == new_value
|
||||||
|
dict_from_file = dotenv_values(dotenv_path=TEST_DOT_ENV_PATH)
|
||||||
|
assert dict_from_file[key] == new_value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_load_test_dot_env")
|
||||||
|
def test_remove_config() -> None:
|
||||||
|
key = "KEY_1"
|
||||||
|
assert Config.get_env(key) == EXPECTED_ENV_DICT[key]
|
||||||
|
Config.remove_env(key)
|
||||||
|
assert Config.get_env(key) is None
|
||||||
|
dict_from_file = dotenv_values(dotenv_path=TEST_DOT_ENV_PATH)
|
||||||
|
assert key not in dict_from_file
|
||||||
1
src/tests/test_main.py
Normal file
1
src/tests/test_main.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
0
src/util/__init__.py
Normal file
0
src/util/__init__.py
Normal file
71
src/util/homeassistant.py
Normal file
71
src/util/homeassistant.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import ast
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
from src.util.location_recorder import LocationData, LocationRecorder
|
||||||
|
from src.util.ticktick import TickTick
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistant:
|
||||||
|
class Message(BaseModel):
|
||||||
|
target: str
|
||||||
|
action: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
def __init__(self, ticktick: TickTick, location_recorder: LocationRecorder) -> None:
|
||||||
|
self._ticktick = ticktick
|
||||||
|
self._location_recorder = location_recorder
|
||||||
|
|
||||||
|
async def process_message(self, message: Message) -> dict[str, str]:
|
||||||
|
if message.target == "ticktick":
|
||||||
|
return await self._process_ticktick_message(message=message)
|
||||||
|
if message.target == "location_recorder":
|
||||||
|
return await self._process_location(message=message)
|
||||||
|
return {"Status": "Unknown target"}
|
||||||
|
|
||||||
|
async def trigger_webhook(self, payload: dict[str, str], webhook_id: str) -> None:
|
||||||
|
token: str = Config.get_env("HOMEASSISTANT_TOKEN")
|
||||||
|
webhook_url: str = Config.get_env("HOMEASSISTANT_URL") + "/api/webhook/" + webhook_id
|
||||||
|
headers: dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||||
|
await httpx.AsyncClient().post(webhook_url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
async def _process_ticktick_message(self, message: Message) -> dict[str, str]:
|
||||||
|
if message.action == "create_shopping_list":
|
||||||
|
return await self._create_shopping_list(content=message.content)
|
||||||
|
if message.action == "create_action_task":
|
||||||
|
return await self._create_action_task(content=message.content)
|
||||||
|
|
||||||
|
return {"Status": "Unknown action"}
|
||||||
|
|
||||||
|
async def _process_location(self, message: Message) -> dict[str, str]:
|
||||||
|
if message.action == "record":
|
||||||
|
location: dict[str, str] = ast.literal_eval(message.content)
|
||||||
|
await self._location_recorder.insert_location_now(
|
||||||
|
person=location["person"],
|
||||||
|
location=LocationData(
|
||||||
|
latitude=float(location["latitude"]),
|
||||||
|
longitude=float(location["longitude"]),
|
||||||
|
altitude=float(location["altitude"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"Status": "Location recorded"}
|
||||||
|
return {"Status": "Unknown action"}
|
||||||
|
|
||||||
|
async def _create_shopping_list(self, content: str) -> dict[str, str]:
|
||||||
|
project_id = Config.get_env("TICKTICK_SHOPPING_LIST")
|
||||||
|
item: dict[str, str] = ast.literal_eval(content)
|
||||||
|
task = TickTick.Task(projectId=project_id, title=item["item"])
|
||||||
|
return await self._ticktick.create_task(task=task)
|
||||||
|
|
||||||
|
async def _create_action_task(self, content: str) -> dict[str, str]:
|
||||||
|
detail: dict[str, str] = ast.literal_eval(content)
|
||||||
|
project_id = Config.get_env("TICKTICK_HOME_TASK_LIST")
|
||||||
|
due_hour = detail["due_hour"]
|
||||||
|
due = datetime.now(tz=datetime.now().astimezone().tzinfo) + timedelta(hours=due_hour)
|
||||||
|
due = (due + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
due = due.astimezone(timezone.utc)
|
||||||
|
task = TickTick.Task(projectId=project_id, title=detail["action"], dueDate=TickTick.datetime_to_ticktick_format(due))
|
||||||
|
return await self._ticktick.create_task(task=task)
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
120
src/util/location_recorder.py
Normal file
120
src/util/location_recorder.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import REAL, TEXT, insert, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Location(Base):
|
||||||
|
__tablename__ = "location"
|
||||||
|
person: Mapped[str] = mapped_column(type_=TEXT, primary_key=True, nullable=False)
|
||||||
|
datetime: Mapped[str] = mapped_column(type_=TEXT, primary_key=True, nullable=False)
|
||||||
|
latitude: Mapped[float] = mapped_column(type_=REAL, nullable=False)
|
||||||
|
longitude: Mapped[float] = mapped_column(type_=REAL, nullable=False)
|
||||||
|
altitude: Mapped[float] = mapped_column(type_=REAL, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LocationData:
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
altitude: float
|
||||||
|
|
||||||
|
|
||||||
|
class LocationRecorder:
|
||||||
|
USER_VERSION = 3
|
||||||
|
|
||||||
|
def __init__(self, db_path: str) -> None:
|
||||||
|
self._db_path = "sqlite+aiosqlite:///" + db_path
|
||||||
|
|
||||||
|
async def create_db_engine(self) -> None:
|
||||||
|
self._engine = create_async_engine(self._db_path)
|
||||||
|
async with self._engine.begin() as conn:
|
||||||
|
user_version = await self._get_user_version(conn=conn)
|
||||||
|
if user_version == 0:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await self._set_user_version(conn=conn, user_version=2)
|
||||||
|
if user_version != LocationRecorder.USER_VERSION:
|
||||||
|
await self._migrate(conn=conn)
|
||||||
|
|
||||||
|
async def dispose_db_engine(self) -> None:
|
||||||
|
await self._engine.dispose()
|
||||||
|
|
||||||
|
async def insert_location(self, person: str, date_time: datetime, location: LocationData) -> None:
|
||||||
|
if date_time.tzinfo != UTC:
|
||||||
|
date_time = date_time.astimezone(UTC)
|
||||||
|
date_time_str = date_time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
async with self._engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
insert(Location)
|
||||||
|
.prefix_with("OR IGNORE")
|
||||||
|
.values(
|
||||||
|
person=person,
|
||||||
|
datetime=date_time_str,
|
||||||
|
latitude=location.latitude,
|
||||||
|
longitude=location.longitude,
|
||||||
|
altitude=location.altitude,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def insert_locations(self, person: str, locations: dict[datetime, LocationData]) -> None:
|
||||||
|
async with self._engine.begin() as conn:
|
||||||
|
for k, v in locations.items():
|
||||||
|
dt = k
|
||||||
|
if k.tzinfo != UTC:
|
||||||
|
dt = k.astimezone(UTC)
|
||||||
|
date_time_str = dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
await conn.execute(
|
||||||
|
insert(Location)
|
||||||
|
.prefix_with("OR IGNORE")
|
||||||
|
.values(
|
||||||
|
person=person,
|
||||||
|
datetime=date_time_str,
|
||||||
|
latitude=v.latitude,
|
||||||
|
longitude=v.longitude,
|
||||||
|
altitude=v.altitude,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def insert_location_now(self, person: str, location: LocationData) -> None:
|
||||||
|
now_utc = datetime.now(tz=UTC)
|
||||||
|
await self.insert_location(person, now_utc, location)
|
||||||
|
|
||||||
|
async def _get_user_version(self, conn: AsyncConnection) -> int:
|
||||||
|
return (await conn.execute(text("PRAGMA user_version"))).first()[0]
|
||||||
|
|
||||||
|
async def _set_user_version(self, conn: AsyncConnection, user_version: int) -> None:
|
||||||
|
await conn.execute(text("PRAGMA user_version = " + str(user_version)))
|
||||||
|
|
||||||
|
async def _migrate(self, conn: AsyncConnection) -> None:
|
||||||
|
user_version = (await conn.execute(text("PRAGMA user_version"))).first()[0]
|
||||||
|
if user_version == 1:
|
||||||
|
await self._migrate_1_2(conn=conn)
|
||||||
|
user_version = (await conn.execute(text("PRAGMA user_version"))).first()[0]
|
||||||
|
if user_version == 2: # noqa: PLR2004
|
||||||
|
await self._migrate_2_3(conn=conn)
|
||||||
|
user_version = (await conn.execute(text("PRAGMA user_version"))).first()[0]
|
||||||
|
|
||||||
|
async def _migrate_1_2(self, conn: AsyncConnection) -> None:
|
||||||
|
print("Location Recorder: migrate from db ver 1 to 2.")
|
||||||
|
await conn.execute(text("DROP TABLE version"))
|
||||||
|
await conn.execute(text("ALTER TABLE location RENAME people TO person"))
|
||||||
|
await self._set_user_version(conn=conn, user_version=2)
|
||||||
|
|
||||||
|
async def _migrate_2_3(self, conn: AsyncConnection) -> None:
|
||||||
|
print("Location Recorder: migrate from db ver 2 to 3.")
|
||||||
|
await conn.execute(text("ALTER TABLE location RENAME TO location_old"))
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO location (person, datetime, latitude, longitude, altitude)
|
||||||
|
SELECT person, datetime, latitude, longitude, altitude FROM location_old;
|
||||||
|
"""),
|
||||||
|
)
|
||||||
|
await conn.execute(text("DROP TABLE location_old"))
|
||||||
|
await self._set_user_version(conn=conn, user_version=3)
|
||||||
73
src/util/mqtt.py
Normal file
73
src/util/mqtt.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import queue
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi_mqtt import FastMQTT, MQTTConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MQTTSubscription:
|
||||||
|
topic: str
|
||||||
|
callback: callable
|
||||||
|
subscribed: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MQTTPendingMessage:
|
||||||
|
topic: str
|
||||||
|
payload: dict
|
||||||
|
retain: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MQTT:
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs): # noqa: ANN002, ANN003, ANN204
|
||||||
|
if not cls._instance:
|
||||||
|
cls._instance = super().__new__(cls, *args, **kwargs)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._mqtt_config = MQTTConfig(username="mqtt", password="mqtt", reconnect_retries=-1) # noqa: S106
|
||||||
|
self._mqtt = FastMQTT(config=self._mqtt_config, client_id="home_automation_backend")
|
||||||
|
self._mqtt.mqtt_handlers.user_connect_handler = self.on_connect
|
||||||
|
self._mqtt.mqtt_handlers.user_message_handler = self.on_message
|
||||||
|
self._connected = False
|
||||||
|
self._subscribed_topic: dict[str, MQTTSubscription] = {}
|
||||||
|
self._queued_message: queue.Queue[MQTTPendingMessage] = queue.Queue()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
print("MQTT Starting...")
|
||||||
|
await self._mqtt.mqtt_startup()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
print("MQTT Stopping...")
|
||||||
|
await self._mqtt.mqtt_shutdown()
|
||||||
|
|
||||||
|
def on_connect(self, client, flags, rc, properties) -> None: # noqa: ANN001, ARG002
|
||||||
|
print("Connected")
|
||||||
|
self._connected = True
|
||||||
|
while not self._queued_message.empty():
|
||||||
|
msg = self._queued_message.get(block=False)
|
||||||
|
self.publish(msg.topic, msg.payload, retain=msg.retain)
|
||||||
|
for topic, subscription in self._subscribed_topic.items():
|
||||||
|
if subscription.subscribed is False:
|
||||||
|
self.subscribe(topic, subscription.callback)
|
||||||
|
|
||||||
|
async def on_message(self, client, topic: str, payload: bytes, qos: int, properties: any) -> any: # noqa: ANN001, ARG002
|
||||||
|
print("On message")
|
||||||
|
if topic in self._subscribed_topic and self._subscribed_topic[topic].callback is not None:
|
||||||
|
await self._subscribed_topic[topic].callback(payload)
|
||||||
|
|
||||||
|
def subscribe(self, topic: str, callback: callable) -> None:
|
||||||
|
if self._connected:
|
||||||
|
print("Subscribe to topic: ", topic)
|
||||||
|
self._mqtt.client.subscribe(topic)
|
||||||
|
self._subscribed_topic[topic] = MQTTSubscription(topic, callback, subscribed=True)
|
||||||
|
else:
|
||||||
|
self._subscribed_topic[topic] = MQTTSubscription(topic, callback, subscribed=False)
|
||||||
|
|
||||||
|
def publish(self, topic: str, payload: dict, *, retain: bool) -> None:
|
||||||
|
if self._connected:
|
||||||
|
self._mqtt.publish(topic, payload=payload, retain=retain)
|
||||||
|
else:
|
||||||
|
self._queued_message.put(MQTTPendingMessage(topic, payload, retain=retain))
|
||||||
90
src/util/notion.py
Normal file
90
src/util/notion.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
|
||||||
|
from notion_client import AsyncClient as Client
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Text:
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RichText:
|
||||||
|
type: str
|
||||||
|
href: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RichTextText(RichText):
|
||||||
|
type: str = "text"
|
||||||
|
text: Text = field(default_factory=lambda: Text(content=""))
|
||||||
|
|
||||||
|
|
||||||
|
class NotionAsync:
|
||||||
|
def __init__(self, token: str) -> None:
|
||||||
|
self._client = Client(auth=token)
|
||||||
|
|
||||||
|
def update_token(self, token: str) -> None:
|
||||||
|
self._client.aclose()
|
||||||
|
self._client = Client(auth=token)
|
||||||
|
|
||||||
|
async def get_block(self, block_id: str) -> dict:
|
||||||
|
return await self._client.blocks.retrieve(block_id=block_id)
|
||||||
|
|
||||||
|
async def get_block_children(self, block_id: str, start_cursor: str | None = None, page_size: int = 100) -> dict:
|
||||||
|
return await self._client.blocks.children.list(
|
||||||
|
block_id=block_id,
|
||||||
|
start_cursor=start_cursor,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_block(self, block_id: str) -> None:
|
||||||
|
await self._client.blocks.delete(block_id=block_id)
|
||||||
|
|
||||||
|
async def block_is_table(self, block_id: str) -> bool:
|
||||||
|
block: dict = await self.get_block(block_id=block_id)
|
||||||
|
return block["type"] == "table"
|
||||||
|
|
||||||
|
async def get_table_width(self, table_id: str) -> int:
|
||||||
|
table = await self._client.blocks.retrieve(block_id=table_id)
|
||||||
|
return table["table"]["table_width"]
|
||||||
|
|
||||||
|
async def append_table_row_text_after_header(self, table_id: str, text_list: list[str]) -> None:
|
||||||
|
header: dict = await self.get_block_children(block_id=table_id, page_size=1)
|
||||||
|
header_id = header["results"][0]["id"]
|
||||||
|
await self.append_table_row_text(table_id=table_id, text_list=text_list, after=header_id)
|
||||||
|
|
||||||
|
async def append_table_row_text(self, table_id: str, text_list: list[str], after: str | None = None) -> None:
|
||||||
|
cells: list[RichText] = []
|
||||||
|
for content in text_list:
|
||||||
|
cells.append([asdict(RichTextText(text=Text(content)))]) # noqa: PERF401
|
||||||
|
await self.append_table_row(table_id=table_id, cells=cells, after=after)
|
||||||
|
|
||||||
|
async def append_table_row(self, table_id: str, cells: list[RichText], after: str | None = None) -> None:
|
||||||
|
if not await self.block_is_table(table_id):
|
||||||
|
return
|
||||||
|
table_width = await self.get_table_width(table_id=table_id)
|
||||||
|
if table_width != len(cells):
|
||||||
|
return
|
||||||
|
children = [
|
||||||
|
{
|
||||||
|
"object": "block",
|
||||||
|
"type": "table_row",
|
||||||
|
"table_row": {
|
||||||
|
"cells": cells,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if after is None:
|
||||||
|
await self._client.blocks.children.append(
|
||||||
|
block_id=table_id,
|
||||||
|
children=children,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._client.blocks.children.append(
|
||||||
|
block_id=table_id,
|
||||||
|
children=children,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
0
src/util/tests/__init__.py
Normal file
0
src/util/tests/__init__.py
Normal file
354
src/util/tests/test_location_recorder.py
Normal file
354
src/util/tests/test_location_recorder.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import INTEGER, REAL, TEXT, create_engine, text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.util.location_recorder import LocationData, LocationRecorder
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).resolve().parent / "test.db"
|
||||||
|
DB_PATH_STR = str(DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _reset_event_loop() -> any:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
loop.stop()
|
||||||
|
loop.close()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _teardown() -> any:
|
||||||
|
yield
|
||||||
|
DB_PATH.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _create_v1_db() -> None:
|
||||||
|
db = "sqlite:///" + DB_PATH_STR
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Version(Base):
|
||||||
|
__tablename__ = "version"
|
||||||
|
version_type: Mapped[str] = mapped_column(type_=TEXT, primary_key=True)
|
||||||
|
version: Mapped[int] = mapped_column(type_=INTEGER)
|
||||||
|
|
||||||
|
class Location(Base):
|
||||||
|
__tablename__ = "location"
|
||||||
|
people: Mapped[str] = mapped_column(type_=TEXT, primary_key=True)
|
||||||
|
datetime: Mapped[str] = mapped_column(type_=TEXT, primary_key=True)
|
||||||
|
latitude: Mapped[float] = mapped_column(type_=REAL)
|
||||||
|
longitude: Mapped[float] = mapped_column(type_=REAL)
|
||||||
|
altitude: Mapped[float] = mapped_column(type_=REAL)
|
||||||
|
|
||||||
|
engine = create_engine(db)
|
||||||
|
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text("PRAGMA user_version = 1"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _create_v2_db() -> None:
|
||||||
|
db = "sqlite:///" + DB_PATH_STR
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Location(Base):
|
||||||
|
__tablename__ = "location"
|
||||||
|
person: Mapped[str] = mapped_column(type_=TEXT, primary_key=True)
|
||||||
|
datetime: Mapped[str] = mapped_column(type_=TEXT, primary_key=True)
|
||||||
|
latitude: Mapped[float] = mapped_column(type_=REAL)
|
||||||
|
longitude: Mapped[float] = mapped_column(type_=REAL)
|
||||||
|
altitude: Mapped[float] = mapped_column(type_=REAL)
|
||||||
|
|
||||||
|
engine = create_engine(db)
|
||||||
|
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text("PRAGMA user_version = 2"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _create_latest_db() -> None:
|
||||||
|
db = "sqlite:///" + DB_PATH_STR
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Location(Base):
|
||||||
|
__tablename__ = "location"
|
||||||
|
person: Mapped[str] = mapped_column(type_=TEXT, primary_key=True, nullable=False)
|
||||||
|
datetime: Mapped[str] = mapped_column(type_=TEXT, primary_key=True, nullable=False)
|
||||||
|
latitude: Mapped[float] = mapped_column(type_=REAL, nullable=False)
|
||||||
|
longitude: Mapped[float] = mapped_column(type_=REAL, nullable=False)
|
||||||
|
altitude: Mapped[float] = mapped_column(type_=REAL, nullable=True)
|
||||||
|
|
||||||
|
engine = create_engine(db)
|
||||||
|
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_create_v1_db")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_migration_1_latest() -> None:
|
||||||
|
nr_tables_ver_1 = 2
|
||||||
|
table_ver_1_0 = "version"
|
||||||
|
nr_column_ver_1_version = 2
|
||||||
|
table_ver_1_1 = "location"
|
||||||
|
nr_column_ver_1_location = 5
|
||||||
|
nr_tables_ver_2 = 1
|
||||||
|
table_ver_2_0 = "location"
|
||||||
|
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("PRAGMA user_version")
|
||||||
|
assert sqlite3_cursor.fetchone()[0] == 1
|
||||||
|
sqlite3_cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table';")
|
||||||
|
tables = sqlite3_cursor.fetchall()
|
||||||
|
assert len(tables) == nr_tables_ver_1
|
||||||
|
assert tables[0][0] == table_ver_1_0
|
||||||
|
assert tables[1][0] == table_ver_1_1
|
||||||
|
sqlite3_cursor.execute(f"PRAGMA table_info({table_ver_1_0})")
|
||||||
|
table_info_version = sqlite3_cursor.fetchall()
|
||||||
|
assert len(table_info_version) == nr_column_ver_1_version
|
||||||
|
assert table_info_version[0] == (0, "version_type", "TEXT", 1, None, 1)
|
||||||
|
assert table_info_version[1] == (1, "version", "INTEGER", 1, None, 0)
|
||||||
|
sqlite3_cursor.execute(f"PRAGMA table_info({table_ver_1_1})")
|
||||||
|
table_info_location = sqlite3_cursor.fetchall()
|
||||||
|
assert len(table_info_location) == nr_column_ver_1_location
|
||||||
|
assert table_info_location[0] == (0, "people", "TEXT", 1, None, 1)
|
||||||
|
assert table_info_location[1] == (1, "datetime", "TEXT", 1, None, 2)
|
||||||
|
assert table_info_location[2] == (2, "latitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[3] == (3, "longitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[4] == (4, "altitude", "REAL", 1, None, 0)
|
||||||
|
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
asyncio.run(location_recorder.create_db_engine())
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("PRAGMA user_version")
|
||||||
|
assert sqlite3_cursor.fetchone()[0] == LocationRecorder.USER_VERSION
|
||||||
|
sqlite3_cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table';")
|
||||||
|
tables = sqlite3_cursor.fetchall()
|
||||||
|
assert len(tables) == nr_tables_ver_2
|
||||||
|
sqlite3_cursor.execute(f"PRAGMA table_info({table_ver_2_0})")
|
||||||
|
table_info_location = sqlite3_cursor.fetchall()
|
||||||
|
assert len(table_info_location) == nr_column_ver_1_location
|
||||||
|
assert table_info_location[0] == (0, "person", "TEXT", 1, None, 1)
|
||||||
|
assert table_info_location[1] == (1, "datetime", "TEXT", 1, None, 2)
|
||||||
|
assert table_info_location[2] == (2, "latitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[3] == (3, "longitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[4] == (4, "altitude", "REAL", 0, None, 0)
|
||||||
|
sqlite3_cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_create_v2_db")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_migration_2_latest() -> None:
|
||||||
|
nr_tables_ver_2 = 1
|
||||||
|
table_ver_2_0 = "location"
|
||||||
|
nr_column_ver_2_location = 5
|
||||||
|
nr_tables_ver_3 = 1
|
||||||
|
table_ver_3_0 = "location"
|
||||||
|
nr_column_ver_3_location = 5
|
||||||
|
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("PRAGMA user_version")
|
||||||
|
assert sqlite3_cursor.fetchone()[0] == 2 # noqa: PLR2004
|
||||||
|
sqlite3_cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table';")
|
||||||
|
tables = sqlite3_cursor.fetchall()
|
||||||
|
assert len(tables) == nr_tables_ver_2
|
||||||
|
assert tables[0][0] == table_ver_2_0
|
||||||
|
sqlite3_cursor.execute(f"PRAGMA table_info({table_ver_2_0})")
|
||||||
|
table_info_location = sqlite3_cursor.fetchall()
|
||||||
|
assert len(table_info_location) == nr_column_ver_2_location
|
||||||
|
assert table_info_location[0] == (0, "person", "TEXT", 1, None, 1)
|
||||||
|
assert table_info_location[1] == (1, "datetime", "TEXT", 1, None, 2)
|
||||||
|
assert table_info_location[2] == (2, "latitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[3] == (3, "longitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[4] == (4, "altitude", "REAL", 1, None, 0)
|
||||||
|
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
asyncio.run(location_recorder.create_db_engine())
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("PRAGMA user_version")
|
||||||
|
assert sqlite3_cursor.fetchone()[0] == LocationRecorder.USER_VERSION
|
||||||
|
sqlite3_cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table';")
|
||||||
|
tables = sqlite3_cursor.fetchall()
|
||||||
|
assert len(tables) == nr_tables_ver_3
|
||||||
|
sqlite3_cursor.execute(f"PRAGMA table_info({table_ver_3_0})")
|
||||||
|
table_info_location = sqlite3_cursor.fetchall()
|
||||||
|
assert len(table_info_location) == nr_column_ver_3_location
|
||||||
|
assert table_info_location[0] == (0, "person", "TEXT", 1, None, 1)
|
||||||
|
assert table_info_location[1] == (1, "datetime", "TEXT", 1, None, 2)
|
||||||
|
assert table_info_location[2] == (2, "latitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[3] == (3, "longitude", "REAL", 1, None, 0)
|
||||||
|
assert table_info_location[4] == (4, "altitude", "REAL", 0, None, 0)
|
||||||
|
sqlite3_cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_reset_event_loop")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_create_db() -> None:
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
event_loop = asyncio.get_event_loop()
|
||||||
|
event_loop.run_until_complete(location_recorder.create_db_engine())
|
||||||
|
event_loop.run_until_complete(location_recorder.dispose_db_engine())
|
||||||
|
assert DB_PATH.exists()
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("PRAGMA user_version")
|
||||||
|
assert sqlite3_cursor.fetchone()[0] == LocationRecorder.USER_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_reset_event_loop")
|
||||||
|
@pytest.mark.usefixtures("_create_latest_db")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_inser_location_utc() -> None:
|
||||||
|
latitude = 1.0
|
||||||
|
longitude = 2.0
|
||||||
|
altitude = 3.0
|
||||||
|
person = "test_person"
|
||||||
|
date_time = datetime.now(tz=UTC)
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
event_loop = asyncio.get_event_loop()
|
||||||
|
event_loop.run_until_complete(location_recorder.create_db_engine())
|
||||||
|
location_data = LocationData(latitude=latitude, longitude=longitude, altitude=altitude)
|
||||||
|
event_loop.run_until_complete(
|
||||||
|
location_recorder.insert_location(
|
||||||
|
person=person,
|
||||||
|
date_time=date_time,
|
||||||
|
location=location_data,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event_loop.run_until_complete(location_recorder.dispose_db_engine())
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("SELECT * FROM location")
|
||||||
|
location = sqlite3_cursor.fetchone()
|
||||||
|
assert location[0] == person
|
||||||
|
assert location[1] == date_time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
assert location[2] == latitude
|
||||||
|
assert location[3] == longitude
|
||||||
|
assert location[4] == altitude
|
||||||
|
sqlite3_cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_reset_event_loop")
|
||||||
|
@pytest.mark.usefixtures("_create_latest_db")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_inser_location_other() -> None:
|
||||||
|
latitude = 1.0
|
||||||
|
longitude = 2.0
|
||||||
|
altitude = 3.0
|
||||||
|
person = "test_person"
|
||||||
|
tz = ZoneInfo("Asia/Shanghai")
|
||||||
|
date_time = datetime.now(tz=tz)
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
event_loop = asyncio.get_event_loop()
|
||||||
|
event_loop.run_until_complete(location_recorder.create_db_engine())
|
||||||
|
location_data = LocationData(latitude=latitude, longitude=longitude, altitude=altitude)
|
||||||
|
event_loop.run_until_complete(
|
||||||
|
location_recorder.insert_location(
|
||||||
|
person=person,
|
||||||
|
date_time=date_time,
|
||||||
|
location=location_data,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event_loop.run_until_complete(location_recorder.dispose_db_engine())
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("SELECT * FROM location")
|
||||||
|
location = sqlite3_cursor.fetchone()
|
||||||
|
assert location[0] == person
|
||||||
|
assert location[1] == date_time.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
assert location[2] == latitude
|
||||||
|
assert location[3] == longitude
|
||||||
|
assert location[4] == altitude
|
||||||
|
sqlite3_cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_reset_event_loop")
|
||||||
|
@pytest.mark.usefixtures("_create_latest_db")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_insert_location_now() -> None:
|
||||||
|
latitude = 1.0
|
||||||
|
longitude = 2.0
|
||||||
|
altitude = 3.0
|
||||||
|
person = "test_person"
|
||||||
|
date_time = datetime.now(tz=UTC)
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
event_loop = asyncio.get_event_loop()
|
||||||
|
event_loop.run_until_complete(location_recorder.create_db_engine())
|
||||||
|
location_data = LocationData(latitude=latitude, longitude=longitude, altitude=altitude)
|
||||||
|
event_loop.run_until_complete(
|
||||||
|
location_recorder.insert_location_now(
|
||||||
|
person=person,
|
||||||
|
location=location_data,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event_loop.run_until_complete(location_recorder.dispose_db_engine())
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("SELECT * FROM location")
|
||||||
|
location = sqlite3_cursor.fetchone()
|
||||||
|
assert location[0] == person
|
||||||
|
date_time_act = datetime.strptime(location[1], "%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
assert date_time.date() == date_time_act.date()
|
||||||
|
assert location[2] == latitude
|
||||||
|
assert location[3] == longitude
|
||||||
|
assert location[4] == altitude
|
||||||
|
sqlite3_cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_reset_event_loop")
|
||||||
|
@pytest.mark.usefixtures("_create_latest_db")
|
||||||
|
@pytest.mark.usefixtures("_teardown")
|
||||||
|
def test_insert_locations() -> None:
|
||||||
|
locations: dict[datetime, LocationData] = {}
|
||||||
|
person = "Tianyu"
|
||||||
|
time_0 = datetime.now(tz=UTC)
|
||||||
|
lat_0 = 1.0
|
||||||
|
lon_0 = 2.0
|
||||||
|
alt_0 = 3.0
|
||||||
|
time_1 = datetime(2021, 8, 30, 10, 20, 15, tzinfo=UTC)
|
||||||
|
lat_1 = 155.0
|
||||||
|
lon_1 = 33.36
|
||||||
|
alt_1 = 1058
|
||||||
|
locations[time_0] = LocationData(lat_0, lon_0, alt_0)
|
||||||
|
locations[time_1] = LocationData(lat_1, lon_1, alt_1)
|
||||||
|
location_recorder = LocationRecorder(db_path=DB_PATH_STR)
|
||||||
|
event_loop = asyncio.get_event_loop()
|
||||||
|
event_loop.run_until_complete(location_recorder.create_db_engine())
|
||||||
|
event_loop.run_until_complete(
|
||||||
|
location_recorder.insert_locations(person=person, locations=locations),
|
||||||
|
)
|
||||||
|
sqlite3_db = sqlite3.connect(DB_PATH_STR)
|
||||||
|
sqlite3_cursor = sqlite3_db.cursor()
|
||||||
|
sqlite3_cursor.execute("SELECT * FROM location")
|
||||||
|
locations = sqlite3_cursor.fetchall()
|
||||||
|
assert len(locations) == 2 # noqa: PLR2004
|
||||||
|
assert locations[0][0] == person
|
||||||
|
assert locations[0][1] == time_0.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
assert locations[0][2] == lat_0
|
||||||
|
assert locations[0][3] == lon_0
|
||||||
|
assert locations[0][4] == alt_0
|
||||||
|
assert locations[1][0] == person
|
||||||
|
assert locations[1][1] == time_1.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
assert locations[1][2] == lat_1
|
||||||
|
assert locations[1][3] == lon_1
|
||||||
|
assert locations[1][4] == alt_1
|
||||||
|
sqlite3_cursor.close()
|
||||||
84
src/util/ticktick.py
Normal file
84
src/util/ticktick.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class TickTick:
|
||||||
|
@dataclass
|
||||||
|
class Task:
|
||||||
|
projectId: str # noqa: N815
|
||||||
|
title: str
|
||||||
|
dueDate: str | None = None # noqa: N815
|
||||||
|
content: str | None = None
|
||||||
|
desc: str | None = None
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
print("Initializing TickTick...")
|
||||||
|
if Config.get_env("TICKTICK_ACCESS_TOKEN") is None:
|
||||||
|
self._begin_auth()
|
||||||
|
else:
|
||||||
|
self._access_token = Config.get_env("TICKTICK_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
def _begin_auth(self) -> None:
|
||||||
|
ticktick_code_auth_url = "https://ticktick.com/oauth/authorize?"
|
||||||
|
ticktick_code_auth_params = {
|
||||||
|
"client_id": Config.get_env("TICKTICK_CLIENT_ID"),
|
||||||
|
"scope": "tasks:read tasks:write",
|
||||||
|
"state": "begin_auth",
|
||||||
|
"redirect_uri": Config.get_env("TICKTICK_CODE_REDIRECT_URI"),
|
||||||
|
"response_type": "code",
|
||||||
|
}
|
||||||
|
ticktick_auth_url_encoded = urllib.parse.urlencode(ticktick_code_auth_params)
|
||||||
|
print("Visit: ", ticktick_code_auth_url + ticktick_auth_url_encoded, " to authenticate.")
|
||||||
|
|
||||||
|
async def retrieve_access_token(self, code: str, state: str) -> bool:
|
||||||
|
if state != "begin_auth":
|
||||||
|
print("Invalid state.")
|
||||||
|
return False
|
||||||
|
ticktick_token_url = "https://ticktick.com/oauth/token" # noqa: S105
|
||||||
|
ticktick_token_auth_params: dict[str, str] = {
|
||||||
|
"code": code,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"scope": "tasks:write tasks:read",
|
||||||
|
"redirect_uri": Config.get_env("TICKTICK_CODE_REDIRECT_URI"),
|
||||||
|
}
|
||||||
|
client_id = Config.get_env("TICKTICK_CLIENT_ID")
|
||||||
|
client_secret = Config.get_env("TICKTICK_CLIENT_SECRET")
|
||||||
|
response = await httpx.AsyncClient().post(
|
||||||
|
ticktick_token_url,
|
||||||
|
data=ticktick_token_auth_params,
|
||||||
|
auth=httpx.BasicAuth(username=client_id, password=client_secret),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
Config.update_env("TICKTICK_ACCESS_TOKEN", response.json()["access_token"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_tasks(self, project_id: str) -> list[dict]:
|
||||||
|
ticktick_get_tasks_url = "https://api.ticktick.com/open/v1/project/" + project_id + "/data"
|
||||||
|
header: dict[str, str] = {"Authorization": f"Bearer {self._access_token}"}
|
||||||
|
response = await httpx.AsyncClient().get(ticktick_get_tasks_url, headers=header, timeout=10)
|
||||||
|
return response.json()["tasks"]
|
||||||
|
|
||||||
|
async def has_duplicate_task(self, project_id: str, task_title: str) -> bool:
|
||||||
|
tasks = await self.get_tasks(project_id=project_id)
|
||||||
|
return any(task["title"] == task_title for task in tasks)
|
||||||
|
|
||||||
|
async def create_task(self, task: TickTick.Task) -> dict[str, str]:
|
||||||
|
if not await self.has_duplicate_task(project_id=task.projectId, task_title=task.title):
|
||||||
|
ticktick_task_creation_url = "https://api.ticktick.com/open/v1/task"
|
||||||
|
header: dict[str, str] = {"Authorization": f"Bearer {self._access_token}"}
|
||||||
|
await httpx.AsyncClient().post(ticktick_task_creation_url, headers=header, json=asdict(task), timeout=10)
|
||||||
|
return {"title": task.title}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def datetime_to_ticktick_format(datetime: datetime) -> str:
|
||||||
|
return datetime.strftime("%Y-%m-%dT%H:%M:%S") + "+0000"
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
package ticktick
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
authState string
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DateTimeLayout = "2006-01-02T15:04:05-0700"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Column struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ProjectId string `json:"projectId"`
|
|
||||||
SortOrder int64 `json:"sortOrder,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectData struct {
|
|
||||||
Project Project `json:"project"`
|
|
||||||
Tasks []Task `json:"tasks"`
|
|
||||||
Columns []Column `json:"columns,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
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 {
|
|
||||||
beginAuth()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleAuthCode(w http.ResponseWriter, r *http.Request) {
|
|
||||||
state := r.URL.Query().Get("state")
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if state != 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 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 HasDuplicateTask(projectId string, taskTitile string) bool {
|
|
||||||
tasks := GetTasks(projectId)
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Title == taskTitile {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateTask(task Task) error {
|
|
||||||
if 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 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)
|
|
||||||
}
|
|
||||||
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", 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()))
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user