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,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
|