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,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
@@ -0,0 +1,5 @@
1
+ """Headless CLI for syncing activities to intervals.icu."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]