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.
- intervalssync/__init__.py +3 -0
- intervalssync/_dropbox_app_key.py +7 -0
- intervalssync/_version.py +1 -0
- intervalssync/bryton/__init__.py +1 -0
- intervalssync/bryton/api.py +49 -0
- intervalssync/bryton/core.py +249 -0
- intervalssync/bryton/ddp.py +231 -0
- intervalssync/bryton/exceptions.py +13 -0
- intervalssync/bryton/fit_encode.py +363 -0
- intervalssync/bryton/workout.py +342 -0
- intervalssync/cli/__init__.py +5 -0
- intervalssync/cli/config.py +53 -0
- intervalssync/cli/env.py +189 -0
- intervalssync/cli/main.py +637 -0
- intervalssync/dropbox_client.py +143 -0
- intervalssync/gui/__init__.py +1 -0
- intervalssync/gui/app.py +352 -0
- intervalssync/gui/config.py +102 -0
- intervalssync/gui/profile_sync_ui.py +225 -0
- intervalssync/gui/secrets.py +135 -0
- intervalssync/gui/settings_view.py +716 -0
- intervalssync/gui/support_gamification.py +658 -0
- intervalssync/gui/sync_view.py +486 -0
- intervalssync/gui/system.py +21 -0
- intervalssync/gui/theme.py +216 -0
- intervalssync/gui/update_check.py +55 -0
- intervalssync/igpsport/__init__.py +1 -0
- intervalssync/igpsport/core.py +520 -0
- intervalssync/igpsport/interval_info.py +269 -0
- intervalssync/igpsport/profile_sync.py +391 -0
- intervalssync/igpsport/region.py +80 -0
- intervalssync/igpsport/workout.py +503 -0
- intervalssync/igpsport/zone_map.py +143 -0
- intervalssync/intervals_icu.py +201 -0
- intervalssync-0.9.3.dist-info/METADATA +179 -0
- intervalssync-0.9.3.dist-info/RECORD +39 -0
- intervalssync-0.9.3.dist-info/WHEEL +4 -0
- intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
- intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
|
@@ -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."""
|