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,269 @@
1
+ """iGPSPORT mobile API for personal interval / zone profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from .region import INTERNATIONAL, IgpRegionConfig, resolve_region
12
+
13
+ IGPS_MOBILE_API = INTERNATIONAL.mobile_api_base
14
+ GET_INTERVAL_URL = INTERNATIONAL.get_interval_url
15
+ UPDATE_INTERVAL_URL = INTERNATIONAL.update_interval_url
16
+
17
+ # iGPSPORT heartRateComputeMode: 0 = max HR, 1 = HRR, 2 = LTHR.
18
+ HEART_RATE_COMPUTE_MODE_MAX_HR = 0
19
+
20
+
21
+ def member_id_from_token(auth_headers: dict[str, str]) -> int | None:
22
+ token = auth_headers.get("Authorization", "").removeprefix("Bearer ").strip()
23
+ parts = token.split(".")
24
+ if len(parts) < 2:
25
+ return None
26
+ segment = parts[1]
27
+ padding = "=" * (-len(segment) % 4)
28
+ try:
29
+ payload = json.loads(base64.urlsafe_b64decode(segment + padding))
30
+ except (ValueError, json.JSONDecodeError):
31
+ return None
32
+ for key in ("memberid", "memberId", "sub"):
33
+ value = payload.get(key)
34
+ if value is not None:
35
+ try:
36
+ return int(value)
37
+ except (TypeError, ValueError):
38
+ continue
39
+ return None
40
+
41
+
42
+ def mobile_headers(
43
+ auth_headers: dict[str, str],
44
+ member_id: int | None,
45
+ region: IgpRegionConfig | str | None = None,
46
+ ) -> dict[str, str]:
47
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
48
+ headers = {
49
+ **auth_headers,
50
+ "Accept": "application/json",
51
+ "Accept-Encoding": "gzip",
52
+ "Accept-Language": cfg.accept_language,
53
+ "Content-Type": "application/json; charset=UTF-8",
54
+ "User-Agent": "intervalssync/1.0",
55
+ "qiwu-app-version": "8.06.36",
56
+ "timezone": "UTC",
57
+ }
58
+ if member_id is not None:
59
+ headers["qiwu-uid"] = str(member_id)
60
+ return headers
61
+
62
+
63
+ def _unwrap_api_data(response_json: Any) -> dict[str, Any] | None:
64
+ if not isinstance(response_json, dict):
65
+ return None
66
+ if response_json.get("code") != 0:
67
+ return None
68
+ data = response_json.get("data")
69
+ if isinstance(data, dict) and "member" in data:
70
+ return data
71
+ if isinstance(data, dict):
72
+ nested = data.get("data")
73
+ if isinstance(nested, dict) and "member" in nested:
74
+ return nested
75
+ for key in ("intervalInfo", "userIntervalInfo", "personalIntervalInfo"):
76
+ nested = data.get(key)
77
+ if isinstance(nested, dict) and "member" in nested:
78
+ return nested
79
+ if "member" in response_json:
80
+ return response_json
81
+ return None
82
+
83
+
84
+ def fetch_personal_interval_info(
85
+ session: requests.Session,
86
+ headers: dict[str, str],
87
+ region: IgpRegionConfig | str | None = None,
88
+ ) -> dict[str, Any]:
89
+ """GET v2/User/UserIntervalInfo."""
90
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
91
+ get_headers = {k: v for k, v in headers.items() if k.lower() != "content-type"}
92
+ try:
93
+ resp = session.get(cfg.get_interval_url, headers=get_headers, timeout=30)
94
+ except requests.RequestException as exc:
95
+ raise RuntimeError(f"GET UserIntervalInfo failed: {exc}") from exc
96
+
97
+ try:
98
+ body = resp.json()
99
+ except ValueError as exc:
100
+ raise RuntimeError(
101
+ f"GET UserIntervalInfo: HTTP {resp.status_code}, non-JSON body"
102
+ ) from exc
103
+
104
+ payload = _unwrap_api_data(body)
105
+ if payload is None:
106
+ raise RuntimeError(
107
+ f"GET UserIntervalInfo: HTTP {resp.status_code}, "
108
+ f"code={body.get('code') if isinstance(body, dict) else '?'}, "
109
+ f"no member block in response"
110
+ )
111
+ return payload
112
+
113
+
114
+ def update_personal_interval_info(
115
+ session: requests.Session,
116
+ headers: dict[str, str],
117
+ body: dict[str, Any],
118
+ region: IgpRegionConfig | str | None = None,
119
+ ) -> dict[str, Any]:
120
+ """POST UpdatePersonalIntervalInfo."""
121
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
122
+ try:
123
+ resp = session.post(
124
+ cfg.update_interval_url, headers=headers, json=body, timeout=30
125
+ )
126
+ except requests.RequestException as exc:
127
+ raise RuntimeError(f"POST UpdatePersonalIntervalInfo failed: {exc}") from exc
128
+
129
+ try:
130
+ result = resp.json()
131
+ except ValueError as exc:
132
+ raise RuntimeError(
133
+ f"POST UpdatePersonalIntervalInfo: HTTP {resp.status_code}, non-JSON body"
134
+ ) from exc
135
+
136
+ if not resp.ok:
137
+ raise RuntimeError(f"POST UpdatePersonalIntervalInfo: HTTP {resp.status_code}")
138
+
139
+ if isinstance(result, dict) and result.get("code") not in (0, None):
140
+ message = result.get("message") or "unknown error"
141
+ raise RuntimeError(f"POST UpdatePersonalIntervalInfo failed: {message}")
142
+
143
+ return result if isinstance(result, dict) else {}
144
+
145
+
146
+ def fetch_user_info(
147
+ session: requests.Session,
148
+ headers: dict[str, str],
149
+ region: IgpRegionConfig | str | None = None,
150
+ ) -> dict[str, Any]:
151
+ """GET User/UserInfo (profile fields including weight shown in the app)."""
152
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
153
+ get_headers = {k: v for k, v in headers.items() if k.lower() != "content-type"}
154
+ try:
155
+ resp = session.get(cfg.user_info_url, headers=get_headers, timeout=30)
156
+ except requests.RequestException as exc:
157
+ raise RuntimeError(f"GET UserInfo failed: {exc}") from exc
158
+
159
+ try:
160
+ body = resp.json()
161
+ except ValueError as exc:
162
+ raise RuntimeError(
163
+ f"GET UserInfo: HTTP {resp.status_code}, non-JSON body"
164
+ ) from exc
165
+
166
+ if not isinstance(body, dict) or body.get("code") not in (0, None):
167
+ message = body.get("message") if isinstance(body, dict) else "unknown error"
168
+ raise RuntimeError(f"GET UserInfo failed: {message}")
169
+
170
+ data = body.get("data")
171
+ if not isinstance(data, dict):
172
+ raise RuntimeError("GET UserInfo: missing data object")
173
+ return data
174
+
175
+
176
+ def build_personal_user_info_payload(
177
+ user_info: dict[str, Any],
178
+ weight_kg: int,
179
+ ) -> dict[str, Any]:
180
+ """Build the UpdatePersonalUserInfo body the iGPSPORT app sends.
181
+
182
+ Captured from the Android profile editor: a 6-field personal payload with
183
+ ``areaId`` (UserInfo ``cityId``) and ``gender`` (UserInfo ``sex``). Using
184
+ ``UpdateUserInfo`` with a full UserInfo merge clears location.
185
+ """
186
+ area_id = user_info.get("cityId")
187
+ if area_id in (None, "", 0, "0"):
188
+ area_id = user_info.get("areaId")
189
+ gender = user_info.get("gender")
190
+ if gender is None:
191
+ gender = user_info.get("sex")
192
+
193
+ return {
194
+ "areaId": int(area_id) if area_id not in (None, "") else 0,
195
+ "birthDate": user_info.get("birthDate") or "",
196
+ "gender": int(gender) if gender is not None else 0,
197
+ "height": int(user_info["height"]) if user_info.get("height") is not None else 0,
198
+ "nickName": user_info.get("nickName") or "",
199
+ # App sends a float (e.g. 76.0); whole-kg rounding happens before call.
200
+ "weight": float(int(weight_kg)),
201
+ }
202
+
203
+
204
+ def update_user_weight(
205
+ session: requests.Session,
206
+ headers: dict[str, str],
207
+ weight_kg: int,
208
+ region: IgpRegionConfig | str | None = None,
209
+ ) -> dict[str, Any]:
210
+ """POST User/UpdatePersonalUserInfo with whole-kg weight (app profile weight).
211
+
212
+ Matches the Android editor: personal fields only, with ``areaId`` so location
213
+ is preserved. ``UpdateUserInfo`` clears ``cityId`` even when present in body.
214
+ """
215
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
216
+ current = fetch_user_info(session, headers, region)
217
+ payload = build_personal_user_info_payload(current, weight_kg)
218
+
219
+ try:
220
+ resp = session.post(
221
+ cfg.update_personal_user_info_url,
222
+ headers=headers,
223
+ json=payload,
224
+ timeout=30,
225
+ )
226
+ except requests.RequestException as exc:
227
+ raise RuntimeError(f"POST UpdatePersonalUserInfo failed: {exc}") from exc
228
+
229
+ try:
230
+ result = resp.json()
231
+ except ValueError as exc:
232
+ raise RuntimeError(
233
+ f"POST UpdatePersonalUserInfo: HTTP {resp.status_code}, non-JSON body"
234
+ ) from exc
235
+
236
+ if not resp.ok:
237
+ raise RuntimeError(f"POST UpdatePersonalUserInfo: HTTP {resp.status_code}")
238
+
239
+ if isinstance(result, dict) and result.get("code") not in (0, None):
240
+ message = result.get("message") or "unknown error"
241
+ raise RuntimeError(f"POST UpdatePersonalUserInfo failed: {message}")
242
+
243
+ return result if isinstance(result, dict) else {}
244
+
245
+
246
+ def zone_range_summary(zones: list[dict[str, Any]]) -> str:
247
+ if not zones:
248
+ return "(none)"
249
+ return " | ".join(f"{z.get('start')}-{z.get('end')}" for z in zones)
250
+
251
+
252
+ def profile_summary(
253
+ body: dict[str, Any],
254
+ *,
255
+ weight: float | None = None,
256
+ ) -> dict[str, Any]:
257
+ """Return a compact summary dict for CLI JSON output."""
258
+ member = body.get("member") if isinstance(body.get("member"), dict) else {}
259
+ power = body.get("power") if isinstance(body.get("power"), list) else []
260
+ heart_rate = body.get("heartRate") if isinstance(body.get("heartRate"), list) else []
261
+ return {
262
+ "ftp": member.get("ftp"),
263
+ "lthr": member.get("lthr"),
264
+ "mhr": member.get("mhr"),
265
+ "weight": weight if weight is not None else member.get("weight"),
266
+ "heart_rate_compute_mode": member.get("heartRateComputeMode"),
267
+ "power_zones": zone_range_summary(power),
268
+ "hr_zones": zone_range_summary(heart_rate),
269
+ }
@@ -0,0 +1,391 @@
1
+ """Sync thresholds, zones, and weight from intervals.icu to iGPSPORT profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ import requests
10
+
11
+ from .. import intervals_icu
12
+ from ..intervals_icu import SportSettings
13
+ from .core import SyncError, login
14
+ from .region import resolve_region
15
+ from .interval_info import (
16
+ HEART_RATE_COMPUTE_MODE_MAX_HR,
17
+ fetch_personal_interval_info,
18
+ fetch_user_info,
19
+ mobile_headers,
20
+ member_id_from_token,
21
+ profile_summary,
22
+ update_personal_interval_info,
23
+ update_user_weight,
24
+ zone_range_summary,
25
+ )
26
+ from .zone_map import map_hr_zones, map_power_zones
27
+
28
+ Progress = Callable[[str], None]
29
+
30
+
31
+ def _noop(_message: str) -> None:
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class ProfileSyncConfig:
37
+ igp_user: str
38
+ igp_password: str
39
+ intervals_api_key: str
40
+ igp_region: str = "international"
41
+ sport: str = "Ride"
42
+
43
+
44
+ @dataclass
45
+ class ProfileSyncResult:
46
+ before: dict[str, Any] | None
47
+ after: dict[str, Any] | None
48
+ weight_before: float | None = None
49
+ weight_after: float | None = None
50
+
51
+
52
+ @dataclass
53
+ class ProfileThresholdStatus:
54
+ needs_sync: bool
55
+ differences: list[str]
56
+ intervals_fingerprint: str
57
+ intervals: dict[str, int | None]
58
+ igpsport: dict[str, int | None]
59
+
60
+
61
+ _THRESHOLD_LABELS = {"ftp": "FTP", "lthr": "LTHR", "mhr": "max HR", "weight": "Weight"}
62
+
63
+
64
+ def _whole_kg(value: Any) -> int | None:
65
+ """Round kg to a whole number; iGPSPORT only accepts integer kilograms."""
66
+ if value is None:
67
+ return None
68
+ try:
69
+ kg = int(round(float(value)))
70
+ except (TypeError, ValueError):
71
+ return None
72
+ if kg <= 0:
73
+ return None
74
+ return kg
75
+
76
+
77
+ def _threshold_values(
78
+ member: dict[str, Any],
79
+ *,
80
+ weight: float | None = None,
81
+ ) -> dict[str, int | None]:
82
+ def _int_val(key: str) -> int | None:
83
+ value = member.get(key)
84
+ if value is None:
85
+ return None
86
+ try:
87
+ return int(round(float(value)))
88
+ except (TypeError, ValueError):
89
+ return None
90
+
91
+ return {
92
+ "ftp": _int_val("ftp"),
93
+ "lthr": _int_val("lthr"),
94
+ "mhr": _int_val("mhr"),
95
+ # App profile weight comes from User/UserInfo, not UserIntervalInfo.member.
96
+ "weight": _whole_kg(weight),
97
+ }
98
+
99
+
100
+ def _threshold_fingerprint(
101
+ settings: SportSettings,
102
+ weight: float | None = None,
103
+ ) -> str:
104
+ ftp = int(round(settings.ftp or 0))
105
+ mhr = int(round(settings.max_hr or 0))
106
+ lthr = int(round(settings.lthr)) if settings.lthr is not None else ""
107
+ weight_part = _whole_kg(weight) if weight is not None else ""
108
+ return f"{ftp}|{lthr}|{mhr}|{weight_part}"
109
+
110
+
111
+ def _intervals_threshold_values(
112
+ settings: SportSettings,
113
+ weight: float | None = None,
114
+ ) -> dict[str, int | None]:
115
+ return {
116
+ "ftp": int(round(settings.ftp or 0)),
117
+ "lthr": int(round(settings.lthr)) if settings.lthr is not None else None,
118
+ "mhr": int(round(settings.max_hr or 0)),
119
+ "weight": _whole_kg(weight),
120
+ }
121
+
122
+
123
+ def compare_profile_thresholds(
124
+ current: dict[str, Any],
125
+ settings: SportSettings,
126
+ *,
127
+ weight: float | None = None,
128
+ current_weight: float | None = None,
129
+ ) -> ProfileThresholdStatus:
130
+ """Return whether FTP, LTHR, max HR, or weight would change on sync."""
131
+ desired = apply_intervals_settings(current, settings)
132
+ member = current.get("member")
133
+ desired_member = desired.get("member")
134
+ current_vals = _threshold_values(
135
+ member if isinstance(member, dict) else {},
136
+ weight=current_weight,
137
+ )
138
+ desired_vals = _threshold_values(
139
+ desired_member if isinstance(desired_member, dict) else {},
140
+ weight=weight,
141
+ )
142
+
143
+ keys_to_compare = ["ftp", "mhr"]
144
+ if settings.lthr is not None:
145
+ keys_to_compare.append("lthr")
146
+ if weight is not None and _whole_kg(weight) is not None:
147
+ keys_to_compare.append("weight")
148
+
149
+ differences: list[str] = []
150
+ for key in keys_to_compare:
151
+ if current_vals.get(key) != desired_vals.get(key):
152
+ label = _THRESHOLD_LABELS[key]
153
+ differences.append(
154
+ f"{label}: iGPSPORT {current_vals.get(key)} "
155
+ f"→ intervals.icu {desired_vals.get(key)}"
156
+ )
157
+
158
+ return ProfileThresholdStatus(
159
+ needs_sync=bool(differences),
160
+ differences=differences,
161
+ intervals_fingerprint=_threshold_fingerprint(settings, weight),
162
+ intervals=_intervals_threshold_values(settings, weight),
163
+ igpsport=current_vals,
164
+ )
165
+
166
+
167
+ def _validate_sport_settings(settings: SportSettings) -> None:
168
+ if settings.ftp is None or settings.ftp <= 0:
169
+ raise SyncError("intervals.icu sport settings missing FTP")
170
+ if settings.max_hr is None or settings.max_hr <= 0:
171
+ raise SyncError("intervals.icu sport settings missing max HR")
172
+ if not settings.power_zones:
173
+ raise SyncError("intervals.icu sport settings missing power zones")
174
+ if not settings.hr_zones:
175
+ raise SyncError("intervals.icu sport settings missing HR zones")
176
+
177
+
178
+ def apply_intervals_settings(
179
+ body: dict[str, Any],
180
+ settings: SportSettings,
181
+ ) -> dict[str, Any]:
182
+ """Return a copy of the iGPSPORT payload with thresholds and zones updated.
183
+
184
+ Weight is updated separately via User/UpdatePersonalUserInfo — UpdatePersonalIntervalInfo
185
+ ignores member.weight.
186
+ """
187
+ updated = copy.deepcopy(body)
188
+ member = updated.get("member")
189
+ if not isinstance(member, dict):
190
+ raise SyncError("iGPSPORT profile payload has no member block")
191
+
192
+ ftp = int(round(settings.ftp or 0))
193
+ max_hr = int(round(settings.max_hr or 0))
194
+ lthr = int(round(settings.lthr or 0)) if settings.lthr is not None else member.get("lthr")
195
+
196
+ member["ftp"] = ftp
197
+ member["mhr"] = max_hr
198
+ if lthr is not None:
199
+ member["lthr"] = lthr
200
+ member["heartRateComputeMode"] = HEART_RATE_COMPUTE_MODE_MAX_HR
201
+
202
+ power = updated.get("power")
203
+ if isinstance(power, list) and power:
204
+ updated["power"] = map_power_zones(settings.power_zones, float(ftp), power)
205
+
206
+ heart_rate = updated.get("heartRate")
207
+ if isinstance(heart_rate, list) and heart_rate:
208
+ updated["heartRate"] = map_hr_zones(settings.hr_zones, float(max_hr), heart_rate)
209
+
210
+ return updated
211
+
212
+
213
+ def _report_summary(
214
+ report: Progress,
215
+ label: str,
216
+ body: dict[str, Any],
217
+ *,
218
+ weight: float | None = None,
219
+ ) -> None:
220
+ member = body.get("member") if isinstance(body.get("member"), dict) else {}
221
+ power = body.get("power") if isinstance(body.get("power"), list) else []
222
+ heart_rate = body.get("heartRate") if isinstance(body.get("heartRate"), list) else []
223
+ report(f"{label}:")
224
+ parts = [
225
+ f"{key}={member[key]}"
226
+ for key in ("ftp", "mhr", "lthr", "heartRateComputeMode", "quietHeartRate")
227
+ if key in member
228
+ ]
229
+ if weight is not None:
230
+ parts.append(f"weight={weight}")
231
+ report(" member: " + ", ".join(parts))
232
+ report(f" power: {zone_range_summary(power)}")
233
+ report(f" heartRate: {zone_range_summary(heart_rate)}")
234
+
235
+
236
+ def sync_profile_zones(
237
+ config: ProfileSyncConfig,
238
+ progress: Progress | None = None,
239
+ ) -> ProfileSyncResult:
240
+ """Fetch intervals.icu sport settings and push them to iGPSPORT."""
241
+ report = progress or _noop
242
+
243
+ session = requests.Session()
244
+ region = resolve_region(config.igp_region)
245
+ report("Logging in to iGPSPORT…")
246
+ try:
247
+ auth_headers = login(session, config.igp_user, config.igp_password, region)
248
+ except Exception as exc:
249
+ raise SyncError(str(exc)) from exc
250
+
251
+ member_id = member_id_from_token(auth_headers)
252
+ headers = mobile_headers(auth_headers, member_id, region)
253
+
254
+ report("Fetching sport settings from intervals.icu…")
255
+ try:
256
+ settings = intervals_icu.fetch_sport_settings(
257
+ config.intervals_api_key,
258
+ config.sport,
259
+ http=session,
260
+ )
261
+ except requests.RequestException as exc:
262
+ raise SyncError(f"Could not fetch intervals.icu sport settings: {exc}") from exc
263
+
264
+ _validate_sport_settings(settings)
265
+
266
+ report("Fetching athlete weight from intervals.icu…")
267
+ try:
268
+ weight = intervals_icu.fetch_athlete_weight(
269
+ config.intervals_api_key,
270
+ http=session,
271
+ )
272
+ except requests.RequestException as exc:
273
+ raise SyncError(f"Could not fetch intervals.icu athlete weight: {exc}") from exc
274
+
275
+ report("Fetching iGPSPORT profile…")
276
+ try:
277
+ current = fetch_personal_interval_info(session, headers, region)
278
+ user_info = fetch_user_info(session, headers, region)
279
+ except RuntimeError as exc:
280
+ raise SyncError(str(exc)) from exc
281
+
282
+ weight_before = user_info.get("weight")
283
+ target_weight = _whole_kg(weight)
284
+
285
+ updated = apply_intervals_settings(current, settings)
286
+ _report_summary(report, "Before", current, weight=weight_before)
287
+ _report_summary(
288
+ report,
289
+ "After",
290
+ updated,
291
+ weight=float(target_weight) if target_weight is not None else weight_before,
292
+ )
293
+
294
+ report("Updating iGPSPORT profile…")
295
+ try:
296
+ update_personal_interval_info(session, headers, updated, region)
297
+ except RuntimeError as exc:
298
+ raise SyncError(str(exc)) from exc
299
+
300
+ if target_weight is not None and _whole_kg(weight_before) != target_weight:
301
+ saved_city_id = user_info.get("cityId")
302
+ report("Updating iGPSPORT weight…")
303
+ try:
304
+ update_user_weight(session, headers, target_weight, region)
305
+ except RuntimeError as exc:
306
+ raise SyncError(str(exc)) from exc
307
+ user_info_after_weight = fetch_user_info(session, headers, region)
308
+ city_after = user_info_after_weight.get("cityId")
309
+ if saved_city_id not in (None, 0, "0") and city_after in (None, 0, "", "0"):
310
+ report(
311
+ "Note: iGPSPORT cleared profile location while updating weight; "
312
+ "set location again in the app."
313
+ )
314
+
315
+ report("Verifying iGPSPORT profile…")
316
+ try:
317
+ after = fetch_personal_interval_info(session, headers, region)
318
+ user_info_after = fetch_user_info(session, headers, region)
319
+ except RuntimeError as exc:
320
+ raise SyncError(str(exc)) from exc
321
+
322
+ weight_after = user_info_after.get("weight")
323
+
324
+ _report_summary(report, "Read-back", after, weight=weight_after)
325
+ return ProfileSyncResult(
326
+ before=current,
327
+ after=after,
328
+ weight_before=float(weight_before) if weight_before is not None else None,
329
+ weight_after=float(weight_after) if weight_after is not None else None,
330
+ )
331
+
332
+
333
+ def fetch_profile_threshold_status(config: ProfileSyncConfig) -> ProfileThresholdStatus:
334
+ """Compare iGPSPORT profile thresholds with intervals.icu sport settings."""
335
+ session = requests.Session()
336
+ region = resolve_region(config.igp_region)
337
+ try:
338
+ auth_headers = login(session, config.igp_user, config.igp_password, region)
339
+ except Exception as exc:
340
+ raise SyncError(str(exc)) from exc
341
+
342
+ member_id = member_id_from_token(auth_headers)
343
+ headers = mobile_headers(auth_headers, member_id, region)
344
+
345
+ try:
346
+ settings = intervals_icu.fetch_sport_settings(
347
+ config.intervals_api_key,
348
+ config.sport,
349
+ http=session,
350
+ )
351
+ except requests.RequestException as exc:
352
+ raise SyncError(f"Could not fetch intervals.icu sport settings: {exc}") from exc
353
+
354
+ _validate_sport_settings(settings)
355
+
356
+ try:
357
+ weight = intervals_icu.fetch_athlete_weight(
358
+ config.intervals_api_key,
359
+ http=session,
360
+ )
361
+ except requests.RequestException as exc:
362
+ raise SyncError(f"Could not fetch intervals.icu athlete weight: {exc}") from exc
363
+
364
+ try:
365
+ current = fetch_personal_interval_info(session, headers, region)
366
+ user_info = fetch_user_info(session, headers, region)
367
+ except RuntimeError as exc:
368
+ raise SyncError(str(exc)) from exc
369
+
370
+ return compare_profile_thresholds(
371
+ current,
372
+ settings,
373
+ weight=weight,
374
+ current_weight=user_info.get("weight"),
375
+ )
376
+
377
+
378
+ def result_payload(result: ProfileSyncResult, *, ok: bool, error: str | None = None) -> dict[str, Any]:
379
+ payload: dict[str, Any] = {
380
+ "ok": ok,
381
+ "source": "igpsport",
382
+ }
383
+ if result.before is not None:
384
+ payload["before"] = profile_summary(result.before, weight=result.weight_before)
385
+ if result.after is not None:
386
+ summary = profile_summary(result.after, weight=result.weight_after)
387
+ payload.update(summary)
388
+ payload["after"] = summary
389
+ if error is not None:
390
+ payload["error"] = error
391
+ return payload