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,363 @@
|
|
|
1
|
+
"""Encode intervals.icu workout_doc into Bryton-compatible FIT workouts.
|
|
2
|
+
|
|
3
|
+
Bryton Active stores uploaded FIT files as-is. Generic FIT exports (e.g. from
|
|
4
|
+
intervals.icu) parse partially in the web UI but fail on mobile and omit the
|
|
5
|
+
workout chart. Bryton's own web app re-encodes imported FIT via
|
|
6
|
+
``parseToEncodeData`` + ``encodeToFit`` before upload; we mirror that here by
|
|
7
|
+
building Bryton-native workout steps from ``workout_doc``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from garmin_fit_sdk import Encoder, Profile
|
|
16
|
+
|
|
17
|
+
# Bryton custom target_type values (see woParser.js in Bryton web bundle).
|
|
18
|
+
TARGET_FTP = 245
|
|
19
|
+
TARGET_LTHR = 248
|
|
20
|
+
TARGET_MHR = 249
|
|
21
|
+
|
|
22
|
+
# Percentage targets (FTP, LTHR, MHR) use custom values + 100_000.
|
|
23
|
+
BRYTON_FTP_OFFSET = 100_000
|
|
24
|
+
|
|
25
|
+
# Bryton Active mobile ignores LTHR (248) and shows 0 % — encode HR as % MHR (249).
|
|
26
|
+
# Dev toggle: set to "lthr" to restore LTHR encoding.
|
|
27
|
+
_BRYTON_HR_TARGET: Literal["mhr", "lthr"] = "mhr"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bryton_hr_uses_mhr() -> bool:
|
|
31
|
+
return _BRYTON_HR_TARGET == "mhr"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _num(value: Any) -> float | None:
|
|
35
|
+
if value is None:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return float(value)
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _step_name(step: dict[str, Any], index: int) -> str:
|
|
44
|
+
text = str(step.get("text") or "").strip()
|
|
45
|
+
if text:
|
|
46
|
+
return text[:16]
|
|
47
|
+
intensity = str(step.get("intensity") or "").lower()
|
|
48
|
+
if step.get("warmup") or intensity == "warmup":
|
|
49
|
+
return "Warm Up"
|
|
50
|
+
if step.get("cooldown") or intensity == "cooldown":
|
|
51
|
+
return "Cool Down"
|
|
52
|
+
if intensity in ("rest", "recovery"):
|
|
53
|
+
return "Recovery"
|
|
54
|
+
return "Work"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _intensity(step: dict[str, Any]) -> str:
|
|
58
|
+
intensity = str(step.get("intensity") or "").lower()
|
|
59
|
+
if step.get("warmup") or intensity == "warmup":
|
|
60
|
+
return "warmup"
|
|
61
|
+
if step.get("cooldown") or intensity == "cooldown":
|
|
62
|
+
return "cooldown"
|
|
63
|
+
if intensity in ("rest", "recovery"):
|
|
64
|
+
return "rest"
|
|
65
|
+
return "active"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ftp_target_range(min_pct: int, max_pct: int) -> tuple[int, int, int]:
|
|
69
|
+
if min_pct > max_pct:
|
|
70
|
+
min_pct, max_pct = max_pct, min_pct
|
|
71
|
+
return (
|
|
72
|
+
TARGET_FTP,
|
|
73
|
+
min_pct + BRYTON_FTP_OFFSET,
|
|
74
|
+
max_pct + BRYTON_FTP_OFFSET,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _power_range(step: dict[str, Any]) -> tuple[int, int, int] | None:
|
|
79
|
+
"""Return Bryton % FTP targets from explicit ``%ftp`` power fields."""
|
|
80
|
+
power = step.get("power")
|
|
81
|
+
resolved = step.get("_power")
|
|
82
|
+
|
|
83
|
+
if not isinstance(power, dict):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
units = str(power.get("units") or "").lower()
|
|
87
|
+
pct_start = _num(power.get("start"))
|
|
88
|
+
pct_end = _num(power.get("end"))
|
|
89
|
+
pct_value = _num(power.get("value"))
|
|
90
|
+
|
|
91
|
+
if units != "%ftp":
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
min_pct: int | None
|
|
95
|
+
max_pct: int | None
|
|
96
|
+
|
|
97
|
+
if pct_start is not None and pct_end is not None:
|
|
98
|
+
min_pct = int(pct_start)
|
|
99
|
+
max_pct = int(pct_end)
|
|
100
|
+
elif pct_value is not None and isinstance(resolved, dict):
|
|
101
|
+
w_start = _num(resolved.get("start"))
|
|
102
|
+
w_end = _num(resolved.get("end"))
|
|
103
|
+
w_value = _num(resolved.get("value"))
|
|
104
|
+
if w_start is not None and w_end is not None:
|
|
105
|
+
ref = w_value
|
|
106
|
+
if ref is None or ref == 0:
|
|
107
|
+
ref = (w_start + w_end) / 2
|
|
108
|
+
if ref and ref != 0:
|
|
109
|
+
min_pct = int(round(w_start / ref * pct_value))
|
|
110
|
+
max_pct = int(round(w_end / ref * pct_value))
|
|
111
|
+
else:
|
|
112
|
+
min_pct = max_pct = int(pct_value)
|
|
113
|
+
else:
|
|
114
|
+
min_pct = max_pct = int(pct_value)
|
|
115
|
+
elif pct_value is not None:
|
|
116
|
+
min_pct = max_pct = int(pct_value)
|
|
117
|
+
else:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
return _ftp_target_range(min_pct, max_pct)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _power_range_from_resolved(
|
|
124
|
+
step: dict[str, Any],
|
|
125
|
+
ftp: float | None,
|
|
126
|
+
) -> tuple[int, int, int] | None:
|
|
127
|
+
"""Return Bryton % FTP targets from resolved watts in ``_power``.
|
|
128
|
+
|
|
129
|
+
intervals.icu provides resolved watt ranges in ``_power`` and athlete FTP
|
|
130
|
+
on the workout doc when ``resolve=true``. Covers ``power_zone`` and any
|
|
131
|
+
other non-``%ftp`` power units (mirrors ``_hr_range`` + LTHR).
|
|
132
|
+
"""
|
|
133
|
+
if not ftp or ftp <= 0:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
resolved = step.get("_power")
|
|
137
|
+
if not isinstance(resolved, dict):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
start = _num(resolved.get("start"))
|
|
141
|
+
end = _num(resolved.get("end"))
|
|
142
|
+
value = _num(resolved.get("value"))
|
|
143
|
+
min_w = start if start is not None else value
|
|
144
|
+
max_w = end if end is not None else value
|
|
145
|
+
if min_w is None and max_w is None:
|
|
146
|
+
return None
|
|
147
|
+
min_w = min_w if min_w is not None else max_w
|
|
148
|
+
max_w = max_w if max_w is not None else min_w
|
|
149
|
+
|
|
150
|
+
min_pct = int(round(min_w / ftp * 100))
|
|
151
|
+
max_pct = int(round(max_w / ftp * 100))
|
|
152
|
+
|
|
153
|
+
return _ftp_target_range(min_pct, max_pct)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _hr_range(
|
|
157
|
+
step: dict[str, Any],
|
|
158
|
+
*,
|
|
159
|
+
lthr: float | None,
|
|
160
|
+
max_hr: float | None,
|
|
161
|
+
) -> tuple[int, int, int] | None:
|
|
162
|
+
"""Return (target_type, low, high) as % HR reference with Bryton offsets.
|
|
163
|
+
|
|
164
|
+
Bryton encodes HR targets as custom percentages (target_type 248 = % LTHR,
|
|
165
|
+
249 = % MHR; values + 100_000), not absolute BPM (target_type 1, + 100).
|
|
166
|
+
intervals.icu provides resolved BPM in ``_hr`` and athlete ``lthr`` /
|
|
167
|
+
``max_hr`` on the workout doc when ``resolve=true``.
|
|
168
|
+
"""
|
|
169
|
+
if _BRYTON_HR_TARGET == "lthr":
|
|
170
|
+
hr_ref = lthr
|
|
171
|
+
target_type = TARGET_LTHR
|
|
172
|
+
else:
|
|
173
|
+
hr_ref = max_hr
|
|
174
|
+
target_type = TARGET_MHR
|
|
175
|
+
|
|
176
|
+
if not hr_ref or hr_ref <= 0:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
resolved = step.get("_hr")
|
|
180
|
+
if not isinstance(resolved, dict):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
start = _num(resolved.get("start"))
|
|
184
|
+
end = _num(resolved.get("end"))
|
|
185
|
+
value = _num(resolved.get("value"))
|
|
186
|
+
min_bpm = start if start is not None else value
|
|
187
|
+
max_bpm = end if end is not None else value
|
|
188
|
+
if min_bpm is None and max_bpm is None:
|
|
189
|
+
return None
|
|
190
|
+
min_bpm = min_bpm if min_bpm is not None else max_bpm
|
|
191
|
+
max_bpm = max_bpm if max_bpm is not None else min_bpm
|
|
192
|
+
if min_bpm > max_bpm:
|
|
193
|
+
min_bpm, max_bpm = max_bpm, min_bpm
|
|
194
|
+
|
|
195
|
+
min_pct = int(round(min_bpm / hr_ref * 100))
|
|
196
|
+
max_pct = int(round(max_bpm / hr_ref * 100))
|
|
197
|
+
return (
|
|
198
|
+
target_type,
|
|
199
|
+
min_pct + BRYTON_FTP_OFFSET,
|
|
200
|
+
max_pct + BRYTON_FTP_OFFSET,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _target_fields(
|
|
205
|
+
step: dict[str, Any],
|
|
206
|
+
*,
|
|
207
|
+
ftp: float | None,
|
|
208
|
+
lthr: float | None,
|
|
209
|
+
max_hr: float | None,
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
power = _power_range(step) or _power_range_from_resolved(step, ftp)
|
|
212
|
+
if power:
|
|
213
|
+
target_type, low, high = power
|
|
214
|
+
return {
|
|
215
|
+
"target_type": target_type,
|
|
216
|
+
"target_value": 0,
|
|
217
|
+
"custom_target_value_low": low,
|
|
218
|
+
"custom_target_value_high": high,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
hr = _hr_range(step, lthr=lthr, max_hr=max_hr)
|
|
222
|
+
if hr:
|
|
223
|
+
target_type, low, high = hr
|
|
224
|
+
return {
|
|
225
|
+
"target_type": target_type,
|
|
226
|
+
"target_value": 0,
|
|
227
|
+
"custom_target_value_low": low,
|
|
228
|
+
"custom_target_value_high": high,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"target_type": "open",
|
|
233
|
+
"target_value": 0,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _timed_step(
|
|
238
|
+
step: dict[str, Any],
|
|
239
|
+
index: int,
|
|
240
|
+
*,
|
|
241
|
+
ftp: float | None,
|
|
242
|
+
lthr: float | None,
|
|
243
|
+
max_hr: float | None,
|
|
244
|
+
name: str | None = None,
|
|
245
|
+
) -> dict[str, Any] | None:
|
|
246
|
+
if step.get("until_lap_press"):
|
|
247
|
+
mesg: dict[str, Any] = {
|
|
248
|
+
"message_index": index,
|
|
249
|
+
"wkt_step_name": name or _step_name(step, index + 1),
|
|
250
|
+
"duration_type": "open",
|
|
251
|
+
"duration_value": 0,
|
|
252
|
+
"intensity": _intensity(step),
|
|
253
|
+
}
|
|
254
|
+
mesg.update(_target_fields(step, ftp=ftp, lthr=lthr, max_hr=max_hr))
|
|
255
|
+
return mesg
|
|
256
|
+
|
|
257
|
+
duration = step.get("duration")
|
|
258
|
+
if duration is None:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
mesg = {
|
|
262
|
+
"message_index": index,
|
|
263
|
+
"wkt_step_name": name or _step_name(step, index + 1),
|
|
264
|
+
"duration_type": "time",
|
|
265
|
+
"duration_value": int(duration) * 1000,
|
|
266
|
+
"intensity": _intensity(step),
|
|
267
|
+
}
|
|
268
|
+
mesg.update(_target_fields(step, ftp=ftp, lthr=lthr, max_hr=max_hr))
|
|
269
|
+
return mesg
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _flatten_steps(
|
|
273
|
+
raw_steps: list[Any],
|
|
274
|
+
*,
|
|
275
|
+
ftp: float | None,
|
|
276
|
+
lthr: float | None,
|
|
277
|
+
max_hr: float | None,
|
|
278
|
+
) -> list[dict[str, Any]] | None:
|
|
279
|
+
"""Expand intervals.icu steps (including repeats) into Bryton FIT messages."""
|
|
280
|
+
out: list[dict[str, Any]] = []
|
|
281
|
+
|
|
282
|
+
def append_repeat(start_index: int, repeat_count: int) -> None:
|
|
283
|
+
out.append(
|
|
284
|
+
{
|
|
285
|
+
"message_index": len(out),
|
|
286
|
+
"duration_type": "repeat_until_steps_cmplt",
|
|
287
|
+
"duration_value": start_index,
|
|
288
|
+
"target_value": int(repeat_count),
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
for step in raw_steps:
|
|
293
|
+
if not isinstance(step, dict):
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
reps = step.get("reps")
|
|
297
|
+
nested = step.get("steps")
|
|
298
|
+
if reps and isinstance(nested, list) and nested:
|
|
299
|
+
start_index = len(out)
|
|
300
|
+
for child in nested:
|
|
301
|
+
if not isinstance(child, dict):
|
|
302
|
+
continue
|
|
303
|
+
mapped = _timed_step(
|
|
304
|
+
child, len(out), ftp=ftp, lthr=lthr, max_hr=max_hr
|
|
305
|
+
)
|
|
306
|
+
if mapped is None:
|
|
307
|
+
return None
|
|
308
|
+
out.append(mapped)
|
|
309
|
+
if len(out) == start_index:
|
|
310
|
+
return None
|
|
311
|
+
append_repeat(start_index, int(reps))
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
mapped = _timed_step(step, len(out), ftp=ftp, lthr=lthr, max_hr=max_hr)
|
|
315
|
+
if mapped is None:
|
|
316
|
+
return None
|
|
317
|
+
out.append(mapped)
|
|
318
|
+
|
|
319
|
+
return out or None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def icu_workout_doc_to_bryton_fit(
|
|
323
|
+
name: str,
|
|
324
|
+
workout_doc: dict[str, Any],
|
|
325
|
+
*,
|
|
326
|
+
max_hr: float | None = None,
|
|
327
|
+
) -> bytes | None:
|
|
328
|
+
"""Build a Bryton-compatible workout FIT file from intervals.icu workout_doc."""
|
|
329
|
+
raw_steps = workout_doc.get("steps")
|
|
330
|
+
if not isinstance(raw_steps, list) or not raw_steps:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
lthr = _num(workout_doc.get("lthr"))
|
|
334
|
+
if max_hr is None:
|
|
335
|
+
max_hr = _num(workout_doc.get("max_hr"))
|
|
336
|
+
ftp = _num(workout_doc.get("ftp"))
|
|
337
|
+
steps = _flatten_steps(raw_steps, ftp=ftp, lthr=lthr, max_hr=max_hr)
|
|
338
|
+
if not steps:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
wkt_name = name.strip() or "Workout"
|
|
342
|
+
encoder = Encoder()
|
|
343
|
+
encoder.on_mesg(
|
|
344
|
+
Profile["mesg_num"]["FILE_ID"],
|
|
345
|
+
{
|
|
346
|
+
"type": "workout",
|
|
347
|
+
"manufacturer": "bryton",
|
|
348
|
+
"serial_number": 255,
|
|
349
|
+
"time_created": datetime.now(tz=timezone.utc),
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
encoder.on_mesg(
|
|
353
|
+
Profile["mesg_num"]["WORKOUT"],
|
|
354
|
+
{
|
|
355
|
+
"sport": "cycling",
|
|
356
|
+
"num_valid_steps": len(steps),
|
|
357
|
+
"wkt_name": wkt_name,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
for step in steps:
|
|
361
|
+
encoder.on_mesg(Profile["mesg_num"]["WORKOUT_STEP"], step)
|
|
362
|
+
|
|
363
|
+
return bytes(encoder.close())
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Upload planned workouts from intervals.icu to Bryton Active (web FIT API).
|
|
2
|
+
|
|
3
|
+
Uses the Bryton Active website backend (active.brytonsport.com): map
|
|
4
|
+
intervals.icu ``workout_doc`` to Bryton-native FIT and POST multipart to
|
|
5
|
+
/workout/upload/{userId}.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import date, datetime, timedelta
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .. import intervals_icu
|
|
18
|
+
from .ddp import WEB_HOST, BrytonSession, call_method, login
|
|
19
|
+
from .exceptions import BrytonSyncError
|
|
20
|
+
from .fit_encode import bryton_hr_uses_mhr, icu_workout_doc_to_bryton_fit
|
|
21
|
+
|
|
22
|
+
_CYCLING_TYPES = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"Ride",
|
|
25
|
+
"MountainBikeRide",
|
|
26
|
+
"GravelRide",
|
|
27
|
+
"VirtualRide",
|
|
28
|
+
"EBikeRide",
|
|
29
|
+
"EMountainBikeRide",
|
|
30
|
+
"TrackRide",
|
|
31
|
+
"Cyclocross",
|
|
32
|
+
"Handcycle",
|
|
33
|
+
"Velomobile",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Progress = Callable[[str], None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _steps_target_hr(steps: Any) -> bool:
|
|
41
|
+
if not isinstance(steps, list):
|
|
42
|
+
return False
|
|
43
|
+
for step in steps:
|
|
44
|
+
if not isinstance(step, dict):
|
|
45
|
+
continue
|
|
46
|
+
if step.get("hr") is not None:
|
|
47
|
+
return True
|
|
48
|
+
if _steps_target_hr(step.get("steps")):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def workout_doc_targets_hr(workout_doc: dict[str, Any]) -> bool:
|
|
54
|
+
"""Return True when intervals.icu workout_doc uses heart-rate targets."""
|
|
55
|
+
target = str(workout_doc.get("target") or "").upper()
|
|
56
|
+
if target == "HR":
|
|
57
|
+
return True
|
|
58
|
+
return _steps_target_hr(workout_doc.get("steps"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _noop(_message: str) -> None:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class BrytonWorkoutUploadResult:
|
|
67
|
+
listed: int = 0
|
|
68
|
+
uploaded: int = 0
|
|
69
|
+
skipped: int = 0
|
|
70
|
+
failed: int = 0
|
|
71
|
+
no_steps: int = 0
|
|
72
|
+
uploaded_map: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
pruned_keys: list[str] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _stored_on_bryton(stored: str | None, live_ids: set[str], live_names: set[str]) -> bool:
|
|
77
|
+
if not stored:
|
|
78
|
+
return False
|
|
79
|
+
return stored in live_ids or stored in live_names
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class BrytonWorkoutUploadConfig:
|
|
84
|
+
bryton_email: str
|
|
85
|
+
bryton_password: str
|
|
86
|
+
intervals_api_key: str
|
|
87
|
+
oldest: date | None = None
|
|
88
|
+
newest: date | None = None
|
|
89
|
+
workout_days_ahead: int = 1
|
|
90
|
+
uploaded_workouts: dict[str, str] = field(default_factory=dict)
|
|
91
|
+
force_resync: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def apply_uploaded_bryton_workout_map(
|
|
95
|
+
uploaded_workouts: dict[str, str],
|
|
96
|
+
result: BrytonWorkoutUploadResult,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Merge upload results into config and drop entries for deleted workouts."""
|
|
99
|
+
uploaded_workouts.update(result.uploaded_map)
|
|
100
|
+
for key in result.pruned_keys:
|
|
101
|
+
uploaded_workouts.pop(key, None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _file_stem(name: str) -> str:
|
|
105
|
+
stem = name[:-4] if name.lower().endswith(".fit") else name
|
|
106
|
+
return stem.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _names_from_file_entry(entry: Any) -> set[str]:
|
|
110
|
+
names: set[str] = set()
|
|
111
|
+
if isinstance(entry, str):
|
|
112
|
+
names.add(_file_stem(entry))
|
|
113
|
+
return names
|
|
114
|
+
if not isinstance(entry, dict):
|
|
115
|
+
return names
|
|
116
|
+
for key in ("name", "filename", "title", "originalName"):
|
|
117
|
+
value = entry.get(key)
|
|
118
|
+
if isinstance(value, str) and value.strip():
|
|
119
|
+
names.add(_file_stem(value))
|
|
120
|
+
file_id = entry.get("_id") or entry.get("id")
|
|
121
|
+
if isinstance(file_id, str) and file_id.strip():
|
|
122
|
+
names.add(_file_stem(file_id))
|
|
123
|
+
return names
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ids_from_file_entry(entry: Any) -> set[str]:
|
|
127
|
+
ids: set[str] = set()
|
|
128
|
+
if isinstance(entry, str):
|
|
129
|
+
ids.add(entry.strip())
|
|
130
|
+
return ids
|
|
131
|
+
if not isinstance(entry, dict):
|
|
132
|
+
return ids
|
|
133
|
+
for key in ("id", "_id"):
|
|
134
|
+
value = entry.get(key)
|
|
135
|
+
if isinstance(value, str) and value.strip():
|
|
136
|
+
ids.add(value.strip())
|
|
137
|
+
url = entry.get("url")
|
|
138
|
+
if isinstance(url, str) and "/userFiles/" in url:
|
|
139
|
+
parts = url.split("/userFiles/", 1)[-1].split("/")
|
|
140
|
+
if parts and parts[0].strip():
|
|
141
|
+
ids.add(parts[0].strip())
|
|
142
|
+
return ids
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_workout_list_result(result: Any) -> list[dict[str, Any]]:
|
|
146
|
+
if isinstance(result, dict):
|
|
147
|
+
workouts = result.get("workout")
|
|
148
|
+
if isinstance(workouts, list):
|
|
149
|
+
return [entry for entry in workouts if isinstance(entry, dict)]
|
|
150
|
+
if isinstance(result, list):
|
|
151
|
+
return [entry for entry in result if isinstance(entry, dict)]
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fetch_workout_library(session: BrytonSession) -> tuple[set[str], set[str]]:
|
|
156
|
+
"""Return (file ids, known name stems) from Bryton's user.file.list."""
|
|
157
|
+
result = call_method(session, "user.file.list", [])
|
|
158
|
+
ids: set[str] = set()
|
|
159
|
+
names: set[str] = set()
|
|
160
|
+
for entry in _parse_workout_list_result(result):
|
|
161
|
+
entry_ids = _ids_from_file_entry(entry)
|
|
162
|
+
ids.update(entry_ids)
|
|
163
|
+
names.update(_names_from_file_entry(entry))
|
|
164
|
+
names.update(entry_ids)
|
|
165
|
+
return ids, names
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def list_workout_file_ids(session: BrytonSession) -> set[str]:
|
|
169
|
+
"""Return Bryton workout file ids currently in the user's library."""
|
|
170
|
+
ids, _ = _fetch_workout_library(session)
|
|
171
|
+
return ids
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def list_workout_file_names(session: BrytonSession) -> set[str]:
|
|
175
|
+
"""Return workout file name stems currently in the Bryton library."""
|
|
176
|
+
_, names = _fetch_workout_library(session)
|
|
177
|
+
return names
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _sanitize_workout_filename(name: str, *, event_id: int) -> str:
|
|
181
|
+
cleaned = re.sub(r"[^\w\-]+", "_", name.strip())
|
|
182
|
+
cleaned = cleaned.strip("_")
|
|
183
|
+
if not cleaned:
|
|
184
|
+
cleaned = f"icu_{event_id}"
|
|
185
|
+
return cleaned[:48]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _auto_workout_filename() -> str:
|
|
189
|
+
now = datetime.now()
|
|
190
|
+
return (
|
|
191
|
+
f"bsWO{now.month:02d}{now.day:02d}_"
|
|
192
|
+
f"{now.hour:02d}{now.minute:02d}{now.second:02d}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def upload_workout_fit(
|
|
197
|
+
session: BrytonSession,
|
|
198
|
+
fit_bytes: bytes,
|
|
199
|
+
name: str,
|
|
200
|
+
*,
|
|
201
|
+
http: requests.Session | None = None,
|
|
202
|
+
) -> bool:
|
|
203
|
+
"""Upload a FIT workout to Bryton Active web; return True on success."""
|
|
204
|
+
stem = _file_stem(name) or _auto_workout_filename()
|
|
205
|
+
filename = f"{stem}.fit"
|
|
206
|
+
url = f"https://{session.host}/workout/upload/{session.user_id}"
|
|
207
|
+
headers = {
|
|
208
|
+
"X-User-Id": session.user_id,
|
|
209
|
+
"X-Auth-Token": session.auth_token,
|
|
210
|
+
}
|
|
211
|
+
client = http or requests.Session()
|
|
212
|
+
resp = client.post(
|
|
213
|
+
url,
|
|
214
|
+
files={"file": (filename, fit_bytes, "application/octet-stream")},
|
|
215
|
+
data={"name": filename, "provider": "bryton"},
|
|
216
|
+
headers=headers,
|
|
217
|
+
timeout=120,
|
|
218
|
+
)
|
|
219
|
+
return resp.ok
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def web_login(email: str, password: str) -> BrytonSession:
|
|
223
|
+
"""Log in to the Bryton Active website backend."""
|
|
224
|
+
return login(email, password, host=WEB_HOST)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def upload_workouts(
|
|
228
|
+
config: BrytonWorkoutUploadConfig,
|
|
229
|
+
progress: Progress | None = None,
|
|
230
|
+
) -> BrytonWorkoutUploadResult:
|
|
231
|
+
"""Fetch upcoming intervals.icu workouts and push FIT files to Bryton."""
|
|
232
|
+
report = progress or _noop
|
|
233
|
+
result = BrytonWorkoutUploadResult()
|
|
234
|
+
|
|
235
|
+
today = date.today()
|
|
236
|
+
oldest = config.oldest or today
|
|
237
|
+
days = max(1, config.workout_days_ahead)
|
|
238
|
+
newest = config.newest or (today + timedelta(days=days - 1))
|
|
239
|
+
|
|
240
|
+
report("Logging in to Bryton Active…")
|
|
241
|
+
session = web_login(config.bryton_email, config.bryton_password)
|
|
242
|
+
report("Logged in.")
|
|
243
|
+
|
|
244
|
+
live_ids, live_names = _fetch_workout_library(session)
|
|
245
|
+
report(f"Found {len(live_ids)} custom workouts on Bryton.")
|
|
246
|
+
|
|
247
|
+
report("Fetching planned workouts from intervals.icu…")
|
|
248
|
+
intervals_http = requests.Session()
|
|
249
|
+
try:
|
|
250
|
+
calendar = intervals_icu.fetch_calendar_workouts(
|
|
251
|
+
config.intervals_api_key,
|
|
252
|
+
oldest,
|
|
253
|
+
newest,
|
|
254
|
+
http=intervals_http,
|
|
255
|
+
)
|
|
256
|
+
except requests.RequestException as exc:
|
|
257
|
+
raise BrytonSyncError(f"Could not fetch intervals.icu workouts: {exc}") from exc
|
|
258
|
+
|
|
259
|
+
result.listed = len(calendar)
|
|
260
|
+
report(f"Found {len(calendar)} planned workouts.")
|
|
261
|
+
|
|
262
|
+
http = requests.Session()
|
|
263
|
+
cached_max_hr: float | None = None
|
|
264
|
+
max_hr_fetched = False
|
|
265
|
+
for workout in calendar:
|
|
266
|
+
event_key = str(workout.event_id)
|
|
267
|
+
|
|
268
|
+
if workout.activity_type not in _CYCLING_TYPES:
|
|
269
|
+
report(
|
|
270
|
+
f"↷ Skipping {workout.name} — unsupported type "
|
|
271
|
+
f"{workout.activity_type!r} (cycling only in v1)."
|
|
272
|
+
)
|
|
273
|
+
result.skipped += 1
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
stored = config.uploaded_workouts.get(event_key)
|
|
277
|
+
on_bryton = _stored_on_bryton(stored, live_ids, live_names)
|
|
278
|
+
|
|
279
|
+
if on_bryton and not config.force_resync:
|
|
280
|
+
report(f"↷ Skipping {workout.name} — already on Bryton.")
|
|
281
|
+
result.skipped += 1
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
ids_before = set(live_ids)
|
|
285
|
+
|
|
286
|
+
encode_max_hr: float | None = None
|
|
287
|
+
if bryton_hr_uses_mhr() and workout_doc_targets_hr(workout.workout_doc):
|
|
288
|
+
if not max_hr_fetched:
|
|
289
|
+
try:
|
|
290
|
+
cached_max_hr = intervals_icu.fetch_sport_settings_max_hr(
|
|
291
|
+
config.intervals_api_key,
|
|
292
|
+
workout.activity_type,
|
|
293
|
+
http=intervals_http,
|
|
294
|
+
)
|
|
295
|
+
except requests.RequestException as exc:
|
|
296
|
+
raise BrytonSyncError(
|
|
297
|
+
f"Could not fetch intervals.icu max HR for {workout.activity_type}: {exc}"
|
|
298
|
+
) from exc
|
|
299
|
+
max_hr_fetched = True
|
|
300
|
+
encode_max_hr = cached_max_hr
|
|
301
|
+
|
|
302
|
+
fit_bytes = icu_workout_doc_to_bryton_fit(
|
|
303
|
+
workout.name,
|
|
304
|
+
workout.workout_doc,
|
|
305
|
+
max_hr=encode_max_hr,
|
|
306
|
+
)
|
|
307
|
+
if fit_bytes is None:
|
|
308
|
+
report(
|
|
309
|
+
f"⚠ Skipping {workout.name} — no structured steps "
|
|
310
|
+
"(open the workout in intervals.icu first)."
|
|
311
|
+
)
|
|
312
|
+
result.no_steps += 1
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
upload_name = (
|
|
316
|
+
stored
|
|
317
|
+
if config.force_resync and on_bryton and stored
|
|
318
|
+
else _auto_workout_filename()
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
report(f"Uploading {workout.name}…")
|
|
322
|
+
if upload_workout_fit(session, fit_bytes, upload_name, http=http):
|
|
323
|
+
after_ids, _ = _fetch_workout_library(session)
|
|
324
|
+
new_ids = after_ids - ids_before
|
|
325
|
+
stored_value = next(iter(new_ids), upload_name)
|
|
326
|
+
report(f"✓ Uploaded {workout.name} ({upload_name}.fit)")
|
|
327
|
+
result.uploaded += 1
|
|
328
|
+
result.uploaded_map[event_key] = stored_value
|
|
329
|
+
live_ids.add(stored_value)
|
|
330
|
+
live_names.add(upload_name)
|
|
331
|
+
live_names.add(_file_stem(upload_name))
|
|
332
|
+
else:
|
|
333
|
+
report(f"✗ Failed to upload {workout.name}.")
|
|
334
|
+
result.failed += 1
|
|
335
|
+
|
|
336
|
+
for event_key, stored_value in config.uploaded_workouts.items():
|
|
337
|
+
if event_key in result.uploaded_map:
|
|
338
|
+
continue
|
|
339
|
+
if not _stored_on_bryton(stored_value, live_ids, live_names):
|
|
340
|
+
result.pruned_keys.append(event_key)
|
|
341
|
+
|
|
342
|
+
return result
|