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