feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Local, read-only Garmin sync; reusable independently of the eventual web UI."""
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user