Add headless Garmin background sync (systemd timer)
New `bincio sync-garmin` command mirrors sync-strava: discovers all users with garmin_creds.json, refreshes cached garth OAuth2 session, imports new activities, and optionally POSTs to the rebuild endpoint. systemd timer fires every 3h offset by 1h30m from Strava to avoid simultaneous rebuilds. Status written to _garmin_sync_status.json per user.
This commit is contained in:
@@ -20,6 +20,7 @@ from bincio.serve.cli import serve # noqa: E402
|
|||||||
from bincio.dev import dev # noqa: E402
|
from bincio.dev import dev # noqa: E402
|
||||||
from bincio.reextract_cmd import reextract_originals # noqa: E402
|
from bincio.reextract_cmd import reextract_originals # noqa: E402
|
||||||
from bincio.sync_strava import sync_strava_cmd # noqa: E402
|
from bincio.sync_strava import sync_strava_cmd # noqa: E402
|
||||||
|
from bincio.sync_garmin import sync_garmin_cmd # noqa: E402
|
||||||
from bincio.segments.cli import segments_group # noqa: E402
|
from bincio.segments.cli import segments_group # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -49,4 +50,5 @@ main.add_command(serve)
|
|||||||
main.add_command(dev)
|
main.add_command(dev)
|
||||||
main.add_command(reextract_originals)
|
main.add_command(reextract_originals)
|
||||||
main.add_command(sync_strava_cmd)
|
main.add_command(sync_strava_cmd)
|
||||||
|
main.add_command(sync_garmin_cmd)
|
||||||
main.add_command(segments_group)
|
main.add_command(segments_group)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Headless multi-user Garmin sync — designed to run as a systemd timer.
|
||||||
|
|
||||||
|
For each user directory that contains garmin_creds.json, tries to refresh
|
||||||
|
the cached garth OAuth2 session (fast, no full login), falls back to a full
|
||||||
|
email/password re-login only when the session has expired, then fetches and
|
||||||
|
ingests new activities via garmin_sync_iter.
|
||||||
|
|
||||||
|
After all users are synced, optionally POSTs to a server endpoint to trigger
|
||||||
|
an Astro rebuild + rsync.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
_STATUS_FILE = "_garmin_sync_status.json"
|
||||||
|
|
||||||
|
log = logging.getLogger("bincio.sync_garmin")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_status(
|
||||||
|
user_dir: Path,
|
||||||
|
status: str,
|
||||||
|
imported: int,
|
||||||
|
errors: int,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload: dict = {
|
||||||
|
"status": status,
|
||||||
|
"imported": imported,
|
||||||
|
"errors": errors,
|
||||||
|
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
if error_message is not None:
|
||||||
|
payload["error_message"] = error_message
|
||||||
|
try:
|
||||||
|
(user_dir / _STATUS_FILE).write_text(
|
||||||
|
json.dumps(payload, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _post_rebuild(url: str, secret: str | None) -> None:
|
||||||
|
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||||
|
if secret:
|
||||||
|
headers["X-Sync-Secret"] = secret
|
||||||
|
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
log.info("rebuild triggered: HTTP %d", resp.status)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("rebuild trigger failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_user(data_dir: Path, user_dir: Path) -> tuple[int, int]:
|
||||||
|
"""Sync one user's Garmin activities.
|
||||||
|
|
||||||
|
Returns (imported_count, error_count). Skips silently if no credentials.
|
||||||
|
"""
|
||||||
|
from bincio.extract.garmin_api import GarminError, has_credentials, get_client
|
||||||
|
from bincio.extract.garmin_sync import run_garmin_sync
|
||||||
|
|
||||||
|
handle = user_dir.name
|
||||||
|
|
||||||
|
if not has_credentials(user_dir):
|
||||||
|
log.debug("sync[%s]: no garmin_creds.json — skipped", handle)
|
||||||
|
_write_status(user_dir, "no_credentials", 0, 0)
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
# Explicit auth step so we can distinguish auth failures from API failures.
|
||||||
|
try:
|
||||||
|
get_client(data_dir, user_dir)
|
||||||
|
except GarminError as exc:
|
||||||
|
log.error("sync[%s]: auth failed: %s", handle, exc)
|
||||||
|
_write_status(user_dir, "auth_error", 0, 1, str(exc))
|
||||||
|
return 0, 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_garmin_sync(data_dir, user_dir)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
log.error("sync[%s]: sync failed: %s", handle, exc)
|
||||||
|
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||||
|
return 0, 1
|
||||||
|
|
||||||
|
imported = result.get("imported", 0)
|
||||||
|
error_count = result.get("error_count", 0)
|
||||||
|
|
||||||
|
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, error_count)
|
||||||
|
_write_status(user_dir, "ok", imported, error_count)
|
||||||
|
return imported, error_count
|
||||||
|
|
||||||
|
|
||||||
|
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
|
||||||
|
"""Sync all users that have garmin_creds.json. Returns {handle: (imported, errors)}."""
|
||||||
|
results: dict[str, tuple[int, int]] = {}
|
||||||
|
cred_files = sorted(root_data_dir.glob("*/garmin_creds.json"))
|
||||||
|
if not cred_files:
|
||||||
|
log.info("sync_all: no users with garmin_creds.json found in %s", root_data_dir)
|
||||||
|
return results
|
||||||
|
log.info("sync_all: %d user(s) with Garmin credentials", len(cred_files))
|
||||||
|
for cf in cred_files:
|
||||||
|
user_dir = cf.parent
|
||||||
|
handle = user_dir.name
|
||||||
|
try:
|
||||||
|
results[handle] = sync_user(root_data_dir, user_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
|
||||||
|
results[handle] = (0, -1)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("sync-garmin")
|
||||||
|
@click.option("--data-dir", "data_dir_str", required=True,
|
||||||
|
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
|
||||||
|
@click.option("--user", "only_user", default=None,
|
||||||
|
help="Sync only this handle instead of all users.")
|
||||||
|
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
|
||||||
|
help="POST here after a successful sync to trigger a site rebuild.")
|
||||||
|
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
|
||||||
|
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
|
||||||
|
def sync_garmin_cmd(
|
||||||
|
data_dir_str: str,
|
||||||
|
only_user: str | None,
|
||||||
|
rebuild_url: str | None,
|
||||||
|
rebuild_secret: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Headless Garmin sync for all users (designed for systemd timer).
|
||||||
|
|
||||||
|
Discovers every user directory that has garmin_creds.json, tries to
|
||||||
|
resume the cached garth session (no full re-login if the token is still
|
||||||
|
valid), fetches new activities, and optionally triggers a site rebuild.
|
||||||
|
"""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
root = Path(data_dir_str).expanduser().resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
raise click.ClickException(f"Data dir not found: {root}")
|
||||||
|
|
||||||
|
if only_user:
|
||||||
|
user_dir = root / only_user
|
||||||
|
if not user_dir.is_dir():
|
||||||
|
raise click.ClickException(f"User dir not found: {user_dir}")
|
||||||
|
new_count, err_count = sync_user(root, user_dir)
|
||||||
|
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
|
||||||
|
total_new = new_count
|
||||||
|
else:
|
||||||
|
results = sync_all(root)
|
||||||
|
total_new = sum(n for n, _ in results.values())
|
||||||
|
total_err = sum(e for _, e in results.values())
|
||||||
|
click.echo(
|
||||||
|
f"Sync complete: {len(results)} users, "
|
||||||
|
f"{total_new} new activities, {total_err} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_new > 0 and rebuild_url:
|
||||||
|
_post_rebuild(rebuild_url, rebuild_secret)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=BincioActivity Garmin sync
|
||||||
|
Documentation=https://github.com/bincio/bincio-activity
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/bincio
|
||||||
|
|
||||||
|
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
|
||||||
|
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
|
||||||
|
EnvironmentFile=/etc/bincio/sync.env
|
||||||
|
|
||||||
|
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-garmin \
|
||||||
|
--data-dir /var/bincio/data \
|
||||||
|
--rebuild-url http://localhost:4041/api/internal/rebuild
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=bincio-sync-garmin
|
||||||
|
|
||||||
|
# Don't restart on failure — the timer will retry in 3 hours.
|
||||||
|
Restart=no
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=BincioActivity Garmin sync — every 3 hours
|
||||||
|
Documentation=https://github.com/bincio/bincio-activity
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Fire at 01:30, 04:30, 07:30, 10:30, 13:30, 16:30, 19:30, 22:30 UTC
|
||||||
|
# Offset by 1h30m from the Strava timer to avoid simultaneous rebuilds.
|
||||||
|
OnCalendar=*-*-* 01,04,07,10,13,16,19,22:30:00
|
||||||
|
# Catch up if the VPS was offline during a scheduled run
|
||||||
|
Persistent=true
|
||||||
|
# Spread load within a 2-minute window
|
||||||
|
RandomizedDelaySec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user