Files
Davide Scaini 0eb25620ef 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.
2026-05-16 20:13:12 +02:00

170 lines
6.0 KiB
Python

"""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)