intervalssync 0.9.3__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.
Files changed (39) hide show
  1. intervalssync/__init__.py +3 -0
  2. intervalssync/_dropbox_app_key.py +7 -0
  3. intervalssync/_version.py +1 -0
  4. intervalssync/bryton/__init__.py +1 -0
  5. intervalssync/bryton/api.py +49 -0
  6. intervalssync/bryton/core.py +249 -0
  7. intervalssync/bryton/ddp.py +231 -0
  8. intervalssync/bryton/exceptions.py +13 -0
  9. intervalssync/bryton/fit_encode.py +363 -0
  10. intervalssync/bryton/workout.py +342 -0
  11. intervalssync/cli/__init__.py +5 -0
  12. intervalssync/cli/config.py +53 -0
  13. intervalssync/cli/env.py +189 -0
  14. intervalssync/cli/main.py +637 -0
  15. intervalssync/dropbox_client.py +143 -0
  16. intervalssync/gui/__init__.py +1 -0
  17. intervalssync/gui/app.py +352 -0
  18. intervalssync/gui/config.py +102 -0
  19. intervalssync/gui/profile_sync_ui.py +225 -0
  20. intervalssync/gui/secrets.py +135 -0
  21. intervalssync/gui/settings_view.py +716 -0
  22. intervalssync/gui/support_gamification.py +658 -0
  23. intervalssync/gui/sync_view.py +486 -0
  24. intervalssync/gui/system.py +21 -0
  25. intervalssync/gui/theme.py +216 -0
  26. intervalssync/gui/update_check.py +55 -0
  27. intervalssync/igpsport/__init__.py +1 -0
  28. intervalssync/igpsport/core.py +520 -0
  29. intervalssync/igpsport/interval_info.py +269 -0
  30. intervalssync/igpsport/profile_sync.py +391 -0
  31. intervalssync/igpsport/region.py +80 -0
  32. intervalssync/igpsport/workout.py +503 -0
  33. intervalssync/igpsport/zone_map.py +143 -0
  34. intervalssync/intervals_icu.py +201 -0
  35. intervalssync-0.9.3.dist-info/METADATA +179 -0
  36. intervalssync-0.9.3.dist-info/RECORD +39 -0
  37. intervalssync-0.9.3.dist-info/WHEEL +4 -0
  38. intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
  39. intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """intervalssync — sync cycling activities to intervals.icu."""
2
+
3
+ from ._version import __version__
@@ -0,0 +1,7 @@
1
+ """Build-stamped Dropbox app key.
2
+
3
+ Release builds may overwrite this file from IGPSYNC_DROPBOX_APP_KEY. Local
4
+ development can also use the IGPSYNC_DROPBOX_APP_KEY environment variable.
5
+ """
6
+
7
+ DROPBOX_APP_KEY = ""
@@ -0,0 +1 @@
1
+ __version__ = "0.9.3"
@@ -0,0 +1 @@
1
+ """Bryton Active package."""
@@ -0,0 +1,49 @@
1
+ """REST FIT download for Bryton Active (Android app API)."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import requests
7
+
8
+ from .ddp import DEFAULT_HOST, BrytonSession
9
+
10
+ DEFAULT_API_KEY = "UHIJdntFZFkZaVJsInRHfRcYES08Fwp0Bwkf"
11
+
12
+
13
+ def download_fit_bytes(
14
+ activity_id: str,
15
+ session: BrytonSession,
16
+ *,
17
+ api_key: str = DEFAULT_API_KEY,
18
+ ) -> bytes:
19
+ """Download ride FIT bytes for activity_id."""
20
+ url = f"https://{session.host}/api/activity"
21
+ headers = {
22
+ "X-User-Id": session.user_id,
23
+ "X-Auth-Token": session.auth_token,
24
+ "x-api-key": api_key,
25
+ "User-Agent": "okhttp/4.12.0",
26
+ "Accept-Encoding": "gzip",
27
+ }
28
+ resp = requests.get(url, params={"id": activity_id}, headers=headers, timeout=120)
29
+ resp.raise_for_status()
30
+ data = resp.content
31
+ if len(data) < 14 or b".FIT" not in data[8:14]:
32
+ raise ValueError(
33
+ f"Response does not look like FIT ({len(data)} bytes, head={data[:16]!r})"
34
+ )
35
+ return data
36
+
37
+
38
+ def download_fit_to_path(
39
+ activity_id: str,
40
+ session: BrytonSession,
41
+ dest_path: Path,
42
+ *,
43
+ api_key: str = DEFAULT_API_KEY,
44
+ ) -> Path:
45
+ """Download FIT file to dest_path."""
46
+ data = download_fit_bytes(activity_id, session, api_key=api_key)
47
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
48
+ dest_path.write_bytes(data)
49
+ return dest_path
@@ -0,0 +1,249 @@
1
+ """Bryton Active → intervals.icu sync orchestration."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import date, datetime, timedelta, timezone
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ import requests
10
+
11
+ from ..dropbox_client import (
12
+ DEFAULT_DROPBOX_FOLDER,
13
+ list_dropbox_fit_names,
14
+ upload_to_dropbox,
15
+ )
16
+ from ..intervals_icu import (
17
+ fetch_uploaded_external_ids,
18
+ set_activity_type,
19
+ upload_fit_file,
20
+ )
21
+ from .api import download_fit_to_path
22
+ from .ddp import DEFAULT_HOST, BrytonSession, list_activities, login
23
+ from .exceptions import BrytonSyncError
24
+
25
+ ACTIVITY_TYPE_KEEP = ""
26
+
27
+
28
+ def external_id_for(activity_id: str) -> str:
29
+ return f"bryton_{activity_id}"
30
+
31
+
32
+ def dropbox_filename_for(activity: "Activity", use_date: bool = True) -> str:
33
+ """Return the Dropbox filename for a Bryton activity."""
34
+ fallback = f"{external_id_for(activity.activity_id)}.fit"
35
+ if not use_date:
36
+ return fallback
37
+ try:
38
+ if activity.start_time <= 0:
39
+ raise ValueError("missing start time")
40
+ start = datetime.fromtimestamp(activity.start_time)
41
+ except (TypeError, ValueError, OSError):
42
+ return fallback
43
+ return f"{start:%y%m%d%H%M%S}.fit"
44
+
45
+
46
+ @dataclass
47
+ class Activity:
48
+ activity_id: str
49
+ title: str
50
+ start_time: int
51
+
52
+
53
+ @dataclass
54
+ class SyncResult:
55
+ listed: int = 0
56
+ downloaded: int = 0
57
+ uploaded: int = 0
58
+ uploaded_dropbox: int = 0
59
+ skipped: int = 0
60
+ skipped_dropbox: int = 0
61
+ failed: int = 0
62
+ failed_dropbox: int = 0
63
+ activities: list[Activity] = field(default_factory=list)
64
+
65
+
66
+ Progress = Callable[[str], None]
67
+
68
+
69
+ def _noop(_message: str) -> None:
70
+ pass
71
+
72
+
73
+ def _activity_date_range(activities: list[Activity]) -> tuple[date, date]:
74
+ dates: list[date] = []
75
+ for act in activities:
76
+ try:
77
+ dates.append(datetime.fromtimestamp(act.start_time, tz=timezone.utc).date())
78
+ except (TypeError, ValueError, OSError):
79
+ continue
80
+ if not dates:
81
+ today = date.today()
82
+ return today - timedelta(days=365), today + timedelta(days=1)
83
+ return min(dates) - timedelta(days=1), max(dates) + timedelta(days=1)
84
+
85
+
86
+ @dataclass
87
+ class SyncConfig:
88
+ bryton_email: str
89
+ bryton_password: str
90
+ intervals_api_key: str | None
91
+ dropbox_refresh_token: str | None = None
92
+ dropbox_app_key: str | None = None
93
+ host: str = DEFAULT_HOST
94
+ max_activities: int = 5
95
+ download_dir: Path = Path("downloads")
96
+ delete_after_upload: bool = True
97
+ force_resync: bool = False
98
+ activity_type: str = ACTIVITY_TYPE_KEEP
99
+ list_activities: bool = True
100
+ download_fit: bool = True
101
+ upload_intervals: bool = True
102
+ upload_dropbox: bool = False
103
+ dropbox_folder: str = DEFAULT_DROPBOX_FOLDER
104
+ dropbox_date_filenames: bool = True
105
+
106
+
107
+ def _to_activity(doc: dict) -> Activity:
108
+ activity_id = doc.get("_id", "")
109
+ return Activity(
110
+ activity_id=activity_id,
111
+ title=doc.get("name") or doc.get("title") or f"Bryton {activity_id}",
112
+ start_time=int(doc.get("local_start_time") or 0),
113
+ )
114
+
115
+
116
+ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
117
+ """Download recent Bryton rides and upload to intervals.icu and/or Dropbox."""
118
+ report = progress or _noop
119
+ result = SyncResult()
120
+
121
+ report("Logging in to Bryton Active…")
122
+ session = login(config.bryton_email, config.bryton_password, host=config.host)
123
+ report("Logged in.")
124
+
125
+ raw_activities = list_activities(session, limit=config.max_activities)
126
+ activities = [_to_activity(doc) for doc in raw_activities]
127
+ result.activities = activities
128
+ result.listed = len(activities)
129
+
130
+ if config.list_activities:
131
+ report(f"Found {len(activities)} activities.")
132
+ for act in activities:
133
+ report(f" • {act.activity_id} | {act.start_time} | {act.title}")
134
+
135
+ if not (config.download_fit or config.upload_intervals or config.upload_dropbox):
136
+ return result
137
+
138
+ already_uploaded: set[str] = set()
139
+ if config.upload_intervals and not config.force_resync and config.intervals_api_key:
140
+ oldest, newest = _activity_date_range(activities)
141
+ try:
142
+ already_uploaded = fetch_uploaded_external_ids(
143
+ config.intervals_api_key, oldest, newest
144
+ )
145
+ report(
146
+ f"{len(already_uploaded)} activities already on intervals.icu "
147
+ "in this date range."
148
+ )
149
+ except requests.RequestException as exc:
150
+ report(f"⚠ Could not check intervals.icu (will process all): {exc}")
151
+
152
+ dropbox_uploaded_names: set[str] = set()
153
+ if config.upload_dropbox:
154
+ if not config.dropbox_app_key:
155
+ raise BrytonSyncError("Dropbox app key is required for Dropbox upload.")
156
+ if not config.dropbox_refresh_token:
157
+ raise BrytonSyncError("Connect Dropbox in Settings before syncing.")
158
+
159
+ if not config.force_resync:
160
+ try:
161
+ dropbox_uploaded_names = list_dropbox_fit_names(
162
+ config.dropbox_folder,
163
+ config.dropbox_refresh_token,
164
+ config.dropbox_app_key,
165
+ )
166
+ report(f"{len(dropbox_uploaded_names)} activities already in Dropbox.")
167
+ except Exception as exc: # noqa: BLE001 — non-fatal; process all
168
+ report(f"⚠ Could not check Dropbox (will process all): {exc}")
169
+
170
+ download_dir = Path(config.download_dir)
171
+ any_upload_enabled = config.upload_intervals or config.upload_dropbox
172
+
173
+ for act in activities:
174
+ ext = external_id_for(act.activity_id)
175
+ fit_path = download_dir / f"{ext}.fit"
176
+ dropbox_filename = dropbox_filename_for(act, config.dropbox_date_filenames)
177
+
178
+ intervals_needs_it = config.upload_intervals and ext not in already_uploaded
179
+ dropbox_needs_it = (
180
+ config.upload_dropbox and dropbox_filename not in dropbox_uploaded_names
181
+ )
182
+
183
+ if config.upload_intervals and not intervals_needs_it:
184
+ report(f"↷ Skipping {act.activity_id} — already on intervals.icu.")
185
+ result.skipped += 1
186
+ if config.upload_dropbox and not dropbox_needs_it:
187
+ report(f"↷ Skipping {act.activity_id} — already in Dropbox.")
188
+ result.skipped_dropbox += 1
189
+
190
+ if any_upload_enabled and not intervals_needs_it and not dropbox_needs_it:
191
+ continue
192
+
193
+ if config.download_fit or intervals_needs_it or dropbox_needs_it:
194
+ report(f"Downloading {act.activity_id}…")
195
+ try:
196
+ download_fit_to_path(act.activity_id, session, fit_path)
197
+ except (requests.RequestException, ValueError) as exc:
198
+ report(f"⚠ Could not download FIT for {act.activity_id}: {exc}")
199
+ result.failed += 1
200
+ continue
201
+ result.downloaded += 1
202
+
203
+ intervals_ok = True
204
+ if intervals_needs_it:
205
+ if not config.intervals_api_key:
206
+ raise BrytonSyncError("intervals.icu API key is required for upload.")
207
+
208
+ activity_id = upload_fit_file(
209
+ fit_path, act.title, ext, config.intervals_api_key
210
+ )
211
+ if activity_id:
212
+ report(f"✓ Uploaded {act.activity_id}: {act.title}")
213
+ result.uploaded += 1
214
+ if config.activity_type:
215
+ if set_activity_type(
216
+ activity_id, config.activity_type, config.intervals_api_key
217
+ ):
218
+ report(f"↻ Set {act.activity_id} → {config.activity_type}")
219
+ else:
220
+ report(f"⚠ Could not set activity type for {act.activity_id}.")
221
+ else:
222
+ report(f"✗ Failed to upload {act.activity_id}.")
223
+ result.failed += 1
224
+ intervals_ok = False
225
+
226
+ dropbox_ok = True
227
+ if dropbox_needs_it:
228
+ report(f"Uploading {act.activity_id} to Dropbox…")
229
+ try:
230
+ upload_to_dropbox(
231
+ fit_path,
232
+ dropbox_filename,
233
+ config.dropbox_refresh_token,
234
+ config.dropbox_app_key,
235
+ config.dropbox_folder,
236
+ )
237
+ except Exception as exc: # noqa: BLE001 — surface provider failures
238
+ dropbox_ok = False
239
+ result.failed_dropbox += 1
240
+ report(f"⚠ Dropbox upload failed for {act.activity_id}: {exc}")
241
+ else:
242
+ result.uploaded_dropbox += 1
243
+ report(f"✓ Uploaded {act.activity_id} to Dropbox")
244
+
245
+ if config.delete_after_upload and any_upload_enabled and intervals_ok and dropbox_ok:
246
+ fit_path.unlink(missing_ok=True)
247
+ report(f" Removed local file {fit_path.name}")
248
+
249
+ return result
@@ -0,0 +1,231 @@
1
+ """Meteor DDP login and activity listing for Bryton Active."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import random
7
+ import string
8
+ import threading
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ from .exceptions import BrytonAuthError, BrytonDDPError
15
+
16
+ DEFAULT_HOST = "m3.brytonactive.com"
17
+ WEB_HOST = "active.brytonsport.com"
18
+ DDP_CONNECT = {"msg": "connect", "version": "1", "support": ["1", "pre2", "pre1"]}
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BrytonSession:
23
+ user_id: str
24
+ auth_token: str
25
+ host: str = DEFAULT_HOST
26
+
27
+
28
+ class _DDPClient:
29
+ """Minimal synchronous Meteor DDP over SockJS/WebSocket."""
30
+
31
+ def __init__(self, host: str, *, timeout: float = 30.0) -> None:
32
+ self.host = host
33
+ self.timeout = timeout
34
+ self._ws = None
35
+ self._msg_id = 0
36
+ self._pending: dict[str, threading.Event] = {}
37
+ self._results: dict[str, tuple[Any, Any]] = {}
38
+ self._connected = threading.Event()
39
+ self._subs_ready: dict[str, threading.Event] = {}
40
+ self._collections: dict[str, dict[str, dict[str, Any]]] = {}
41
+ self._thread: threading.Thread | None = None
42
+
43
+ def _next_id(self) -> str:
44
+ self._msg_id += 1
45
+ return str(self._msg_id)
46
+
47
+ @staticmethod
48
+ def _sockjs_url(host: str) -> str:
49
+ requests.get(f"https://{host}/sockjs/info", timeout=20).raise_for_status()
50
+ server = str(random.randint(100, 999))
51
+ session = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
52
+ return f"wss://{host}/sockjs/{server}/{session}/websocket"
53
+
54
+ def _send(self, payload: dict[str, Any]) -> None:
55
+ frame = json.dumps([json.dumps(payload)])
56
+ self._ws.send(frame)
57
+
58
+ def _on_message(self, _ws: Any, message: str) -> None:
59
+ if message == "o":
60
+ self._send(DDP_CONNECT)
61
+ return
62
+ if message == "h":
63
+ return
64
+ if message.startswith("a"):
65
+ for item in json.loads(message[1:]):
66
+ self._handle(json.loads(item))
67
+
68
+ def _handle(self, msg: dict[str, Any]) -> None:
69
+ kind = msg.get("msg")
70
+ if kind == "connected":
71
+ self._connected.set()
72
+ return
73
+ if kind == "failed":
74
+ raise BrytonDDPError(f"DDP connect failed: {msg.get('reason', msg)}")
75
+ if kind == "result":
76
+ req_id = msg.get("id")
77
+ if req_id in self._pending:
78
+ self._results[req_id] = (msg.get("error"), msg.get("result"))
79
+ self._pending[req_id].set()
80
+ return
81
+ if kind == "added":
82
+ coll = msg["collection"]
83
+ self._collections.setdefault(coll, {})[msg["id"]] = dict(msg.get("fields", {}))
84
+ return
85
+ if kind == "ready":
86
+ for sub_id in msg.get("subs", []):
87
+ ready = self._subs_ready.get(sub_id)
88
+ if ready:
89
+ ready.set()
90
+ return
91
+ if kind == "nosub":
92
+ raise BrytonDDPError(f"Subscription failed: {msg.get('id')} {msg}")
93
+ if kind == "removed":
94
+ coll = msg["collection"]
95
+ self._collections.get(coll, {}).pop(msg["id"], None)
96
+ return
97
+ if kind == "changed":
98
+ coll = msg["collection"]
99
+ doc = self._collections.setdefault(coll, {}).setdefault(msg["id"], {})
100
+ doc.update(msg.get("fields", {}))
101
+ for key in msg.get("unset", {}):
102
+ doc.pop(key, None)
103
+
104
+ def connect(self) -> None:
105
+ try:
106
+ import websocket # type: ignore[import-untyped]
107
+ except ImportError as exc:
108
+ raise BrytonDDPError(
109
+ "websocket-client is required: uv pip install websocket-client"
110
+ ) from exc
111
+
112
+ url = self._sockjs_url(self.host)
113
+ self._ws = websocket.WebSocketApp(url, on_message=self._on_message)
114
+ self._thread = threading.Thread(
115
+ target=self._ws.run_forever,
116
+ kwargs={"ping_interval": 20, "ping_timeout": 10},
117
+ daemon=True,
118
+ )
119
+ self._thread.start()
120
+ if not self._connected.wait(self.timeout):
121
+ raise BrytonDDPError(f"DDP connect timed out ({self.host})")
122
+
123
+ def call(self, method: str, params: list[Any]) -> Any:
124
+ req_id = self._next_id()
125
+ event = threading.Event()
126
+ self._pending[req_id] = event
127
+ self._send({"msg": "method", "method": method, "params": params, "id": req_id})
128
+ if not event.wait(self.timeout):
129
+ raise BrytonDDPError(f"DDP method timed out: {method}")
130
+ error, result = self._results.pop(req_id)
131
+ self._pending.pop(req_id, None)
132
+ if error:
133
+ reason = error.get("reason") or error.get("message") or str(error)
134
+ if method == "login":
135
+ raise BrytonAuthError(reason)
136
+ raise BrytonDDPError(f"{method}: {reason}")
137
+ return result
138
+
139
+ def subscribe(self, name: str, params: list[Any] | None = None) -> None:
140
+ req_id = self._next_id()
141
+ ready = threading.Event()
142
+ self._subs_ready[req_id] = ready
143
+ self._send(
144
+ {
145
+ "msg": "sub",
146
+ "id": req_id,
147
+ "name": name,
148
+ "params": params or [],
149
+ }
150
+ )
151
+ if not ready.wait(self.timeout):
152
+ raise BrytonDDPError(f"Subscription timed out: {name}")
153
+ self._subs_ready.pop(req_id, None)
154
+
155
+ def close(self) -> None:
156
+ if self._ws:
157
+ self._ws.close()
158
+
159
+
160
+ def _password_digest(password: str) -> dict[str, str]:
161
+ digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
162
+ return {"digest": digest, "algorithm": "sha-256"}
163
+
164
+
165
+ def login(email: str, password: str, *, host: str = DEFAULT_HOST) -> BrytonSession:
166
+ """Log in with Bryton Active email/password."""
167
+ client = _DDPClient(host)
168
+ try:
169
+ client.connect()
170
+ result = client.call(
171
+ "login",
172
+ [{"user": {"email": email}, "password": _password_digest(password)}],
173
+ )
174
+ finally:
175
+ client.close()
176
+
177
+ if not isinstance(result, dict) or not result.get("id") or not result.get("token"):
178
+ raise BrytonAuthError(f"Unexpected login response: {result!r}")
179
+
180
+ return BrytonSession(
181
+ user_id=result["id"],
182
+ auth_token=result["token"],
183
+ host=host,
184
+ )
185
+
186
+
187
+ def _is_deleted_activity(fields: dict[str, Any]) -> bool:
188
+ if fields.get("_deleted"):
189
+ return True
190
+ label = fields.get("name") or fields.get("title") or ""
191
+ return label == "_deleted"
192
+
193
+
194
+ def call_method(
195
+ session: BrytonSession,
196
+ method: str,
197
+ params: list[Any] | None = None,
198
+ *,
199
+ timeout: float = 60,
200
+ ) -> Any:
201
+ """Resume-login and invoke a Meteor DDP method."""
202
+ client = _DDPClient(session.host, timeout=timeout)
203
+ try:
204
+ client.connect()
205
+ client.call("login", [{"resume": session.auth_token}])
206
+ return client.call(method, params or [])
207
+ finally:
208
+ client.close()
209
+
210
+
211
+ def list_activities(
212
+ session: BrytonSession,
213
+ *,
214
+ limit: int = 50,
215
+ ) -> list[dict[str, Any]]:
216
+ """Return recent activities from the activityList subscription."""
217
+ client = _DDPClient(session.host, timeout=60)
218
+ try:
219
+ client.connect()
220
+ client.call("login", [{"resume": session.auth_token}])
221
+ client.subscribe("activityList", [])
222
+ docs = client._collections.get("userActivities", {})
223
+ activities = [
224
+ {"_id": doc_id, **fields}
225
+ for doc_id, fields in docs.items()
226
+ if not _is_deleted_activity(fields)
227
+ ]
228
+ activities.sort(key=lambda a: a.get("local_start_time", 0), reverse=True)
229
+ return activities[:limit]
230
+ finally:
231
+ client.close()
@@ -0,0 +1,13 @@
1
+ """Bryton-specific errors."""
2
+
3
+
4
+ class BrytonSyncError(Exception):
5
+ """Base class for Bryton sync errors shown to users."""
6
+
7
+
8
+ class BrytonAuthError(BrytonSyncError):
9
+ """Login failed."""
10
+
11
+
12
+ class BrytonDDPError(BrytonSyncError):
13
+ """DDP transport or protocol failure."""