feat: build Stride local app with Garmin imports and friends-only sharing

This commit is contained in:
Peter Stockings
2026-10-01 14:55:59 +10:00
parent 16e71a69d5
commit e37569ad09
45 changed files with 3735 additions and 40 deletions
+1
View File
@@ -0,0 +1 @@
"""Local, read-only Garmin sync; reusable independently of the eventual web UI."""
+145
View File
@@ -0,0 +1,145 @@
"""Run with `uv run python -m garmin_sync --help`. Never accepts passwords as flags."""
import argparse
import getpass
import json
import logging
import os
import re
import sys
import warnings
from pathlib import Path
from filelock import FileLock, Timeout
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from .service import (
SyncError,
account_key,
atomic_write,
private_directory,
sync_activities,
write_json,
)
ROOT = Path(__file__).resolve().parents[1]
def parser():
cli = argparse.ArgumentParser(description="Read-only Garmin completed-activity sync.")
cli.add_argument("--profile", default="default", help="Local account slot (default: default)")
cli.add_argument("--domain", choices=["garmin.com", "garmin.cn"], default="garmin.com")
commands = cli.add_subparsers(dest="command", required=True)
commands.add_parser("login", help="Interactive local Garmin login and MFA; save session")
commands.add_parser("verify", help="Validate the saved session without credentials")
imported = commands.add_parser("import-tokens", help="Validate and copy a local token export")
imported.add_argument("file", type=Path)
sync = commands.add_parser("sync", help="Download and decode recent completed activities")
sync.add_argument("--limit", type=int, default=5, help="Recent activities to examine (1–1000)")
sync.add_argument("--type", choices=["all", "running"], default="all")
return cli
def run(args):
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", args.profile):
raise SyncError("Profile must be 1–64 letters, numbers, underscores or hyphens.")
if args.command == "sync" and not 1 <= args.limit <= 1000:
raise SyncError("Limit must be between 1 and 1000.")
# An inherited global token store must never select another account.
os.environ.pop("GARMINTOKENS", None)
store = ROOT / "instance" / "garmin" / args.profile
private_directory(store)
token_path = store / "garmin_tokens.json"
with FileLock(str(store / ".session.lock"), timeout=0):
api = Garmin(is_cn=args.domain == "garmin.cn")
if args.command == "login":
if not sys.stdin.isatty():
raise SyncError(
"Run login in your own interactive terminal; do not paste credentials into chat."
)
email = input("Garmin email: ").strip()
with warnings.catch_warnings():
warnings.simplefilter("error", getpass.GetPassWarning)
password = getpass.getpass("Garmin password (hidden): ")
api = Garmin(
email=email,
password=password,
is_cn=args.domain == "garmin.cn",
prompt_mfa=lambda: getpass.getpass("Garmin MFA code (hidden): "),
)
password = None
print("Authenticating with Garmin...", flush=True)
api.login() # Deliberately do not reuse a previous account's token file.
elif args.command == "import-tokens":
if args.file.stat().st_size > 65536:
raise SyncError("Token file is too large.")
payload = json.loads(args.file.read_text(encoding="utf-8-sig"))
if not isinstance(payload, dict) or not all(
isinstance(payload.get(key), str) and payload[key]
for key in ("di_token", "di_refresh_token", "di_client_id")
):
raise SyncError("Unsupported token format; export with garminconnect 0.3.17.")
api.login(
json.dumps(
{key: payload[key] for key in ("di_token", "di_refresh_token", "di_client_id")}
)
)
else:
if not token_path.is_file():
raise SyncError("No saved session. Run login or import-tokens first.")
api.login(str(token_path))
key = account_key(api, args.domain)
# Writes refreshed state for inline imports/login, plus every successful resume.
atomic_write(token_path, api.client.dumps().encode())
print(f"Garmin session verified. Local account: {key}", flush=True)
if args.command != "sync":
print(f"Session saved privately in instance/garmin/{args.profile}/garmin_tokens.json")
return 0
destination = ROOT / "data" / "garmin" / key
try:
result = sync_activities(
api, destination, args.limit, None if args.type == "all" else args.type
)
finally:
# Preserve rotations even if a later download fails.
atomic_write(token_path, api.client.dumps().encode())
write_json(destination / "last-sync.json", result)
print(json.dumps(result, indent=2))
print(f"Private activity files: {destination}")
return 1 if result["failed"] else 0
def main():
# Third-party exception/log strings can contain credentials and request URLs.
logging.disable(logging.CRITICAL)
args = parser().parse_args()
try:
return run(args)
except SyncError as error:
print(f"Error: {error}", file=sys.stderr)
except GarminConnectTooManyRequestsError:
print("Garmin rate limit reached. Wait before retrying.", file=sys.stderr)
except GarminConnectAuthenticationError:
print("Garmin rejected the session/login. Run login locally to reconnect.", file=sys.stderr)
except GarminConnectConnectionError:
print("Garmin connection failed. Retry later; no credentials were logged.", file=sys.stderr)
except Timeout:
print("Another command is using this profile. Wait for it to finish.", file=sys.stderr)
except (KeyboardInterrupt, EOFError):
print("Cancelled.", file=sys.stderr)
except Exception as error: # noqa: BLE001 -- CLI boundary must not expose provider secrets.
print(
f"Operation failed ({type(error).__name__}); details suppressed to protect credentials.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
"""Download completed activities, preserving originals and decoded FIT data."""
from __future__ import annotations
import hashlib
import io
import json
import os
import tempfile
import zipfile
from collections import Counter
from datetime import UTC, date, datetime
from pathlib import Path
import fitdecode
from garminconnect import Garmin
MAX_BYTES = 64 * 1024 * 1024
class SyncError(Exception):
"""An actionable error with no provider response or credentials attached."""
def private_directory(path: Path) -> None:
path.mkdir(mode=0o700, parents=True, exist_ok=True)
if os.name != "nt":
path.chmod(0o700)
def atomic_write(path: Path, content: bytes) -> None:
private_directory(path.parent)
fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def json_default(value):
if isinstance(value, datetime):
# FIT timestamps are UTC; fitdecode returns naive datetime objects.
return value.replace(tzinfo=UTC).isoformat() if value.tzinfo is None else value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, bytes):
return value.hex()
raise TypeError(f"Unsupported JSON type: {type(value).__name__}")
def write_json(path: Path, value) -> None:
atomic_write(path, json.dumps(value, indent=2, default=json_default).encode("utf-8"))
def fit_payloads(payload: bytes) -> list[bytes]:
"""Read bounded FIT members without extracting archive paths to disk."""
if not payload or len(payload) > MAX_BYTES:
raise SyncError("Download was empty or exceeded the 64 MiB limit.")
if len(payload) >= 12 and payload[8:12] == b".FIT":
return [payload]
try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
members = [m for m in archive.infolist() if m.filename.lower().endswith(".fit")]
if not members:
raise SyncError(
"Original download contains no FIT file (possibly a manual activity)."
)
if len(members) > 16 or sum(m.file_size for m in members) > MAX_BYTES:
raise SyncError("FIT archive exceeds the extraction limit.")
result = []
for member in members:
with archive.open(member) as stream:
content = stream.read(MAX_BYTES + 1)
if len(content) > MAX_BYTES:
raise SyncError("FIT archive exceeds the extraction limit.")
result.append(content)
return result
except (zipfile.BadZipFile, RuntimeError, NotImplementedError):
raise SyncError("Original download is not a readable FIT or ZIP file.") from None
def decode_fit(content: bytes) -> dict:
counts = Counter()
messages = {"session": [], "lap": [], "record": []}
units = {}
try:
with fitdecode.FitReader(io.BytesIO(content), check_crc=fitdecode.CrcCheck.RAISE) as reader:
for frame in reader:
if not isinstance(frame, fitdecode.FitDataMessage):
continue
counts[frame.name] += 1
if frame.name in messages:
values = {}
for field in frame.fields:
values[field.name] = field.value
if field.units:
units[f"{frame.name}.{field.name}"] = field.units
messages[frame.name].append(values)
except (fitdecode.FitError, ValueError, TypeError, KeyError, OverflowError):
raise SyncError("FIT decoding or CRC validation failed.") from None
if not counts:
raise SyncError("FIT file contains no data messages.")
return {"message_counts": dict(counts), "units": units, **messages}
def account_key(api: Garmin, domain: str) -> str:
# Loaded by Garmin.login from the provider, never taken from an uploaded label.
profile_id = getattr(api, "profile_id", None)
identity = f"id:{profile_id}" if isinstance(profile_id, int) else api.display_name
if not isinstance(identity, str) or not identity:
raise SyncError("Garmin did not return an account identity.")
return hashlib.sha256(f"{domain}:{identity}".encode()).hexdigest()[:24]
def complete(directory: Path) -> bool:
try:
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
files = manifest["files"]
if manifest.get("version") != 1 or not files:
return False
for name, digest in files.items():
if Path(name).name != name or "/" in name or "\\" in name:
return False
if hashlib.sha256((directory / name).read_bytes()).hexdigest() != digest:
return False
return True
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return False
def save_activity(api: Garmin, metadata: dict, root: Path) -> tuple[str, int]:
raw_id = metadata.get("activityId")
if isinstance(raw_id, bool) or not str(raw_id).isascii() or not str(raw_id).isdigit():
raise SyncError("Garmin returned an invalid activity ID.")
activity_id = str(int(raw_id))
if int(activity_id) <= 0:
raise SyncError("Garmin returned an invalid activity ID.")
directory = root / activity_id
if complete(directory):
return "skipped", 0
payload = api.download_activity(activity_id, dl_fmt=Garmin.ActivityDownloadFormat.ORIGINAL)
fits = fit_payloads(payload)
decoded = [decode_fit(content) for content in fits]
# Parse first; write a manifest last so incomplete imports are retried.
originals_name = "original.fit" if payload[8:12] == b".FIT" else "original.zip"
files = {originals_name: payload}
files["activity.json"] = json.dumps(metadata, indent=2, default=json_default).encode()
for index, (content, parsed) in enumerate(zip(fits, decoded, strict=True), start=1):
files[f"activity-{index}.fit"] = content
files[f"activity-{index}.json"] = json.dumps(
parsed, indent=2, default=json_default
).encode()
for name, content in files.items():
atomic_write(directory / name, content)
records = sum(item["message_counts"].get("record", 0) for item in decoded)
write_json(
directory / "manifest.json",
{
"version": 1,
"activity_id": activity_id,
"imported_at": datetime.now(UTC),
"fit_files": len(fits),
"record_count": records,
"files": {name: hashlib.sha256(content).hexdigest() for name, content in files.items()},
},
)
return "downloaded", records
def sync_activities(api: Garmin, root: Path, limit: int, activity_type: str | None) -> dict:
if not 1 <= limit <= 1000:
raise SyncError("Limit must be between 1 and 1000.")
result = {"found": 0, "downloaded": 0, "skipped": 0, "failed": 0, "records": 0, "errors": []}
offset = 0
while offset < limit:
page_size = min(50, limit - offset)
page = api.get_activities(start=offset, limit=page_size, activitytype=activity_type)
if not isinstance(page, list):
raise SyncError("Garmin returned an unexpected activity list.")
if not page:
break
for metadata in page[:page_size]:
result["found"] += 1
try:
if not isinstance(metadata, dict):
raise SyncError("Garmin returned invalid activity metadata.")
state, records = save_activity(api, metadata, root)
result[state] += 1
result["records"] += records
except SyncError as error:
result["failed"] += 1
result["errors"].append(str(error))
offset += len(page)
if len(page) < page_size:
break
return result