Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 6 additions & 3 deletions docs/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 | …"
}
Expand Down Expand Up @@ -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).
2 changes: 1 addition & 1 deletion src/intervalssync/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/intervalssync/gui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/intervalssync/gui/profile_sync_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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"],
),
Expand Down
2 changes: 1 addition & 1 deletion src/intervalssync/gui/settings_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
),
Expand Down
107 changes: 106 additions & 1 deletion src/intervalssync/igpsport/interval_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand All @@ -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),
Expand Down
Loading
Loading