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 +3 -0
- unstrap/api.py +186 -0
- unstrap/cli.py +271 -0
- unstrap/policy.py +76 -0
- unstrap/py.typed +0 -0
- unstrap/sync.py +267 -0
- unstrap/transport/__init__.py +3 -0
- unstrap/transport/base.py +71 -0
- unstrap/transport/directory.py +74 -0
- unstrap/transport/mtp_rs.py +327 -0
- unstrap-0.1.0.dist-info/METADATA +24 -0
- unstrap-0.1.0.dist-info/RECORD +15 -0
- unstrap-0.1.0.dist-info/WHEEL +4 -0
- unstrap-0.1.0.dist-info/entry_points.txt +2 -0
- unstrap-0.1.0.dist-info/licenses/LICENSE +21 -0
unstrap/__init__.py
ADDED
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,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,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.
|