Files

202 lines
8.0 KiB
Python

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