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:
Davide Scaini
2026-05-16 20:13:12 +02:00
parent 307f1fbbc1
commit 0eb25620ef
4 changed files with 211 additions and 0 deletions
+2
View File
@@ -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)
+169
View File
@@ -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)
+25
View File
@@ -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
+15
View File
@@ -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