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