diff --git a/CLAUDE.md b/CLAUDE.md index 7589405..5bec388 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ src/intervalssync/ - **`intervals_icu.py`** — shared upload, skip-existing (`external_id`), sport PUT, calendar workout fetch, sport-settings lookup (`fetch_sport_settings`, `max_hr`). - **`igpsport/core.py`** — `sync(SyncConfig, progress)`: login → list → FIT URL → download → upload. `external_id`: `igpsport_{ride_id}`. -- **`igpsport/profile_sync.py`** — `sync_profile_zones`: intervals.icu sport settings → iGPSPORT profile thresholds/zones. +- **`igpsport/profile_sync.py`** — `sync_profile_zones`: intervals.icu sport settings + athlete weight → iGPSPORT profile thresholds/zones/weight. - **`igpsport/workout.py`** — planned workouts intervals.icu → iGPSPORT. - **`bryton/ddp.py`** — Meteor DDP login + `activityList` subscription. - **`bryton/api.py`** — `GET https://m3.brytonactive.com/api/activity?id=…` (FIT download; Android app API). diff --git a/README.md b/README.md index 5b9b485..88b96a7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ - Optionally deletes the local `.fit` files after a successful upload - Stores your credentials in the **OS secure vault** (Windows Credential Manager / macOS Keychain / Android Keystore), never in a file - Lets you know when a newer version is available -- **Sync zones to iGPSPORT** — push FTP, LTHR, max HR, and power/HR zones from intervals.icu into your iGPSPORT profile (Settings → iGPSPORT profile, or `intervalssync sync-zones`; the app prompts on launch when thresholds differ) +- **Sync zones to iGPSPORT** — push FTP, LTHR, max HR, weight, and power/HR zones from intervals.icu into your iGPSPORT profile (Settings → iGPSPORT profile, or `intervalssync sync-zones`; the app prompts on launch when thresholds differ) - **Headless CLI** — activity sync, workout upload, and iGPSPORT zone/threshold sync from the terminal, with JSON output and exit codes for automation and AI agents (see [CLI & automation](#cli--automation-ai-agents)) - **China iGPSPORT region** — accounts on [app.igpsport.cn](https://app.igpsport.cn/login) use a separate API; choose **China** in Settings (or `INTERVALSSYNC_IGPSPORT_REGION=china` in the CLI) diff --git a/docs/AGENT.md b/docs/AGENT.md index a413c14..691bf84 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -100,8 +100,8 @@ uv run intervalssync sync-zones --env-file .env --json ``` - **iGPSPORT-only** (no `--source` flag) -- Reads FTP, LTHR, max HR, power zones, and HR zones from intervals.icu `sport-settings/Ride` (override with `--sport`) -- Writes them to iGPSPORT via the mobile `UserIntervalInfo` API +- Reads FTP, LTHR, max HR, power zones, and HR zones from intervals.icu `sport-settings/Ride` (override with `--sport`), plus athlete weight (`icu_weight`) rounded to whole kg +- Writes thresholds/zones via `UpdatePersonalIntervalInfo`, and weight via `User/UpdatePersonalUserInfo` (the endpoint the app profile editor uses) - Progress on **stderr**; result JSON on **stdout**; same exit codes as other commands Success example: @@ -114,18 +114,21 @@ Success example: "ftp": 240, "lthr": 153, "mhr": 193, + "weight": 79.0, "power_zones": "0-132 | 132-180 | …", "hr_zones": "0-120 | …" }, "ftp": 242, "lthr": 176, "mhr": 193, + "weight": 76.0, "power_zones": "0-133 | 133-182 | …", "hr_zones": "0-120 | 120-146 | …", "after": { "ftp": 242, "lthr": 176, "mhr": 193, + "weight": 76.0, "power_zones": "0-133 | 133-182 | …", "hr_zones": "0-120 | 120-146 | …" } @@ -173,4 +176,4 @@ Same as GUI **Upload to iGPSPORT** / **Upload to Bryton** — intervals.icu cale ### Zone sync -Reads FTP, LTHR, max HR, and power/HR zones from intervals.icu sport settings and writes them to the iGPSPORT profile (CLI only for now). +Reads FTP, LTHR, max HR, power/HR zones, and weight from intervals.icu and writes them to the iGPSPORT profile (CLI only for now). diff --git a/src/intervalssync/cli/main.py b/src/intervalssync/cli/main.py index 54fe83a..1618429 100644 --- a/src/intervalssync/cli/main.py +++ b/src/intervalssync/cli/main.py @@ -608,7 +608,7 @@ def _build_parser() -> argparse.ArgumentParser: sync_zones_parser = subparsers.add_parser( "sync-zones", parents=[common], - help="Push FTP, LTHR, max HR, and zones from intervals.icu to iGPSPORT.", + help="Push FTP, LTHR, max HR, weight, and zones from intervals.icu to iGPSPORT.", ) sync_zones_parser.add_argument( "--sport", diff --git a/src/intervalssync/gui/config.py b/src/intervalssync/gui/config.py index bea9ac9..d46a6f2 100644 --- a/src/intervalssync/gui/config.py +++ b/src/intervalssync/gui/config.py @@ -59,7 +59,7 @@ class AppConfig: workout_days_ahead: int = 1 # intervals.icu threshold fingerprint the user declined to sync; cleared after sync. profile_sync_declined_fingerprint: str = "" - # Prompt on launch when FTP, LTHR, or max HR differ from intervals.icu. + # Prompt on launch when FTP, LTHR, max HR, or weight differ from intervals.icu. profile_sync_check_on_launch: bool = True # Lifetime sync stats (GUI gamification). lifetime_activities_uploaded: int = 0 diff --git a/src/intervalssync/gui/profile_sync_ui.py b/src/intervalssync/gui/profile_sync_ui.py index 4a25333..7e15e58 100644 --- a/src/intervalssync/gui/profile_sync_ui.py +++ b/src/intervalssync/gui/profile_sync_ui.py @@ -109,6 +109,8 @@ def _sync_success_message(result: ProfileSyncResult) -> str: for key, label in (("ftp", "FTP"), ("lthr", "LTHR"), ("mhr", "max HR")): if key in member: parts.append(f"{label} {member[key]}") + if result.weight_after is not None: + parts.append(f"weight {result.weight_after}") if parts: return "iGPSPORT profile updated — " + ", ".join(parts) + "." return "iGPSPORT profile updated." @@ -190,13 +192,13 @@ async def update_now(_: ft.ControlEvent) -> None: spacing=theme.SPACE_SM, controls=[ ft.Text( - "Your iGPSPORT thresholds differ from intervals.icu:", + "Your iGPSPORT profile differs from intervals.icu:", size=13, color=colors["text_muted"], ), *difference_lines, ft.Text( - "Power and heart-rate zones will be updated too.", + "Power/HR zones and weight will be updated too.", size=12, color=colors["text_muted"], ), diff --git a/src/intervalssync/gui/settings_view.py b/src/intervalssync/gui/settings_view.py index fc0bb82..119bf29 100644 --- a/src/intervalssync/gui/settings_view.py +++ b/src/intervalssync/gui/settings_view.py @@ -410,7 +410,7 @@ async def on_profile_tile_change(e: ft.ControlEvent) -> None: profile_sync_options = ft.ExpansionTile( title=ft.Text("iGPSPORT profile", weight=ft.FontWeight.W_500), subtitle=ft.Text( - "FTP, LTHR, max HR, and zones from intervals.icu", + "FTP, LTHR, max HR, weight, and zones from intervals.icu", size=12, color=colors["text_muted"], ), diff --git a/src/intervalssync/igpsport/interval_info.py b/src/intervalssync/igpsport/interval_info.py index 5e15d44..3b629e1 100644 --- a/src/intervalssync/igpsport/interval_info.py +++ b/src/intervalssync/igpsport/interval_info.py @@ -143,13 +143,117 @@ def update_personal_interval_info( return result if isinstance(result, dict) else {} +def fetch_user_info( + session: requests.Session, + headers: dict[str, str], + region: IgpRegionConfig | str | None = None, +) -> dict[str, Any]: + """GET User/UserInfo (profile fields including weight shown in the app).""" + cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region) + get_headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} + try: + resp = session.get(cfg.user_info_url, headers=get_headers, timeout=30) + except requests.RequestException as exc: + raise RuntimeError(f"GET UserInfo failed: {exc}") from exc + + try: + body = resp.json() + except ValueError as exc: + raise RuntimeError( + f"GET UserInfo: HTTP {resp.status_code}, non-JSON body" + ) from exc + + if not isinstance(body, dict) or body.get("code") not in (0, None): + message = body.get("message") if isinstance(body, dict) else "unknown error" + raise RuntimeError(f"GET UserInfo failed: {message}") + + data = body.get("data") + if not isinstance(data, dict): + raise RuntimeError("GET UserInfo: missing data object") + return data + + +def build_personal_user_info_payload( + user_info: dict[str, Any], + weight_kg: int, +) -> dict[str, Any]: + """Build the UpdatePersonalUserInfo body the iGPSPORT app sends. + + Captured from the Android profile editor: a 6-field personal payload with + ``areaId`` (UserInfo ``cityId``) and ``gender`` (UserInfo ``sex``). Using + ``UpdateUserInfo`` with a full UserInfo merge clears location. + """ + area_id = user_info.get("cityId") + if area_id in (None, "", 0, "0"): + area_id = user_info.get("areaId") + gender = user_info.get("gender") + if gender is None: + gender = user_info.get("sex") + + return { + "areaId": int(area_id) if area_id not in (None, "") else 0, + "birthDate": user_info.get("birthDate") or "", + "gender": int(gender) if gender is not None else 0, + "height": int(user_info["height"]) if user_info.get("height") is not None else 0, + "nickName": user_info.get("nickName") or "", + # App sends a float (e.g. 76.0); whole-kg rounding happens before call. + "weight": float(int(weight_kg)), + } + + +def update_user_weight( + session: requests.Session, + headers: dict[str, str], + weight_kg: int, + region: IgpRegionConfig | str | None = None, +) -> dict[str, Any]: + """POST User/UpdatePersonalUserInfo with whole-kg weight (app profile weight). + + Matches the Android editor: personal fields only, with ``areaId`` so location + is preserved. ``UpdateUserInfo`` clears ``cityId`` even when present in body. + """ + cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region) + current = fetch_user_info(session, headers, region) + payload = build_personal_user_info_payload(current, weight_kg) + + try: + resp = session.post( + cfg.update_personal_user_info_url, + headers=headers, + json=payload, + timeout=30, + ) + except requests.RequestException as exc: + raise RuntimeError(f"POST UpdatePersonalUserInfo failed: {exc}") from exc + + try: + result = resp.json() + except ValueError as exc: + raise RuntimeError( + f"POST UpdatePersonalUserInfo: HTTP {resp.status_code}, non-JSON body" + ) from exc + + if not resp.ok: + raise RuntimeError(f"POST UpdatePersonalUserInfo: HTTP {resp.status_code}") + + if isinstance(result, dict) and result.get("code") not in (0, None): + message = result.get("message") or "unknown error" + raise RuntimeError(f"POST UpdatePersonalUserInfo failed: {message}") + + return result if isinstance(result, dict) else {} + + def zone_range_summary(zones: list[dict[str, Any]]) -> str: if not zones: return "(none)" return " | ".join(f"{z.get('start')}-{z.get('end')}" for z in zones) -def profile_summary(body: dict[str, Any]) -> dict[str, Any]: +def profile_summary( + body: dict[str, Any], + *, + weight: float | None = None, +) -> dict[str, Any]: """Return a compact summary dict for CLI JSON output.""" member = body.get("member") if isinstance(body.get("member"), dict) else {} power = body.get("power") if isinstance(body.get("power"), list) else [] @@ -158,6 +262,7 @@ def profile_summary(body: dict[str, Any]) -> dict[str, Any]: "ftp": member.get("ftp"), "lthr": member.get("lthr"), "mhr": member.get("mhr"), + "weight": weight if weight is not None else member.get("weight"), "heart_rate_compute_mode": member.get("heartRateComputeMode"), "power_zones": zone_range_summary(power), "hr_zones": zone_range_summary(heart_rate), diff --git a/src/intervalssync/igpsport/profile_sync.py b/src/intervalssync/igpsport/profile_sync.py index 898a73b..0b7d0b1 100644 --- a/src/intervalssync/igpsport/profile_sync.py +++ b/src/intervalssync/igpsport/profile_sync.py @@ -1,4 +1,4 @@ -"""Sync thresholds and zones from intervals.icu to iGPSPORT profile.""" +"""Sync thresholds, zones, and weight from intervals.icu to iGPSPORT profile.""" from __future__ import annotations @@ -15,10 +15,12 @@ from .interval_info import ( HEART_RATE_COMPUTE_MODE_MAX_HR, fetch_personal_interval_info, + fetch_user_info, mobile_headers, member_id_from_token, profile_summary, update_personal_interval_info, + update_user_weight, zone_range_summary, ) from .zone_map import map_hr_zones, map_power_zones @@ -43,6 +45,8 @@ class ProfileSyncConfig: class ProfileSyncResult: before: dict[str, Any] | None after: dict[str, Any] | None + weight_before: float | None = None + weight_after: float | None = None @dataclass @@ -54,10 +58,27 @@ class ProfileThresholdStatus: igpsport: dict[str, int | None] -_THRESHOLD_LABELS = {"ftp": "FTP", "lthr": "LTHR", "mhr": "max HR"} +_THRESHOLD_LABELS = {"ftp": "FTP", "lthr": "LTHR", "mhr": "max HR", "weight": "Weight"} -def _threshold_values(member: dict[str, Any]) -> dict[str, int | None]: +def _whole_kg(value: Any) -> int | None: + """Round kg to a whole number; iGPSPORT only accepts integer kilograms.""" + if value is None: + return None + try: + kg = int(round(float(value))) + except (TypeError, ValueError): + return None + if kg <= 0: + return None + return kg + + +def _threshold_values( + member: dict[str, Any], + *, + weight: float | None = None, +) -> dict[str, int | None]: def _int_val(key: str) -> int | None: value = member.get(key) if value is None: @@ -71,38 +92,59 @@ def _int_val(key: str) -> int | None: "ftp": _int_val("ftp"), "lthr": _int_val("lthr"), "mhr": _int_val("mhr"), + # App profile weight comes from User/UserInfo, not UserIntervalInfo.member. + "weight": _whole_kg(weight), } -def _threshold_fingerprint(settings: SportSettings) -> str: +def _threshold_fingerprint( + settings: SportSettings, + weight: float | None = None, +) -> str: ftp = int(round(settings.ftp or 0)) mhr = int(round(settings.max_hr or 0)) lthr = int(round(settings.lthr)) if settings.lthr is not None else "" - return f"{ftp}|{lthr}|{mhr}" + weight_part = _whole_kg(weight) if weight is not None else "" + return f"{ftp}|{lthr}|{mhr}|{weight_part}" -def _intervals_threshold_values(settings: SportSettings) -> dict[str, int | None]: +def _intervals_threshold_values( + settings: SportSettings, + weight: float | None = None, +) -> dict[str, int | None]: return { "ftp": int(round(settings.ftp or 0)), "lthr": int(round(settings.lthr)) if settings.lthr is not None else None, "mhr": int(round(settings.max_hr or 0)), + "weight": _whole_kg(weight), } def compare_profile_thresholds( current: dict[str, Any], settings: SportSettings, + *, + weight: float | None = None, + current_weight: float | None = None, ) -> ProfileThresholdStatus: - """Return whether FTP, LTHR, or max HR would change on sync.""" + """Return whether FTP, LTHR, max HR, or weight would change on sync.""" desired = apply_intervals_settings(current, settings) member = current.get("member") desired_member = desired.get("member") - current_vals = _threshold_values(member if isinstance(member, dict) else {}) - desired_vals = _threshold_values(desired_member if isinstance(desired_member, dict) else {}) + current_vals = _threshold_values( + member if isinstance(member, dict) else {}, + weight=current_weight, + ) + desired_vals = _threshold_values( + desired_member if isinstance(desired_member, dict) else {}, + weight=weight, + ) keys_to_compare = ["ftp", "mhr"] if settings.lthr is not None: keys_to_compare.append("lthr") + if weight is not None and _whole_kg(weight) is not None: + keys_to_compare.append("weight") differences: list[str] = [] for key in keys_to_compare: @@ -116,8 +158,8 @@ def compare_profile_thresholds( return ProfileThresholdStatus( needs_sync=bool(differences), differences=differences, - intervals_fingerprint=_threshold_fingerprint(settings), - intervals=_intervals_threshold_values(settings), + intervals_fingerprint=_threshold_fingerprint(settings, weight), + intervals=_intervals_threshold_values(settings, weight), igpsport=current_vals, ) @@ -137,7 +179,11 @@ def apply_intervals_settings( body: dict[str, Any], settings: SportSettings, ) -> dict[str, Any]: - """Return a copy of the iGPSPORT payload with thresholds and zones updated.""" + """Return a copy of the iGPSPORT payload with thresholds and zones updated. + + Weight is updated separately via User/UpdatePersonalUserInfo — UpdatePersonalIntervalInfo + ignores member.weight. + """ updated = copy.deepcopy(body) member = updated.get("member") if not isinstance(member, dict): @@ -164,19 +210,25 @@ def apply_intervals_settings( return updated -def _report_summary(report: Progress, label: str, body: dict[str, Any]) -> None: +def _report_summary( + report: Progress, + label: str, + body: dict[str, Any], + *, + weight: float | None = None, +) -> None: member = body.get("member") if isinstance(body.get("member"), dict) else {} power = body.get("power") if isinstance(body.get("power"), list) else [] heart_rate = body.get("heartRate") if isinstance(body.get("heartRate"), list) else [] report(f"{label}:") - report( - " member: " - + ", ".join( - f"{key}={member[key]}" - for key in ("ftp", "mhr", "lthr", "heartRateComputeMode", "quietHeartRate") - if key in member - ) - ) + parts = [ + f"{key}={member[key]}" + for key in ("ftp", "mhr", "lthr", "heartRateComputeMode", "quietHeartRate") + if key in member + ] + if weight is not None: + parts.append(f"weight={weight}") + report(" member: " + ", ".join(parts)) report(f" power: {zone_range_summary(power)}") report(f" heartRate: {zone_range_summary(heart_rate)}") @@ -211,15 +263,33 @@ def sync_profile_zones( _validate_sport_settings(settings) + report("Fetching athlete weight from intervals.icu…") + try: + weight = intervals_icu.fetch_athlete_weight( + config.intervals_api_key, + http=session, + ) + except requests.RequestException as exc: + raise SyncError(f"Could not fetch intervals.icu athlete weight: {exc}") from exc + report("Fetching iGPSPORT profile…") try: current = fetch_personal_interval_info(session, headers, region) + user_info = fetch_user_info(session, headers, region) except RuntimeError as exc: raise SyncError(str(exc)) from exc + weight_before = user_info.get("weight") + target_weight = _whole_kg(weight) + updated = apply_intervals_settings(current, settings) - _report_summary(report, "Before", current) - _report_summary(report, "After", updated) + _report_summary(report, "Before", current, weight=weight_before) + _report_summary( + report, + "After", + updated, + weight=float(target_weight) if target_weight is not None else weight_before, + ) report("Updating iGPSPORT profile…") try: @@ -227,14 +297,37 @@ def sync_profile_zones( except RuntimeError as exc: raise SyncError(str(exc)) from exc + if target_weight is not None and _whole_kg(weight_before) != target_weight: + saved_city_id = user_info.get("cityId") + report("Updating iGPSPORT weight…") + try: + update_user_weight(session, headers, target_weight, region) + except RuntimeError as exc: + raise SyncError(str(exc)) from exc + user_info_after_weight = fetch_user_info(session, headers, region) + city_after = user_info_after_weight.get("cityId") + if saved_city_id not in (None, 0, "0") and city_after in (None, 0, "", "0"): + report( + "Note: iGPSPORT cleared profile location while updating weight; " + "set location again in the app." + ) + report("Verifying iGPSPORT profile…") try: - after = fetch_personal_interval_info(session, headers) + after = fetch_personal_interval_info(session, headers, region) + user_info_after = fetch_user_info(session, headers, region) except RuntimeError as exc: raise SyncError(str(exc)) from exc - _report_summary(report, "Read-back", after) - return ProfileSyncResult(before=current, after=after) + weight_after = user_info_after.get("weight") + + _report_summary(report, "Read-back", after, weight=weight_after) + return ProfileSyncResult( + before=current, + after=after, + weight_before=float(weight_before) if weight_before is not None else None, + weight_after=float(weight_after) if weight_after is not None else None, + ) def fetch_profile_threshold_status(config: ProfileSyncConfig) -> ProfileThresholdStatus: @@ -260,12 +353,26 @@ def fetch_profile_threshold_status(config: ProfileSyncConfig) -> ProfileThreshol _validate_sport_settings(settings) + try: + weight = intervals_icu.fetch_athlete_weight( + config.intervals_api_key, + http=session, + ) + except requests.RequestException as exc: + raise SyncError(f"Could not fetch intervals.icu athlete weight: {exc}") from exc + try: current = fetch_personal_interval_info(session, headers, region) + user_info = fetch_user_info(session, headers, region) except RuntimeError as exc: raise SyncError(str(exc)) from exc - return compare_profile_thresholds(current, settings) + return compare_profile_thresholds( + current, + settings, + weight=weight, + current_weight=user_info.get("weight"), + ) def result_payload(result: ProfileSyncResult, *, ok: bool, error: str | None = None) -> dict[str, Any]: @@ -274,9 +381,9 @@ def result_payload(result: ProfileSyncResult, *, ok: bool, error: str | None = N "source": "igpsport", } if result.before is not None: - payload["before"] = profile_summary(result.before) + payload["before"] = profile_summary(result.before, weight=result.weight_before) if result.after is not None: - summary = profile_summary(result.after) + summary = profile_summary(result.after, weight=result.weight_after) payload.update(summary) payload["after"] = summary if error is not None: diff --git a/src/intervalssync/igpsport/region.py b/src/intervalssync/igpsport/region.py index 680fc1b..4164d80 100644 --- a/src/intervalssync/igpsport/region.py +++ b/src/intervalssync/igpsport/region.py @@ -33,6 +33,15 @@ def get_interval_url(self) -> str: def update_interval_url(self) -> str: return f"{self.mobile_api_base}/User/UpdatePersonalIntervalInfo" + @property + def user_info_url(self) -> str: + return f"{self.mobile_api_base}/User/UserInfo" + + @property + def update_personal_user_info_url(self) -> str: + # App profile editor saves weight/height/etc here (not UpdateUserInfo). + return f"{self.mobile_api_base}/User/UpdatePersonalUserInfo" + INTERNATIONAL = IgpRegionConfig( name="international", diff --git a/src/intervalssync/intervals_icu.py b/src/intervalssync/intervals_icu.py index cbcdb38..d890b1b 100644 --- a/src/intervalssync/intervals_icu.py +++ b/src/intervalssync/intervals_icu.py @@ -17,6 +17,7 @@ INTERVALS_ACTIVITY_URL = "https://intervals.icu/api/v1/activity" INTERVALS_EVENTS_URL = "https://intervals.icu/api/v1/athlete/0/events" INTERVALS_SPORT_SETTINGS_URL = "https://intervals.icu/api/v1/athlete/0/sport-settings" +INTERVALS_ATHLETE_URL = "https://intervals.icu/api/v1/athlete/0" @dataclass @@ -175,3 +176,22 @@ def fetch_sport_settings_max_hr( ) -> float | None: """Return athlete max HR (bpm) from intervals.icu sport settings.""" return fetch_sport_settings(api_key, sport, http=http).max_hr + + +def fetch_athlete_weight( + api_key: str, + *, + http: requests.Session | None = None, +) -> float | None: + """Return athlete weight in kg from intervals.icu (`icu_weight`, else `weight`).""" + client = http or requests.Session() + resp = client.get( + INTERVALS_ATHLETE_URL, + auth=("API_KEY", api_key), + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + return None + return _num(data.get("icu_weight")) or _num(data.get("weight")) diff --git a/tests/test_intervals_icu.py b/tests/test_intervals_icu.py index 009b3c1..d2212ae 100644 --- a/tests/test_intervals_icu.py +++ b/tests/test_intervals_icu.py @@ -85,3 +85,35 @@ def test_fetch_sport_settings(monkeypatch): assert settings.max_hr == 193.0 assert settings.power_zones == [55.0, 75.0, 90.0, 105.0, 120.0, 999.0] assert settings.hr_zones == [120.0, 146.0, 166.0, 185.0, 193.0] + + +def test_fetch_athlete_weight_prefers_icu_weight(monkeypatch): + captured: dict = {} + + def fake_get(self, url, auth, timeout): + captured["url"] = url + captured["auth"] = auth + return FakeResponse(json_data={"icu_weight": 76.1, "weight": 70.0}) + + monkeypatch.setattr(intervals_icu.requests.Session, "get", fake_get) + assert intervals_icu.fetch_athlete_weight("api-key") == 76.1 + assert captured["url"].endswith("/athlete/0") + assert captured["auth"] == ("API_KEY", "api-key") + + +def test_fetch_athlete_weight_falls_back_to_weight(monkeypatch): + monkeypatch.setattr( + intervals_icu.requests.Session, + "get", + lambda self, *a, **k: FakeResponse(json_data={"weight": 72.5}), + ) + assert intervals_icu.fetch_athlete_weight("api-key") == 72.5 + + +def test_fetch_athlete_weight_missing(monkeypatch): + monkeypatch.setattr( + intervals_icu.requests.Session, + "get", + lambda self, *a, **k: FakeResponse(json_data={"name": "Athlete"}), + ) + assert intervals_icu.fetch_athlete_weight("api-key") is None diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 0d099d4..3ca5bb0 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -38,6 +38,7 @@ def test_apply_intervals_settings_updates_thresholds_and_zones(): assert updated["member"]["lthr"] == 176 assert updated["member"]["mhr"] == 193 assert updated["member"]["heartRateComputeMode"] == 0 + assert "weight" not in updated["member"] assert len(updated["power"]) == 7 assert updated["power"][0]["end"] == 133 assert updated["power"][-1]["end"] == 2500 @@ -66,10 +67,12 @@ def _ride_settings(**overrides: object) -> SportSettings: def test_compare_profile_thresholds_in_sync(): body = apply_intervals_settings(_igpsport_payload(), _ride_settings()) - status = compare_profile_thresholds(body, _ride_settings()) + status = compare_profile_thresholds( + body, _ride_settings(), weight=79.0, current_weight=79.0 + ) assert status.needs_sync is False assert status.differences == [] - assert status.intervals_fingerprint == "242|176|193" + assert status.intervals_fingerprint == "242|176|193|79" def test_compare_profile_thresholds_ftp_mismatch(): @@ -77,6 +80,7 @@ def test_compare_profile_thresholds_ftp_mismatch(): assert status.needs_sync is True assert len(status.differences) == 3 assert status.differences[0].startswith("FTP:") + assert status.intervals_fingerprint == "242|176|193|" def test_compare_profile_thresholds_lthr_mismatch_only(): @@ -97,6 +101,124 @@ def test_compare_profile_thresholds_mhr_mismatch_only(): assert status.differences == ["max HR: iGPSPORT 190 → intervals.icu 193"] +def test_build_personal_user_info_payload_maps_city_and_sex(): + from intervalssync.igpsport.interval_info import build_personal_user_info_payload + + payload = build_personal_user_info_payload( + { + "cityId": 103172, + "cityName": "Braga", + "sex": 1, + "height": 179, + "nickName": "Jorge Silva", + "birthDate": "1998-08-17", + "weight": 79.0, + "ftp": 242, + "avatar": "https://example.com/a.png", + }, + 76, + ) + assert payload == { + "areaId": 103172, + "birthDate": "1998-08-17", + "gender": 1, + "height": 179, + "nickName": "Jorge Silva", + "weight": 76.0, + } + + +def test_build_personal_user_info_payload_prefers_area_id_and_gender(): + from intervalssync.igpsport.interval_info import build_personal_user_info_payload + + payload = build_personal_user_info_payload( + { + "cityId": 0, + "areaId": 1101172, + "gender": 1, + "sex": 0, + "height": 179, + "nickName": "Jorge", + "birthDate": "1998-08-17", + }, + 76, + ) + assert payload["areaId"] == 1101172 + assert payload["gender"] == 1 + assert payload["weight"] == 76.0 + + +def test_update_user_weight_posts_personal_user_info(monkeypatch): + from intervalssync.igpsport import interval_info + + posted: dict = {} + + class FakeResp: + ok = True + status_code = 200 + + def json(self): + return {"code": 0, "data": True} + + class FakeSession: + def post(self, url, headers=None, json=None, timeout=None): + posted["url"] = url + posted["json"] = json + return FakeResp() + + monkeypatch.setattr( + interval_info, + "fetch_user_info", + lambda *a, **k: { + "cityId": 103172, + "cityName": "Braga", + "sex": 1, + "height": 179, + "nickName": "Jorge Silva", + "birthDate": "1998-08-17", + "weight": 79.0, + }, + ) + + result = interval_info.update_user_weight(FakeSession(), {"Authorization": "Bearer x"}, 76) + assert result["code"] == 0 + assert posted["url"].endswith("/User/UpdatePersonalUserInfo") + assert posted["json"] == { + "areaId": 103172, + "birthDate": "1998-08-17", + "gender": 1, + "height": 179, + "nickName": "Jorge Silva", + "weight": 76.0, + } + + body = apply_intervals_settings(_igpsport_payload(), _ride_settings()) + status = compare_profile_thresholds( + body, _ride_settings(), weight=76.1, current_weight=79.0 + ) + assert status.needs_sync is True + assert status.differences == ["Weight: iGPSPORT 79 → intervals.icu 76"] + assert status.intervals_fingerprint == "242|176|193|76" + + +def test_compare_profile_thresholds_weight_fractional_matches_after_round(): + body = apply_intervals_settings(_igpsport_payload(), _ride_settings()) + status = compare_profile_thresholds( + body, _ride_settings(), weight=76.4, current_weight=76.0 + ) + assert status.needs_sync is False + assert status.intervals_fingerprint == "242|176|193|76" + + +def test_compare_profile_thresholds_skips_weight_when_intervals_missing(): + body = apply_intervals_settings(_igpsport_payload(), _ride_settings()) + status = compare_profile_thresholds( + body, _ride_settings(), weight=None, current_weight=79.0 + ) + assert status.needs_sync is False + assert status.intervals_fingerprint == "242|176|193|" + + def test_compare_profile_thresholds_skips_lthr_when_intervals_missing(): body = _igpsport_payload() body["member"]["ftp"] = 242 @@ -104,7 +226,7 @@ def test_compare_profile_thresholds_skips_lthr_when_intervals_missing(): settings = _ride_settings(lthr=None) status = compare_profile_thresholds(body, settings) assert status.needs_sync is False - assert status.intervals_fingerprint == "242||193" + assert status.intervals_fingerprint == "242||193|" def test_fetch_profile_threshold_status(monkeypatch): @@ -120,23 +242,35 @@ def test_fetch_profile_threshold_status(monkeypatch): "fetch_sport_settings", lambda *a, **k: settings, ) + monkeypatch.setattr( + profile_sync.intervals_icu, + "fetch_athlete_weight", + lambda *a, **k: 76.1, + ) monkeypatch.setattr( profile_sync, "fetch_personal_interval_info", lambda *a, **k: current, ) + monkeypatch.setattr( + profile_sync, + "fetch_user_info", + lambda *a, **k: {"weight": 79.0}, + ) status = profile_sync.fetch_profile_threshold_status( ProfileSyncConfig("user", "pass", "api-key"), ) assert status.needs_sync is True assert any(diff.startswith("FTP:") for diff in status.differences) + assert any(diff.startswith("Weight:") for diff in status.differences) def test_sync_profile_zones_end_to_end(monkeypatch): from intervalssync.igpsport import profile_sync posted: dict = {} + weight_posts: list[int] = [] settings = SportSettings( ftp=242, @@ -154,19 +288,35 @@ def test_sync_profile_zones_end_to_end(monkeypatch): "fetch_sport_settings", lambda *a, **k: settings, ) + monkeypatch.setattr( + profile_sync.intervals_icu, + "fetch_athlete_weight", + lambda *a, **k: 76.1, + ) calls = {"get": 0} + userinfo_weight = {"value": 79.0} def fake_fetch(session, headers, *args, **kwargs): calls["get"] += 1 return current if calls["get"] == 1 else apply_intervals_settings(current, settings) + def fake_user_info(session, headers, *args, **kwargs): + return {"weight": userinfo_weight["value"]} + def fake_update(session, headers, body, *args, **kwargs): posted["body"] = body return {"code": 0} + def fake_update_weight(session, headers, weight_kg, *args, **kwargs): + weight_posts.append(weight_kg) + userinfo_weight["value"] = float(weight_kg) + return {"code": 0} + monkeypatch.setattr(profile_sync, "fetch_personal_interval_info", fake_fetch) + monkeypatch.setattr(profile_sync, "fetch_user_info", fake_user_info) monkeypatch.setattr(profile_sync, "update_personal_interval_info", fake_update) + monkeypatch.setattr(profile_sync, "update_user_weight", fake_update_weight) result = profile_sync.sync_profile_zones( ProfileSyncConfig("user", "pass", "api-key"), @@ -174,5 +324,57 @@ def fake_update(session, headers, body, *args, **kwargs): ) assert posted["body"]["member"]["ftp"] == 242 + assert "weight" not in posted["body"]["member"] or posted["body"]["member"].get("weight") != 76.0 + assert weight_posts == [76] assert result.after is not None assert result.after["member"]["mhr"] == 193 + assert result.weight_before == 79.0 + assert result.weight_after == 76.0 + + +def test_sync_profile_zones_skips_weight_update_when_unchanged(monkeypatch): + from intervalssync.igpsport import profile_sync + + settings = _ride_settings() + current = apply_intervals_settings(_igpsport_payload(), settings) + weight_posts: list[int] = [] + + monkeypatch.setattr(profile_sync, "login", lambda *a, **k: {"Authorization": "Bearer x"}) + monkeypatch.setattr(profile_sync, "member_id_from_token", lambda *a, **k: 1171353) + monkeypatch.setattr( + profile_sync.intervals_icu, + "fetch_sport_settings", + lambda *a, **k: settings, + ) + monkeypatch.setattr( + profile_sync.intervals_icu, + "fetch_athlete_weight", + lambda *a, **k: 76.0, + ) + monkeypatch.setattr( + profile_sync, + "fetch_personal_interval_info", + lambda *a, **k: current, + ) + monkeypatch.setattr( + profile_sync, + "fetch_user_info", + lambda *a, **k: {"weight": 76.0, "cityId": 103172, "cityName": "Braga"}, + ) + monkeypatch.setattr( + profile_sync, + "update_personal_interval_info", + lambda *a, **k: {"code": 0}, + ) + monkeypatch.setattr( + profile_sync, + "update_user_weight", + lambda *a, **k: weight_posts.append(a[2]), + ) + + result = profile_sync.sync_profile_zones( + ProfileSyncConfig("user", "pass", "api-key"), + progress=lambda _message: None, + ) + assert weight_posts == [] + assert result.weight_after == 76.0