From 0eb25620ef113fcc7750e82151b5a198ded78d15 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sat, 16 May 2026 20:13:12 +0200 Subject: [PATCH] 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. --- bincio/cli.py | 2 + bincio/sync_garmin.py | 169 ++++++++++++++++++++++ deploy/systemd/bincio-sync-garmin.service | 25 ++++ deploy/systemd/bincio-sync-garmin.timer | 15 ++ 4 files changed, 211 insertions(+) create mode 100644 bincio/sync_garmin.py create mode 100644 deploy/systemd/bincio-sync-garmin.service create mode 100644 deploy/systemd/bincio-sync-garmin.timer diff --git a/bincio/cli.py b/bincio/cli.py index 7b740d7..ed17292 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -20,6 +20,7 @@ from bincio.serve.cli import serve # noqa: E402 from bincio.dev import dev # noqa: E402 from bincio.reextract_cmd import reextract_originals # 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 @@ -49,4 +50,5 @@ main.add_command(serve) main.add_command(dev) main.add_command(reextract_originals) main.add_command(sync_strava_cmd) +main.add_command(sync_garmin_cmd) main.add_command(segments_group) diff --git a/bincio/sync_garmin.py b/bincio/sync_garmin.py new file mode 100644 index 0000000..491010f --- /dev/null +++ b/bincio/sync_garmin.py @@ -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) diff --git a/deploy/systemd/bincio-sync-garmin.service b/deploy/systemd/bincio-sync-garmin.service new file mode 100644 index 0000000..b8f0473 --- /dev/null +++ b/deploy/systemd/bincio-sync-garmin.service @@ -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 diff --git a/deploy/systemd/bincio-sync-garmin.timer b/deploy/systemd/bincio-sync-garmin.timer new file mode 100644 index 0000000..72ef968 --- /dev/null +++ b/deploy/systemd/bincio-sync-garmin.timer @@ -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