202 lines
8.0 KiB
Python
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
|