unstrap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
unstrap/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Unstrap: copy Garmin watch data off the watch over USB (MTP) and upload it."""
2
+
3
+ __version__ = "0.1.0"
unstrap/api.py ADDED
@@ -0,0 +1,186 @@
1
+ """HTTP client for the Unstrap sync API (plan §4). Retries only what is safe to retry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import random
7
+ import time
8
+ from collections.abc import Callable, Sequence
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from unstrap import __version__
16
+ from unstrap.transport import DeviceInfo, RemoteFile
17
+
18
+ DEFAULT_SERVER = "https://unstrap.sweatstack.no"
19
+ MAX_ATTEMPTS = 5
20
+
21
+
22
+ class ApiError(Exception):
23
+ """The server said no. `message` is meant for people and is printed as-is."""
24
+
25
+ def __init__(self, status: int, code: str, message: str) -> None:
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.code = code
29
+ self.message = message
30
+
31
+
32
+ class ClientTooOld(ApiError):
33
+ pass
34
+
35
+
36
+ class ServerUnreachable(Exception):
37
+ pass
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class HashedFile:
42
+ path: str
43
+ size: int
44
+ sha256: str
45
+
46
+ def as_json(self) -> dict[str, Any]:
47
+ return {"path": self.path, "size": self.size, "sha256": self.sha256}
48
+
49
+
50
+ def user_agent(mtp_rs_version: str | None) -> str:
51
+ system = f"{platform.system()} {platform.machine()}".strip()
52
+ return f"unstrap/{__version__} ({system}; mtp-rs/{mtp_rs_version or 'none'})"
53
+
54
+
55
+ class Api:
56
+ def __init__(
57
+ self,
58
+ base_url: str = DEFAULT_SERVER,
59
+ *,
60
+ agent: str | None = None,
61
+ client: httpx.Client | None = None,
62
+ sleep: Callable[[float], None] = time.sleep,
63
+ ) -> None:
64
+ self.base_url = base_url.rstrip("/")
65
+ self._client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0))
66
+ self._agent = agent or user_agent(None)
67
+ self._sleep = sleep
68
+
69
+ def close(self) -> None:
70
+ self._client.close()
71
+
72
+ # -- plumbing ---------------------------------------------------------------------------------
73
+
74
+ def _request(
75
+ self,
76
+ method: str,
77
+ path: str,
78
+ *,
79
+ token: str | None = None,
80
+ json: Any = None,
81
+ content: bytes | None = None,
82
+ headers: dict[str, str] | None = None,
83
+ ) -> dict[str, Any]:
84
+ h = {"user-agent": self._agent, **(headers or {})}
85
+ if token:
86
+ h["authorization"] = f"Bearer {token}"
87
+ url = f"{self.base_url}/api/v1{path}"
88
+ last: Exception | None = None
89
+ for attempt in range(MAX_ATTEMPTS):
90
+ try:
91
+ res = self._client.request(method, url, json=json, content=content, headers=h)
92
+ except httpx.TransportError as e:
93
+ last = e
94
+ self._backoff(attempt, None)
95
+ continue
96
+ if res.status_code == 429 or res.status_code >= 500:
97
+ last = ApiError(res.status_code, *_error(res))
98
+ self._backoff(attempt, res.headers.get("retry-after"))
99
+ continue
100
+ if res.status_code >= 400:
101
+ code, message = _error(res)
102
+ if res.status_code == 426:
103
+ raise ClientTooOld(426, code, message)
104
+ raise ApiError(res.status_code, code, message)
105
+ try:
106
+ data: dict[str, Any] = res.json()
107
+ except ValueError as e:
108
+ # E.g. a captive portal or proxy answering in HTML (review M3).
109
+ raise ServerUnreachable(
110
+ f"Unexpected answer from {self.base_url} (a Wi-Fi login page or proxy?)"
111
+ ) from e
112
+ return data
113
+ if isinstance(last, ApiError):
114
+ raise last
115
+ raise ServerUnreachable(f"Can't reach {self.base_url}: {last}")
116
+
117
+ def _backoff(self, attempt: int, retry_after: str | None) -> None:
118
+ if attempt + 1 >= MAX_ATTEMPTS:
119
+ return
120
+ if retry_after and retry_after.isdigit():
121
+ delay = min(float(retry_after), 60.0)
122
+ else:
123
+ delay = min(2.0**attempt, 30.0) * (0.5 + random.random() / 2)
124
+ self._sleep(delay)
125
+
126
+ # -- endpoints --------------------------------------------------------------------------------
127
+
128
+ def healthz(self) -> bool:
129
+ try:
130
+ res = self._client.get(f"{self.base_url}/healthz", headers={"user-agent": self._agent})
131
+ except httpx.TransportError:
132
+ return False
133
+ return res.status_code == 200
134
+
135
+ def start(self, device: DeviceInfo, listing: Sequence[RemoteFile]) -> dict[str, Any]:
136
+ dev: dict[str, Any] = {"usb_serial": device.usb_serial, "unit_id": device.unit_id}
137
+ if device.product:
138
+ dev["product"] = device.product[:100]
139
+ if device.firmware:
140
+ dev["firmware"] = device.firmware[:32]
141
+ body = {"device": dev, "listing": [{"path": f.path, "size": f.size} for f in listing]}
142
+ return self._request("POST", "/syncs", json=body)
143
+
144
+ def prove(
145
+ self, sync_id: int, token: str, nonce: str, proofs: list[dict[str, str]]
146
+ ) -> dict[str, Any]:
147
+ body = {"nonce": nonce, "proofs": proofs}
148
+ return self._request("POST", f"/syncs/{sync_id}/prove", token=token, json=body)
149
+
150
+ def check(self, sync_id: int, token: str, files: Sequence[HashedFile]) -> list[str]:
151
+ body = {"files": [f.as_json() for f in files]}
152
+ res = self._request("POST", f"/syncs/{sync_id}/check", token=token, json=body)
153
+ return list(res["missing"])
154
+
155
+ def put_blob(self, sync_id: int, token: str, file: HashedFile, data: bytes) -> bool:
156
+ headers = {
157
+ "unstrap-path": quote(file.path, safe=""),
158
+ "content-type": "application/octet-stream",
159
+ }
160
+ res = self._request(
161
+ "PUT",
162
+ f"/syncs/{sync_id}/blobs/{file.sha256}",
163
+ token=token,
164
+ content=data,
165
+ headers=headers,
166
+ )
167
+ return bool(res.get("stored"))
168
+
169
+ def finish(
170
+ self,
171
+ sync_id: int,
172
+ token: str,
173
+ files: Sequence[HashedFile],
174
+ stats: dict[str, int | str | bool],
175
+ errors: list[str],
176
+ ) -> dict[str, Any]:
177
+ body = {"files": [f.as_json() for f in files], "stats": stats, "errors": errors[:100]}
178
+ return self._request("POST", f"/syncs/{sync_id}/finish", token=token, json=body)
179
+
180
+
181
+ def _error(res: httpx.Response) -> tuple[str, str]:
182
+ try:
183
+ err = res.json()["error"]
184
+ return str(err["code"]), str(err["message"])
185
+ except (ValueError, KeyError, TypeError):
186
+ return "http_error", f"Server returned HTTP {res.status_code}."
unstrap/cli.py ADDED
@@ -0,0 +1,271 @@
1
+ """`unstrap`: copy a Garmin watch's data off the watch and upload it (plan §5.2).
2
+
3
+ Headless and non-interactive: it never asks anything, so it runs the same from a terminal,
4
+ cron or the Raspberry Pi box. Human output goes to stderr; `--json` prints a report to stdout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import platform
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+ from typing import TextIO
17
+
18
+ from unstrap import __version__
19
+ from unstrap.api import DEFAULT_SERVER, Api, ApiError, ClientTooOld, ServerUnreachable, user_agent
20
+ from unstrap.sync import WAIT_FOR_DEVICE_SECONDS, SyncReport, run_sync
21
+ from unstrap.transport import Transport, TransportError
22
+ from unstrap.transport.directory import DirectoryTransport
23
+ from unstrap.transport.mtp_rs import (
24
+ MISSING_BINARY,
25
+ SUPPORTED_MTP_RS,
26
+ MtpRsTransport,
27
+ find_binary,
28
+ )
29
+
30
+ # Exit codes (plan §5.2).
31
+ OK, OTHER, NO_DEVICE, SERVER_UNREACHABLE, PARTIAL, TOO_OLD, STUCK = 0, 1, 2, 3, 4, 5, 6
32
+
33
+ TAGLINE = "Plug in. Pull out. Done."
34
+ LINUX_HELP = "https://unstrap.sweatstack.no/linux"
35
+
36
+ ADVICE = {
37
+ "no_device": (
38
+ "No Garmin watch found. Plug it in at the watch face (no buttons held) and check "
39
+ "Settings > System > USB Mode is MTP."
40
+ ),
41
+ "stuck": "The watch stopped responding. Unplug it, plug it back in, and run unstrap again.",
42
+ "permission": f"No permission to open the watch over USB. See {LINUX_HELP}",
43
+ "busy": (
44
+ "Another program is using the watch (Garmin Express, Android File Transfer, "
45
+ f"a file manager). Close it and try again. See {LINUX_HELP}"
46
+ ),
47
+ }
48
+
49
+
50
+ class Output:
51
+ def __init__(self, stream: TextIO, quiet: bool) -> None:
52
+ self.stream = stream
53
+ self.quiet = quiet
54
+
55
+ def __call__(self, message: str = "") -> None:
56
+ if not self.quiet:
57
+ print(message, file=self.stream, flush=True)
58
+
59
+
60
+ def _add_common(parser: argparse.ArgumentParser, *, top_level: bool) -> None:
61
+ """Options accepted before or after the subcommand (review M3: `unstrap sync --json`)."""
62
+
63
+ def default(value: object) -> object:
64
+ # Subcommand copies must not overwrite a value given before the subcommand.
65
+ return value if top_level else argparse.SUPPRESS
66
+
67
+ parser.add_argument(
68
+ "--server",
69
+ default=default(os.environ.get("UNSTRAP_SERVER", DEFAULT_SERVER)),
70
+ help="Unstrap server (env UNSTRAP_SERVER)",
71
+ )
72
+ parser.add_argument(
73
+ "--device", default=default(None), help="USB serial of the watch, if several are connected"
74
+ )
75
+ parser.add_argument(
76
+ "--source",
77
+ type=Path,
78
+ default=default(None),
79
+ help=(
80
+ "read a copy of the watch's files on this computer (a folder containing GARMIN/, "
81
+ "or the GARMIN folder itself) instead of the USB watch. Leave out to use the watch."
82
+ ),
83
+ )
84
+ parser.add_argument(
85
+ "--json", action="store_true", default=default(False), help="print a JSON report on stdout"
86
+ )
87
+ parser.add_argument("-v", "--verbose", action="store_true", default=default(False))
88
+
89
+
90
+ def build_parser() -> argparse.ArgumentParser:
91
+ parser = argparse.ArgumentParser(
92
+ prog="unstrap",
93
+ description="Your watch data, set free. " + TAGLINE,
94
+ )
95
+ parser.add_argument("--version", action="version", version=f"unstrap {__version__}")
96
+ _add_common(parser, top_level=True)
97
+ sub = parser.add_subparsers(dest="command")
98
+ sync = sub.add_parser("sync", help="sync the watch (the default)")
99
+ _add_common(sync, top_level=False)
100
+ sync.add_argument("--save", type=Path, help="also keep a complete local copy in this folder")
101
+ sync.add_argument("--dry-run", action="store_true", help="list the watch; upload nothing")
102
+ for name, text in [
103
+ ("info", "show the connected watch; upload nothing"),
104
+ ("doctor", "check that everything needed for a sync works"),
105
+ ]:
106
+ _add_common(sub.add_parser(name, help=text), top_level=False)
107
+ return parser
108
+
109
+
110
+ def make_transport(args: argparse.Namespace) -> Transport:
111
+ if args.source is not None:
112
+ return DirectoryTransport(args.source, usb_serial=args.device)
113
+ binary = find_binary()
114
+ if binary is None:
115
+ raise TransportError("other", MISSING_BINARY)
116
+ return MtpRsTransport(binary, device=args.device)
117
+
118
+
119
+ def _mtp_version(transport: Transport) -> str | None:
120
+ return transport.version() if isinstance(transport, MtpRsTransport) else None
121
+
122
+
123
+ def main(argv: Sequence[str] | None = None) -> int:
124
+ args = build_parser().parse_args(argv)
125
+ command = args.command or "sync"
126
+ out = Output(sys.stderr, quiet=False)
127
+ try:
128
+ if command == "doctor":
129
+ return doctor(args, out)
130
+ transport = make_transport(args)
131
+ if command == "info":
132
+ return info(transport, args, out)
133
+ return sync(transport, args, out)
134
+ except TransportError as e:
135
+ if e.code in ADVICE:
136
+ out(ADVICE[e.code])
137
+ out(f" Details: {e}") # always: generic advice must never hide the real reason
138
+ else:
139
+ out(str(e) if e.code == "bad_source" else f"Watch error: {e}")
140
+ return {"no_device": NO_DEVICE, "stuck": STUCK}.get(e.code, OTHER)
141
+ except ClientTooOld as e:
142
+ out(e.message)
143
+ return TOO_OLD
144
+ except ServerUnreachable as e:
145
+ out(f"{e}. Check your internet connection and try again.")
146
+ return SERVER_UNREACHABLE
147
+ except ApiError as e:
148
+ out(f"The server refused: {e.message}")
149
+ return OTHER
150
+ except KeyboardInterrupt:
151
+ out("Stopped. Run unstrap again to continue where it stopped.")
152
+ return OTHER
153
+
154
+
155
+ def sync(transport: Transport, args: argparse.Namespace, out: Output) -> int:
156
+ human = Output(sys.stderr, quiet=args.json)
157
+ human(f"Unstrap {__version__}. {TAGLINE}")
158
+ human("Waiting for a Garmin watch...")
159
+ device = transport.wait_for_device(WAIT_FOR_DEVICE_SECONDS)
160
+ human(f"Found {device.product or 'a Garmin watch'} (unit ID {device.unit_id})")
161
+ if getattr(args, "dry_run", False):
162
+ listing = transport.list_files()
163
+ total = sum(f.size for f in listing)
164
+ human(f"Dry run: {len(listing)} files ({total / 1e6:.1f} MB) would be synced.")
165
+ human("Nothing uploaded.")
166
+ if args.json:
167
+ print(json.dumps({"unit_id": device.unit_id, "listed": len(listing), "bytes": total}))
168
+ return OK
169
+ api = Api(args.server, agent=user_agent(_mtp_version(transport)))
170
+ try:
171
+ report = run_sync(
172
+ transport,
173
+ api,
174
+ say=(lambda m: human(f" {m}")) if args.verbose else (lambda _m: None),
175
+ save_dir=getattr(args, "save", None),
176
+ device=device,
177
+ )
178
+ finally:
179
+ api.close()
180
+ if args.json:
181
+ print(json.dumps(report.as_json(), indent=2))
182
+ else:
183
+ print_summary(report, args.server, human)
184
+ return PARTIAL if report.status == "partial" else OK
185
+
186
+
187
+ def print_summary(r: SyncReport, server: str, say: Output) -> None:
188
+ mb = r.uploaded_bytes / 1e6
189
+ say(f"Read {r.read} new or changed files, uploaded {r.uploaded} ({mb:.1f} MB).")
190
+ if r.saved:
191
+ say(f"Saved {r.saved} files to your local copy.")
192
+ if r.changed:
193
+ say(
194
+ f"{len(r.changed)} files changed on the watch during the sync; next sync includes them."
195
+ )
196
+ for e in r.errors[:5]:
197
+ say(f" problem: {e}")
198
+ if r.status == "partial":
199
+ say(f"Some files couldn't be recorded ({len(r.unrecorded)}). Run unstrap again later.")
200
+ say(f"Done in {r.seconds:.1f} s. You can unplug your watch.")
201
+ say("")
202
+ if r.store_status == "claimed":
203
+ if r.owner_hint:
204
+ say(f"This watch is linked to {r.owner_hint}. If that isn't you, add it to your own")
205
+ say(f"account before syncing again: {server}/watches/add?unit={r.unit_id}")
206
+ else:
207
+ days = r.retention_days or 30
208
+ say(f"Not linked to an account yet (kept for {days} days). To add it: sign in at")
209
+ say(f'{server}, choose "Add a watch", then sync again within 10 minutes.')
210
+
211
+
212
+ def info(transport: Transport, args: argparse.Namespace, out: Output) -> int:
213
+ device = transport.wait_for_device(WAIT_FOR_DEVICE_SECONDS)
214
+ data = {
215
+ "product": device.product,
216
+ "unit_id": device.unit_id,
217
+ "usb_serial": device.usb_serial,
218
+ "firmware": device.firmware,
219
+ }
220
+ if args.json:
221
+ print(json.dumps(data, indent=2))
222
+ else:
223
+ out(f"{device.product or 'Garmin watch'}")
224
+ out(f" Unit ID: {device.unit_id} (enter this on the website)")
225
+ out(f" USB serial: {device.usb_serial}")
226
+ out(f" Firmware: {device.firmware or 'unknown'}")
227
+ return OK
228
+
229
+
230
+ def doctor(args: argparse.Namespace, out: Output) -> int:
231
+ """Check every link in the chain and say exactly what to fix (plan §5.2)."""
232
+ ok = True
233
+
234
+ def check(passed: bool, label: str, fix: str = "") -> None:
235
+ nonlocal ok
236
+ ok &= passed
237
+ out(f"[{'ok' if passed else 'FAIL'}] {label}")
238
+ if not passed and fix:
239
+ out(f" {fix}")
240
+
241
+ system = f"{platform.system()} {platform.machine()}"
242
+ out(f"Unstrap {__version__} on {system}, Python {platform.python_version()}")
243
+ if args.source is None:
244
+ binary = find_binary()
245
+ check(binary is not None, f"mtp-rs program: {binary}", MISSING_BINARY)
246
+ if binary is not None:
247
+ t = MtpRsTransport(binary, device=args.device)
248
+ version = t.version()
249
+ check(
250
+ version == SUPPORTED_MTP_RS,
251
+ f"mtp-rs version {version}",
252
+ f"Expected {SUPPORTED_MTP_RS}. Reinstall: uvx unstrap@latest",
253
+ )
254
+ try:
255
+ d = t.wait_for_device(timeout=5)
256
+ check(True, f"watch: {d.product} (unit ID {d.unit_id})")
257
+ check(len(t.list_files()) > 0, "watch files readable", ADVICE["stuck"])
258
+ except TransportError as e:
259
+ check(False, "watch", ADVICE.get(e.code, str(e)))
260
+ if platform.system() == "Linux":
261
+ out(f" On Linux, see {LINUX_HELP} (udev rule, gvfs).")
262
+ api = Api(args.server)
263
+ try:
264
+ check(api.healthz(), f"server {args.server}", "Check your internet connection.")
265
+ finally:
266
+ api.close()
267
+ return OK if ok else OTHER
268
+
269
+
270
+ if __name__ == "__main__":
271
+ sys.exit(main())
unstrap/policy.py ADDED
@@ -0,0 +1,76 @@
1
+ """Folder policy: which files are synced. Mirrors server/src/lib/policy.ts (a test checks it).
2
+
3
+ The server sends its policy in every `POST /syncs` response; this copy is used before that
4
+ (listing) and as defence in depth: the client never reads anything outside it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FolderPolicy:
15
+ folder: str # e.g. "GARMIN/Activity"
16
+ immutable: bool
17
+
18
+
19
+ FOLDERS: tuple[FolderPolicy, ...] = (
20
+ FolderPolicy("GARMIN/Activity", immutable=True),
21
+ FolderPolicy("GARMIN/SUMMARY", immutable=True),
22
+ FolderPolicy("GARMIN/Monitor", immutable=False),
23
+ FolderPolicy("GARMIN/Sleep", immutable=False),
24
+ FolderPolicy("GARMIN/HRVStatus", immutable=False),
25
+ FolderPolicy("GARMIN/Metrics", immutable=False),
26
+ FolderPolicy("GARMIN/Debug/MetricsBackup", immutable=False),
27
+ FolderPolicy("GARMIN/Records", immutable=False),
28
+ FolderPolicy("GARMIN/Totals", immutable=False),
29
+ FolderPolicy("GARMIN/Goals", immutable=False),
30
+ FolderPolicy("GARMIN/Settings", immutable=False),
31
+ FolderPolicy("GARMIN/Sports", immutable=False),
32
+ FolderPolicy("GARMIN/Schedule", immutable=False),
33
+ FolderPolicy("GARMIN/Location", immutable=False),
34
+ FolderPolicy("GARMIN/Workouts", immutable=False),
35
+ FolderPolicy("GARMIN/Courses", immutable=False),
36
+ )
37
+ _EXTRA_DIRS = {
38
+ "GARMIN/Workouts/Guided": "GARMIN/Workouts",
39
+ "GARMIN/Workouts/Schedule": "GARMIN/Workouts",
40
+ }
41
+ _BY_FOLDER = {p.folder: p for p in FOLDERS}
42
+
43
+ # Never list or read these: credentials, firmware, maps, crash risk. Reading
44
+ # GARMIN/Debug/err_log.txt locked up a real fenix 6 until it was physically replugged.
45
+ NEVER: tuple[str, ...] = (
46
+ "GARMIN/Debug",
47
+ "GARMIN/WIFI",
48
+ "GARMIN/Apps",
49
+ "GARMIN/Text",
50
+ "GARMIN/RemoteSW",
51
+ "GARMIN/http",
52
+ "GARMIN/EVNTLOGS",
53
+ "GARMIN/PointCache",
54
+ "GARMIN/TempFIT",
55
+ )
56
+
57
+ _FILENAME = re.compile(r"^(?!\.)[\w.\s()&,!'\[\]+#@-]{1,128}$", re.UNICODE)
58
+
59
+
60
+ def policy_for(path: str) -> FolderPolicy | None:
61
+ """The policy for a watch path like "GARMIN/Activity/x.fit", or None if it isn't synced."""
62
+ directory, _, name = path.rpartition("/")
63
+ if not directory or not name.lower().endswith(".fit") or not _FILENAME.match(name):
64
+ return None
65
+ if "\x00" in name or "/" in name or "\\" in name:
66
+ return None
67
+ return _BY_FOLDER.get(_EXTRA_DIRS.get(directory, directory))
68
+
69
+
70
+ def is_synced(path: str) -> bool:
71
+ return policy_for(path) is not None
72
+
73
+
74
+ def is_immutable(path: str) -> bool:
75
+ p = policy_for(path)
76
+ return p is not None and p.immutable
unstrap/py.typed ADDED
File without changes
unstrap/sync.py ADDED
@@ -0,0 +1,267 @@
1
+ """One sync, end to end (plan §4.1): list → start → prove → [read → check → upload]* → finish.
2
+
3
+ Pure orchestration: the transport (watch) and API (server) are injected, so this is tested
4
+ without hardware or network.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import hmac
12
+ import shutil
13
+ import tempfile
14
+ import time
15
+ from collections.abc import Callable, Iterator, Sequence
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from dataclasses import asdict, dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from unstrap.api import Api, HashedFile
22
+ from unstrap.transport import DeviceInfo, RemoteFile, Transport, TransportError
23
+
24
+ WAIT_FOR_DEVICE_SECONDS = 30.0
25
+ BATCH_FILES = 10
26
+ BATCH_BYTES = 5 * 1024 * 1024
27
+ UPLOAD_CONCURRENCY = 6
28
+
29
+ Say = Callable[[str], None]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _Session:
34
+ """One server-side sync: the API plus this sync's id and capability token."""
35
+
36
+ api: Api
37
+ sync_id: int
38
+ token: str
39
+
40
+
41
+ @dataclass
42
+ class SyncReport:
43
+ """Everything a person (or `--json`) needs to know about one sync."""
44
+
45
+ unit_id: str
46
+ usb_serial: str
47
+ product: str | None
48
+ listed: int = 0
49
+ read: int = 0
50
+ uploaded: int = 0
51
+ uploaded_bytes: int = 0
52
+ store: str | None = None # "new" | "existing"
53
+ status: str | None = None # "ok" | "partial"
54
+ store_status: str | None = None # "claimed" | "unclaimed"
55
+ owner_hint: str | None = None
56
+ retention_days: int | None = None
57
+ unrecorded: list[str] = field(default_factory=list)
58
+ errors: list[str] = field(default_factory=list)
59
+ # Mutable files the watch rewrote between listing and reading; the next sync gets them.
60
+ changed: list[str] = field(default_factory=list)
61
+ saved: int = 0
62
+ seconds: float = 0.0
63
+
64
+ def as_json(self) -> dict[str, Any]:
65
+ return asdict(self)
66
+
67
+
68
+ def sha256_file(path: Path) -> str:
69
+ h = hashlib.sha256()
70
+ with path.open("rb") as f:
71
+ for chunk in iter(lambda: f.read(1 << 20), b""):
72
+ h.update(chunk)
73
+ return h.hexdigest()
74
+
75
+
76
+ def proof_mac(nonce: bytes, data: bytes) -> str:
77
+ return hmac.new(nonce, data, hashlib.sha256).hexdigest()
78
+
79
+
80
+ def b64url_decode(s: str) -> bytes:
81
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
82
+
83
+
84
+ def batches(
85
+ paths: Sequence[str], sizes: dict[str, int], max_files: int, max_bytes: int
86
+ ) -> Iterator[list[str]]:
87
+ """Group paths so each batch stays under both limits (a single big file is its own batch)."""
88
+ batch: list[str] = []
89
+ total = 0
90
+ for p in paths:
91
+ size = sizes.get(p, 0)
92
+ if batch and (len(batch) >= max_files or total + size > max_bytes):
93
+ yield batch
94
+ batch, total = [], 0
95
+ batch.append(p)
96
+ total += size
97
+ if batch:
98
+ yield batch
99
+
100
+
101
+ def run_sync(
102
+ transport: Transport,
103
+ api: Api,
104
+ *,
105
+ say: Say = lambda _m: None,
106
+ save_dir: Path | None = None,
107
+ device: DeviceInfo | None = None,
108
+ batch_files: int = BATCH_FILES,
109
+ batch_bytes: int = BATCH_BYTES,
110
+ clock: Callable[[], float] = time.monotonic,
111
+ ) -> SyncReport:
112
+ started = clock()
113
+ info = device or transport.wait_for_device(WAIT_FOR_DEVICE_SECONDS)
114
+ report = SyncReport(unit_id=info.unit_id, usb_serial=info.usb_serial, product=info.product)
115
+ listing: list[RemoteFile] = transport.list_files()
116
+ report.listed = len(listing)
117
+ sizes = {f.path: f.size for f in listing}
118
+ say(f"Listing {len(listing)} files")
119
+
120
+ start = api.start(info, listing)
121
+ sync_id, token = int(start["sync_id"]), str(start["token"])
122
+ challenge = start["challenge"]
123
+ hashed: list[HashedFile] = []
124
+
125
+ try:
126
+ with tempfile.TemporaryDirectory(prefix="unstrap-") as tmp_name:
127
+ tmp = Path(tmp_name)
128
+ session = _Session(api, sync_id, token)
129
+ proved = _prove(transport, session, challenge, sizes, tmp, say)
130
+ report.store = proved["store"]
131
+ server_wants = {p for p in proved["fetch"] if p in sizes}
132
+ fetch = sorted(server_wants)
133
+ if save_dir is not None:
134
+ fetch += _missing_from_mirror(listing, save_dir, server_wants)
135
+ transport.plan(fetch)
136
+ say(f"Reading {len(fetch)} new or changed files")
137
+ for i, batch in enumerate(batches(fetch, sizes, batch_files, batch_bytes)):
138
+ hashed += _upload_batch(
139
+ transport, session, batch, tmp / f"b{i}", server_wants, sizes, save_dir, report
140
+ )
141
+ say(f"Read {report.read} of {len(fetch)}")
142
+ finally:
143
+ transport.close()
144
+
145
+ stats: dict[str, int | str | bool] = {
146
+ "listed": report.listed,
147
+ "read": report.read,
148
+ "uploaded": report.uploaded,
149
+ }
150
+ done = api.finish(sync_id, token, hashed, stats, report.errors)
151
+ report.status = done.get("status")
152
+ report.store_status = done.get("store_status")
153
+ report.owner_hint = done.get("owner_hint")
154
+ report.retention_days = done.get("retention_days")
155
+ report.unrecorded = list(done.get("unrecorded", []))
156
+ report.seconds = round(clock() - started, 1)
157
+ return report
158
+
159
+
160
+ def _prove(
161
+ transport: Transport,
162
+ session: _Session,
163
+ challenge: dict[str, Any],
164
+ sizes: dict[str, int],
165
+ tmp: Path,
166
+ say: Say,
167
+ ) -> dict[str, Any]:
168
+ """Answer the possession challenge with HMAC(nonce, file bytes) (plan §3.3)."""
169
+ nonce = b64url_decode(challenge["nonce"])
170
+ challenged = [p for p in challenge["paths"] if p in sizes]
171
+ proofs = []
172
+ if challenged:
173
+ say("Verifying this watch")
174
+ # A challenged file may have vanished (e.g. rotated away): just leave it out. Wrong or
175
+ # missing answers never count against us; any one correct answer proves the watch.
176
+ got = _fetch_each(transport, challenged, tmp / "proof", report=None)
177
+ proofs = [{"path": p, "mac": proof_mac(nonce, f.read_bytes())} for p, f in got.items()]
178
+ return session.api.prove(session.sync_id, session.token, challenge["nonce"], proofs)
179
+
180
+
181
+ def _upload_batch(
182
+ transport: Transport,
183
+ session: _Session,
184
+ batch: list[str],
185
+ batch_dir: Path,
186
+ server_wants: set[str],
187
+ sizes: dict[str, int],
188
+ save_dir: Path | None,
189
+ report: SyncReport,
190
+ ) -> list[HashedFile]:
191
+ """Read one batch from the watch, then check and upload what the server lacks."""
192
+ try:
193
+ got = transport.fetch(batch, batch_dir)
194
+ except TransportError as e:
195
+ if e.code != "not_found":
196
+ raise # watch gone or stuck: stop; the next sync resumes (plan §4.5)
197
+ got = _fetch_each(transport, batch, batch_dir, report)
198
+ files = [HashedFile(p, local.stat().st_size, sha256_file(local)) for p, local in got.items()]
199
+ report.read += len(files)
200
+ if save_dir is not None:
201
+ report.saved += _save(got, save_dir)
202
+ # A mutable file the watch rewrote since listing no longer matches the listed size; the
203
+ # server would reject it, and the next sync lists it afresh. Not an error (review M3).
204
+ for f in files:
205
+ if f.path in server_wants and f.size != sizes.get(f.path):
206
+ report.changed.append(f.path)
207
+ to_server = [f for f in files if f.path in server_wants and f.size == sizes.get(f.path)]
208
+ if to_server:
209
+ missing = set(session.api.check(session.sync_id, session.token, to_server))
210
+ uploads: dict[str, HashedFile] = {}
211
+ for f in to_server:
212
+ if f.sha256 in missing:
213
+ uploads.setdefault(f.sha256, f) # same content at two paths: upload once
214
+ # A few uploads in parallel: each is a server round trip of ~0.5 s, so sequential uploads
215
+ # would dominate a first sync. The server makes quota and bookkeeping atomic (review M2).
216
+ with ThreadPoolExecutor(max_workers=UPLOAD_CONCURRENCY) as pool:
217
+ stored = list(
218
+ pool.map(
219
+ lambda f: session.api.put_blob(
220
+ session.sync_id, session.token, f, got[f.path].read_bytes()
221
+ ),
222
+ uploads.values(),
223
+ )
224
+ )
225
+ # Count what the server actually stored (a concurrent duplicate answers stored=false).
226
+ for f, ok in zip(uploads.values(), stored, strict=True):
227
+ if ok:
228
+ report.uploaded += 1
229
+ report.uploaded_bytes += f.size
230
+ shutil.rmtree(batch_dir, ignore_errors=True)
231
+ return to_server
232
+
233
+
234
+ def _fetch_each(
235
+ transport: Transport, batch: list[str], dest: Path, report: SyncReport | None
236
+ ) -> dict[str, Path]:
237
+ """Read files one by one, skipping any that vanished (e.g. Monitor rotation)."""
238
+ got: dict[str, Path] = {}
239
+ for p in batch:
240
+ try:
241
+ got.update(transport.fetch([p], dest))
242
+ except TransportError as e:
243
+ if e.code != "not_found":
244
+ raise
245
+ if report is not None:
246
+ report.errors.append(f"{p}: {e}")
247
+ return got
248
+
249
+
250
+ def _missing_from_mirror(
251
+ listing: Sequence[RemoteFile], save_dir: Path, already: set[str]
252
+ ) -> list[str]:
253
+ """--save keeps a complete local mirror, including files the server already has."""
254
+ out = []
255
+ for f in listing:
256
+ local = save_dir / f.path
257
+ if f.path not in already and (not local.is_file() or local.stat().st_size != f.size):
258
+ out.append(f.path)
259
+ return out
260
+
261
+
262
+ def _save(got: dict[str, Path], save_dir: Path) -> int:
263
+ for path, local in got.items():
264
+ target = save_dir / path
265
+ target.parent.mkdir(parents=True, exist_ok=True)
266
+ shutil.copyfile(local, target)
267
+ return len(got)
@@ -0,0 +1,3 @@
1
+ from unstrap.transport.base import DeviceInfo, RemoteFile, Transport, TransportError
2
+
3
+ __all__ = ["DeviceInfo", "RemoteFile", "Transport", "TransportError"]
@@ -0,0 +1,71 @@
1
+ """Read-only access to a watch. There are deliberately no write or delete methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal, Protocol
10
+
11
+ ErrorCode = Literal["no_device", "stuck", "not_found", "permission", "busy", "bad_source", "other"]
12
+
13
+
14
+ class TransportError(Exception):
15
+ """A watch access problem with a code the CLI maps to advice and an exit code."""
16
+
17
+ def __init__(self, code: ErrorCode, message: str) -> None:
18
+ super().__init__(message)
19
+ self.code: ErrorCode = code
20
+
21
+
22
+ _USB_SERIAL = re.compile(r"^[0-9a-f]{12}$")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DeviceInfo:
27
+ usb_serial: str # 12 lowercase hex digits; low 32 bits = unit ID
28
+ product: str | None = None
29
+ firmware: str | None = None
30
+
31
+ def __post_init__(self) -> None:
32
+ if not _USB_SERIAL.match(self.usb_serial):
33
+ raise TransportError(
34
+ "other", f"{self.usb_serial!r} is not a Garmin USB serial (12 hex digits)"
35
+ )
36
+
37
+ @property
38
+ def unit_id(self) -> str:
39
+ return str(int(self.usb_serial[-8:], 16))
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RemoteFile:
44
+ path: str # "GARMIN/Activity/2026-10-18-12-33-45.fit"
45
+ size: int
46
+
47
+
48
+ class Transport(Protocol):
49
+ def wait_for_device(self, timeout: float) -> DeviceInfo:
50
+ """Block until a watch is available (or raise TransportError("no_device"))."""
51
+ ...
52
+
53
+ def list_files(self) -> list[RemoteFile]:
54
+ """Every file under GARMIN/ that the folder policy syncs (never the NEVER folders)."""
55
+ ...
56
+
57
+ def fetch(self, paths: Sequence[str], dest: Path) -> dict[str, Path]:
58
+ """Make `paths` readable locally (path → local file). Raises for policy violations.
59
+
60
+ Returned files may differ in size from the listing if the watch rewrote them meanwhile;
61
+ the caller decides what to do with that.
62
+ """
63
+ ...
64
+
65
+ def plan(self, paths: Sequence[str]) -> None:
66
+ """Everything this sync will fetch, so the transport can choose how to read it."""
67
+ ...
68
+
69
+ def close(self) -> None:
70
+ """Release temporary resources."""
71
+ ...
@@ -0,0 +1,74 @@
1
+ """A directory tree as a "watch": tests, development, captured snapshots, mass-storage watches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from unstrap.policy import is_synced
10
+ from unstrap.transport.base import DeviceInfo, RemoteFile, TransportError
11
+
12
+
13
+ class DirectoryTransport:
14
+ """Reads a folder that contains `GARMIN/` exactly like the watch exposes it."""
15
+
16
+ def __init__(self, root: Path, usb_serial: str | None = None) -> None:
17
+ # Accept the GARMIN folder itself as well as the folder that contains it.
18
+ self.root = root.parent if root.name == "GARMIN" and root.is_dir() else root
19
+ self._usb_serial = usb_serial
20
+
21
+ def wait_for_device(self, timeout: float) -> DeviceInfo:
22
+ garmin = self.root / "GARMIN"
23
+ if not garmin.is_dir():
24
+ # Not "no watch found": --source means "read this folder instead of the USB watch",
25
+ # and a wrong folder must not look like a missing watch.
26
+ where = "doesn't exist" if not self.root.exists() else "has no GARMIN folder in it"
27
+ raise TransportError(
28
+ "bad_source",
29
+ f"--source {self.root} {where}. --source reads a copy of the watch's files on "
30
+ "this computer; leave it out to sync the watch on USB.",
31
+ )
32
+ xml_path = garmin / "GarminDevice.xml"
33
+ xml = xml_path.read_text(errors="replace") if xml_path.is_file() else ""
34
+ serial = self._usb_serial
35
+ if serial is None:
36
+ m = re.search(r"<Id>(\d{1,10})</Id>", xml)
37
+ if not m:
38
+ raise TransportError(
39
+ "not_found", "No unit ID: pass a USB serial or add GarminDevice.xml"
40
+ )
41
+ serial = f"{int(m.group(1)):012x}"
42
+ product = re.search(r"<Description>([^<]{1,100})</Description>", xml)
43
+ firmware = re.search(r"<SoftwareVersion>([^<]{1,32})</SoftwareVersion>", xml)
44
+ return DeviceInfo(
45
+ usb_serial=serial.lower(),
46
+ product=f"Garmin {product.group(1)}" if product else None,
47
+ firmware=firmware.group(1) if firmware else None,
48
+ )
49
+
50
+ def list_files(self) -> list[RemoteFile]:
51
+ garmin = self.root / "GARMIN"
52
+ files = []
53
+ for p in sorted(garmin.rglob("*")):
54
+ rel = p.relative_to(self.root).as_posix()
55
+ if p.is_file() and is_synced(rel):
56
+ files.append(RemoteFile(rel, p.stat().st_size))
57
+ return files
58
+
59
+ def fetch(self, paths: Sequence[str], dest: Path) -> dict[str, Path]:
60
+ out = {}
61
+ for path in paths:
62
+ if not is_synced(path):
63
+ raise TransportError("other", f"refusing to read {path}: outside the folder policy")
64
+ local = self.root / path
65
+ if not local.is_file():
66
+ raise TransportError("not_found", f"{path} disappeared from the watch")
67
+ out[path] = local # already local; no copy needed
68
+ return out
69
+
70
+ def plan(self, paths: Sequence[str]) -> None:
71
+ pass
72
+
73
+ def close(self) -> None:
74
+ pass
@@ -0,0 +1,327 @@
1
+ """Watch access through the `mtp-rs` CLI (plan §5.3). Read-only: only devices/info/ls/get are run.
2
+
3
+ Behaviour verified against mtp-rs 0.9.1 and a real fenix 6 (fixtures/capture-2026-10-19):
4
+ JSON on stdout, `error: <message>` on stderr, exit codes 0 ok, 1 other (e.g. local path
5
+ exists), 2 no device, 5 remote path not found, 6 open timed out (watch busy or stuck).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+ from collections import defaultdict
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from unstrap.policy import NEVER, is_synced
23
+ from unstrap.transport.base import DeviceInfo, ErrorCode, RemoteFile, TransportError
24
+
25
+ GARMIN_VENDOR_ID = 0x091E
26
+ SUPPORTED_MTP_RS = "0.9.1"
27
+ COMMAND_TIMEOUT_SECONDS = 300
28
+
29
+ # Cost model for choosing per-file vs whole-folder downloads (measured, plan §4.2).
30
+ SESSION_SECONDS = 0.3
31
+ PER_OBJECT_LISTING_SECONDS = 0.005
32
+ BYTES_PER_SECOND = 2.5e6
33
+
34
+ Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
35
+
36
+
37
+ # Platform wheels of unstrap bundle mtp-rs here (mtp/build_wheels.py).
38
+ VENDORED = Path(__file__).resolve().parents[1] / "_vendor" / "mtp-rs"
39
+
40
+ MISSING_BINARY = (
41
+ f"No mtp-rs program for this computer. Install mtp-rs {SUPPORTED_MTP_RS} "
42
+ "(https://github.com/vdavid/mtp-rs) and put it on your PATH, or set UNSTRAP_MTP_RS."
43
+ )
44
+
45
+
46
+ def find_binary() -> Path | None:
47
+ """UNSTRAP_MTP_RS, then the copy bundled in the wheel, then mtp-rs on PATH."""
48
+ override = os.environ.get("UNSTRAP_MTP_RS")
49
+ if override:
50
+ return Path(override) if Path(override).is_file() else None
51
+ if VENDORED.is_file():
52
+ return VENDORED
53
+ found = shutil.which("mtp-rs")
54
+ return Path(found) if found else None
55
+
56
+
57
+ @dataclass
58
+ class Listing:
59
+ files: list[RemoteFile]
60
+ objects_per_folder: dict[str, int]
61
+ bytes_per_folder: dict[str, int] # including subfolders
62
+ clean_folders: set[str] # every file below is synced by the policy
63
+
64
+
65
+ class MtpRsTransport:
66
+ def __init__(
67
+ self,
68
+ binary: Path,
69
+ *,
70
+ device: str | None = None,
71
+ runner: Runner | None = None,
72
+ sleep: Callable[[float], None] = time.sleep,
73
+ stuck_retries: int = 2,
74
+ ) -> None:
75
+ self.binary = binary
76
+ self.device = device
77
+ self._run_override = runner
78
+ self._sleep = sleep
79
+ self._stuck_retries = stuck_retries
80
+ self._listing: Listing | None = None
81
+ self._planned: dict[str, int] = {}
82
+ self._cache_root: Path | None = None
83
+ self._folder_cache: dict[str, Path] = {}
84
+
85
+ # -- process plumbing -------------------------------------------------------------------
86
+
87
+ def _run(self, args: list[str]) -> subprocess.CompletedProcess[str]:
88
+ cmd = [str(self.binary), *args]
89
+ try:
90
+ if self._run_override is not None:
91
+ return self._run_override(cmd)
92
+ return subprocess.run(
93
+ cmd, capture_output=True, text=True, timeout=COMMAND_TIMEOUT_SECONDS, check=False
94
+ )
95
+ except subprocess.TimeoutExpired:
96
+ # A hung watch: treat like the watch's own "open timed out" (advice: replug).
97
+ return subprocess.CompletedProcess(cmd, 6, "", "error: mtp-rs did not respond\n")
98
+ except OSError as e:
99
+ raise TransportError("other", f"Can't run mtp-rs at {self.binary}: {e}") from e
100
+
101
+ def _device_args(self) -> list[str]:
102
+ return ["--device", self.device] if self.device else []
103
+
104
+ def _call(self, *args: str, retry_stuck: bool = True) -> subprocess.CompletedProcess[str]:
105
+ """Run mtp-rs; map failures to TransportError. Retries 'open timed out' a few times."""
106
+ attempts = self._stuck_retries + 1 if retry_stuck else 1
107
+ for attempt in range(attempts):
108
+ res = self._run([*self._device_args(), *args])
109
+ if res.returncode == 0:
110
+ return res
111
+ code = _error_code(res)
112
+ if code == "stuck" and attempt + 1 < attempts:
113
+ self._sleep(2.0)
114
+ continue
115
+ raise TransportError(code, _message(res))
116
+ raise AssertionError("unreachable")
117
+
118
+ # -- Transport -----------------------------------------------------------------------------
119
+
120
+ def version(self) -> str:
121
+ res = self._run(["--version"])
122
+ return res.stdout.strip().removeprefix("mtp-rs ").strip()
123
+
124
+ def wait_for_device(self, timeout: float) -> DeviceInfo:
125
+ deadline = time.monotonic() + timeout
126
+ last: TransportError | None = None
127
+ while True:
128
+ try:
129
+ return self._try_open()
130
+ except TransportError as e:
131
+ if e.code not in ("no_device", "stuck"):
132
+ raise
133
+ last = e
134
+ if time.monotonic() >= deadline:
135
+ raise last or TransportError("no_device", "No Garmin watch found")
136
+ self._sleep(1.0)
137
+
138
+ def _try_open(self) -> DeviceInfo:
139
+ res = self._run(["--json", "devices"])
140
+ if res.returncode != 0:
141
+ raise TransportError(_error_code(res), _message(res))
142
+ devices = [
143
+ d
144
+ for d in json.loads(res.stdout or "[]")
145
+ if d.get("vendor_id") == GARMIN_VENDOR_ID and _real_serial(d.get("serial_number"))
146
+ ]
147
+ if not devices:
148
+ raise TransportError("no_device", "No Garmin watch found")
149
+ if self.device is None:
150
+ if len(devices) > 1:
151
+ serials = ", ".join(d["serial_number"] for d in devices)
152
+ raise TransportError(
153
+ "other", f"More than one Garmin connected ({serials}); use --device"
154
+ )
155
+ self.device = str(devices[0]["serial_number"])
156
+ info = json.loads(self._call("--json", "info", retry_stuck=False).stdout)
157
+ model = info.get("model")
158
+ maker = info.get("manufacturer") or "Garmin"
159
+ return DeviceInfo(
160
+ usb_serial=str(info.get("serial_number") or self.device).lower(),
161
+ product=f"{maker} {model}" if model else None,
162
+ firmware=info.get("device_version"),
163
+ )
164
+
165
+ def list_files(self) -> list[RemoteFile]:
166
+ res = self._call("--json", "ls", "--recursive", "/GARMIN")
167
+ self._listing = parse_recursive_listing(json.loads(res.stdout))
168
+ return self._listing.files
169
+
170
+ def plan(self, paths: Sequence[str]) -> None:
171
+ """Remember how many files per folder this sync needs, for the cost model (review M3)."""
172
+ counts: dict[str, int] = defaultdict(int)
173
+ for p in paths:
174
+ counts[p.rpartition("/")[0]] += 1
175
+ self._planned = dict(counts)
176
+
177
+ def close(self) -> None:
178
+ if self._cache_root is not None:
179
+ shutil.rmtree(self._cache_root, ignore_errors=True)
180
+ self._cache_root = None
181
+ self._folder_cache.clear()
182
+
183
+ def fetch(self, paths: Sequence[str], dest: Path) -> dict[str, Path]:
184
+ dest.mkdir(parents=True, exist_ok=True)
185
+ for p in paths:
186
+ if not is_synced(p):
187
+ raise TransportError("other", f"refusing to read {p}: outside the folder policy")
188
+ listing = self._listing or parse_recursive_listing(
189
+ json.loads(self._call("--json", "ls", "--recursive", "/GARMIN").stdout)
190
+ )
191
+ by_folder: dict[str, list[str]] = defaultdict(list)
192
+ for p in paths:
193
+ by_folder[p.rpartition("/")[0]].append(p)
194
+ out: dict[str, Path] = {}
195
+ for folder, wanted in by_folder.items():
196
+ planned = max(self._planned.get(folder, 0), len(wanted))
197
+ whole = folder in self._folder_cache or (
198
+ folder in listing.clean_folders
199
+ and _folder_is_safe(folder)
200
+ and choose_whole_folder(
201
+ planned,
202
+ listing.objects_per_folder.get(folder, planned),
203
+ listing.bytes_per_folder.get(folder, 0),
204
+ )
205
+ )
206
+ if whole:
207
+ out.update(self._from_folder(folder, wanted, dest))
208
+ else:
209
+ for p in wanted:
210
+ out[p] = self._get_file(p, dest)
211
+ return out
212
+
213
+ def _get_file(self, path: str, dest: Path) -> Path:
214
+ local = dest / path
215
+ local.parent.mkdir(parents=True, exist_ok=True)
216
+ if local.exists():
217
+ local.unlink()
218
+ self._call("get", f"/{path}", str(local))
219
+ return local
220
+
221
+ def _from_folder(self, folder: str, wanted: list[str], dest: Path) -> dict[str, Path]:
222
+ """Copy wanted files out of a folder downloaded once per sync (cached until close())."""
223
+ cached = self._folder_cache.get(folder)
224
+ if cached is None:
225
+ if self._cache_root is None:
226
+ self._cache_root = Path(tempfile.mkdtemp(prefix="unstrap-folders-"))
227
+ cached = self._cache_root / str(len(self._folder_cache)) # must not exist yet
228
+ self._call("get", f"/{folder}", str(cached))
229
+ self._folder_cache[folder] = cached
230
+ out = {}
231
+ for p in wanted:
232
+ src = cached / p.rpartition("/")[2]
233
+ if not src.is_file():
234
+ raise TransportError("not_found", f"{p} disappeared from the watch")
235
+ final = dest / p
236
+ final.parent.mkdir(parents=True, exist_ok=True)
237
+ shutil.copyfile(src, final)
238
+ out[p] = final
239
+ return out
240
+
241
+
242
+ def choose_whole_folder(wanted: int, objects: int, folder_bytes: int) -> bool:
243
+ """Per-file gets re-list the parent folder each time; a folder get reads everything once."""
244
+ per_file = wanted * (SESSION_SECONDS + PER_OBJECT_LISTING_SECONDS * objects)
245
+ whole = SESSION_SECONDS + folder_bytes / BYTES_PER_SECOND
246
+ return whole < per_file
247
+
248
+
249
+ def _folder_is_safe(folder: str) -> bool:
250
+ """Never bulk-download a folder that contains (or is) a never-read folder."""
251
+ return not any(n == folder or n.startswith(folder + "/") for n in NEVER)
252
+
253
+
254
+ def parse_recursive_listing(doc: dict[str, Any]) -> Listing:
255
+ """Rebuild paths from `ls --recursive --json /GARMIN` (objects carry parent handles only)."""
256
+ objects = doc.get("objects", [])
257
+ by_handle = {o["handle"]: o for o in objects}
258
+ cache: dict[int, str] = {}
259
+
260
+ def path_of(handle: int) -> str:
261
+ if handle in cache:
262
+ return cache[handle]
263
+ o = by_handle[handle]
264
+ parent = o.get("parent")
265
+ prefix = path_of(parent) if parent in by_handle else "GARMIN"
266
+ cache[handle] = f"{prefix}/{o['filename']}"
267
+ return cache[handle]
268
+
269
+ files: list[RemoteFile] = []
270
+ objects_per_folder: dict[str, int] = defaultdict(int)
271
+ bytes_per_folder: dict[str, int] = defaultdict(int)
272
+ folders: set[str] = set()
273
+ dirty: set[str] = set()
274
+ for o in objects:
275
+ p = path_of(o["handle"])
276
+ folder = p.rpartition("/")[0]
277
+ objects_per_folder[folder] += 1
278
+ if o.get("kind") != "file":
279
+ folders.add(p)
280
+ continue
281
+ size = int(o.get("size", 0))
282
+ ancestors = _ancestors(folder)
283
+ for a in ancestors:
284
+ bytes_per_folder[a] += size
285
+ if is_synced(p):
286
+ files.append(RemoteFile(p, size))
287
+ else:
288
+ dirty.update(ancestors) # a folder get here would read a non-policy file
289
+ return Listing(
290
+ sorted(files, key=lambda f: f.path),
291
+ dict(objects_per_folder),
292
+ dict(bytes_per_folder),
293
+ (folders | set(objects_per_folder)) - dirty,
294
+ )
295
+
296
+
297
+ def _ancestors(folder: str) -> list[str]:
298
+ parts = folder.split("/")
299
+ return ["/".join(parts[: i + 1]) for i in range(len(parts))]
300
+
301
+
302
+ def _real_serial(serial: object) -> bool:
303
+ # While booting, watches enumerate as PID 0x0003 with serial "Not Provided".
304
+ return (
305
+ isinstance(serial, str)
306
+ and len(serial) == 12
307
+ and all(c in "0123456789abcdefABCDEF" for c in serial)
308
+ )
309
+
310
+
311
+ def _message(res: subprocess.CompletedProcess[str]) -> str:
312
+ lines = [ln for ln in (res.stderr or "").splitlines() if ln.startswith("error:")]
313
+ return (
314
+ lines[-1].removeprefix("error:").strip()
315
+ if lines
316
+ else f"mtp-rs exited with {res.returncode}"
317
+ )
318
+
319
+
320
+ def _error_code(res: subprocess.CompletedProcess[str]) -> ErrorCode:
321
+ msg = _message(res).lower()
322
+ if "permission denied" in msg or "access denied" in msg:
323
+ return "permission"
324
+ if "busy" in msg or "claimed" in msg:
325
+ return "busy"
326
+ codes: dict[int, ErrorCode] = {2: "no_device", 5: "not_found", 6: "stuck"}
327
+ return codes.get(res.returncode, "other")
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: unstrap
3
+ Version: 0.1.0
4
+ Summary: Your watch data, set free. Copies everything your Garmin watch records, straight off the watch over USB.
5
+ Author: SweatStack
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx<1,>=0.28
14
+ Description-Content-Type: text/markdown
15
+
16
+ # unstrap
17
+
18
+ Your watch data, set free. Plug in your Garmin watch and run:
19
+
20
+ ```sh
21
+ uvx unstrap
22
+ ```
23
+
24
+ See the project README for how it works.
@@ -0,0 +1,15 @@
1
+ unstrap/__init__.py,sha256=PWSFwjqCW7XQ0V57uP5WKS3e225pstV4Kx6khYpEvKA,105
2
+ unstrap/api.py,sha256=tpKfx40FPEZrUjgkC_kB-3DV-0yo6Mf0-NI5M_tC-A0,6445
3
+ unstrap/cli.py,sha256=ZewFc-P2vV16Hh92iEcYiLJzVFpsrNSjezZqo9XIWr4,10423
4
+ unstrap/policy.py,sha256=mr5aTD38cU-SrxaPx-E9D5q4hqbIQHZFe6uZiYVCtww,2604
5
+ unstrap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ unstrap/sync.py,sha256=uFqkTMqOuGBxvNG7jOePfQFPSG8e5m0hesi9IzLc3gw,9548
7
+ unstrap/transport/__init__.py,sha256=ZiTtFlJWdllTWdKiS3Qb6ukhcoHKAjjX5uYU7CWI4vs,156
8
+ unstrap/transport/base.py,sha256=IA6O7bhdIHEnocZ4Wx4GiuhjQ0p2210xrxXuqN3efpg,2206
9
+ unstrap/transport/directory.py,sha256=FOY9ISX7w9v3jXfKTH-ZwX6Obc389L8SJygRBrULPO0,3124
10
+ unstrap/transport/mtp_rs.py,sha256=RH53THZYvxJ61qtWWCbtx0iWeARYoqm01uONrcMTgCQ,12643
11
+ unstrap-0.1.0.dist-info/METADATA,sha256=XiB0xBeRNK2uu_d88UHN9yMrqS9FVw9YdR937gWr6SI,631
12
+ unstrap-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ unstrap-0.1.0.dist-info/entry_points.txt,sha256=-_hhzqSUxMGFX0zPPns7Wz4yAGCfgLGWYHbcyiQLwzA,45
14
+ unstrap-0.1.0.dist-info/licenses/LICENSE,sha256=VdfjL69g2_81MnwprFdz5EvGPfSWNweyCg1WsY5_jCY,1067
15
+ unstrap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ unstrap = unstrap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SweatStack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.