intervalssync 0.9.3__tar.gz → 0.9.5__tar.gz

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 (83) hide show
  1. {intervalssync-0.9.3 → intervalssync-0.9.5}/.env.example +2 -0
  2. intervalssync-0.9.5/.github/scripts/publish_release.sh +61 -0
  3. {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/workflows/release.yml +10 -15
  4. {intervalssync-0.9.3 → intervalssync-0.9.5}/CLAUDE.md +6 -4
  5. {intervalssync-0.9.3 → intervalssync-0.9.5}/PKG-INFO +3 -1
  6. {intervalssync-0.9.3 → intervalssync-0.9.5}/README.md +2 -0
  7. {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/AGENT.md +7 -1
  8. {intervalssync-0.9.3 → intervalssync-0.9.5}/pyproject.toml +1 -1
  9. intervalssync-0.9.5/src/intervalssync/_version.py +1 -0
  10. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/core.py +18 -5
  11. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/ddp.py +5 -2
  12. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/fit_encode.py +23 -0
  13. intervalssync-0.9.5/src/intervalssync/bryton/region.py +57 -0
  14. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/workout.py +77 -8
  15. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/config.py +2 -0
  16. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/env.py +15 -2
  17. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/main.py +18 -1
  18. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/config.py +3 -0
  19. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/settings_view.py +16 -1
  20. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/sync_view.py +21 -2
  21. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/core.py +133 -30
  22. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/intervals_icu.py +92 -13
  23. intervalssync-0.9.5/tests/test_activity_checkpoints.py +87 -0
  24. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_core.py +7 -2
  25. intervalssync-0.9.5/tests/test_bryton_region.py +85 -0
  26. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_workout.py +176 -2
  27. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_cli.py +137 -8
  28. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_config.py +6 -0
  29. intervalssync-0.9.5/tests/test_gui_sync.py +84 -0
  30. intervalssync-0.9.5/tests/test_igpsport_core.py +1250 -0
  31. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_intervals_icu.py +4 -0
  32. intervalssync-0.9.3/src/intervalssync/_version.py +0 -1
  33. intervalssync-0.9.3/tests/test_igpsport_core.py +0 -608
  34. {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/FUNDING.yml +0 -0
  35. {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/pull_request_template.md +0 -0
  36. {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/workflows/test.yml +0 -0
  37. {intervalssync-0.9.3 → intervalssync-0.9.5}/.gitignore +0 -0
  38. {intervalssync-0.9.3 → intervalssync-0.9.5}/.python-version +0 -0
  39. {intervalssync-0.9.3 → intervalssync-0.9.5}/CONTRIBUTING.md +0 -0
  40. {intervalssync-0.9.3 → intervalssync-0.9.5}/LICENSE +0 -0
  41. {intervalssync-0.9.3 → intervalssync-0.9.5}/assets/icon.png +0 -0
  42. {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/img/demo.gif +0 -0
  43. {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/img/settings.png +0 -0
  44. {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/img/sync.png +0 -0
  45. {intervalssync-0.9.3 → intervalssync-0.9.5}/main.py +0 -0
  46. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/__init__.py +0 -0
  47. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/_dropbox_app_key.py +0 -0
  48. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/__init__.py +0 -0
  49. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/api.py +0 -0
  50. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/exceptions.py +0 -0
  51. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/__init__.py +0 -0
  52. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/dropbox_client.py +0 -0
  53. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/__init__.py +0 -0
  54. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/app.py +0 -0
  55. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/profile_sync_ui.py +0 -0
  56. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/secrets.py +0 -0
  57. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/support_gamification.py +0 -0
  58. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/system.py +0 -0
  59. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/theme.py +0 -0
  60. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/update_check.py +0 -0
  61. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/__init__.py +0 -0
  62. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/interval_info.py +0 -0
  63. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/profile_sync.py +0 -0
  64. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/region.py +0 -0
  65. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/workout.py +0 -0
  66. {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/zone_map.py +0 -0
  67. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.fit +0 -0
  68. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.json +0 -0
  69. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.fit +0 -0
  70. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.json +0 -0
  71. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit +0 -0
  72. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json +0 -0
  73. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_fit_encode.py +0 -0
  74. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_fit_native.py +0 -0
  75. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_dropbox_client.py +0 -0
  76. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_gui_app.py +0 -0
  77. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_profile_sync.py +0 -0
  78. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_secrets.py +0 -0
  79. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_support_gamification.py +0 -0
  80. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_update_check.py +0 -0
  81. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_workout.py +0 -0
  82. {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_zone_map.py +0 -0
  83. {intervalssync-0.9.3 → intervalssync-0.9.5}/uv.lock +0 -0
@@ -16,4 +16,6 @@ INTERVALSSYNC_IGPSPORT_PASSWORD=
16
16
  INTERVALSSYNC_IGPSPORT_REGION=international
17
17
  INTERVALSSYNC_BRYTON_EMAIL=
18
18
  INTERVALSSYNC_BRYTON_PASSWORD=
19
+ # international (default) or china — use china for the China-region Bryton app
20
+ INTERVALSSYNC_BRYTON_REGION=international
19
21
  INTERVALSSYNC_INTERVALS_API_KEY=
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ tag=${1:?usage: publish_release.sh TAG ASSET_DIRECTORY}
6
+ asset_directory=${2:?usage: publish_release.sh TAG ASSET_DIRECTORY}
7
+
8
+ if [[ ! -d "$asset_directory" ]]; then
9
+ echo "Asset directory does not exist: $asset_directory" >&2
10
+ exit 1
11
+ fi
12
+
13
+ mapfile -d '' assets < <(find "$asset_directory" -type f -print0 | sort -z)
14
+ if (( ${#assets[@]} == 0 )); then
15
+ echo "No release assets found under $asset_directory" >&2
16
+ exit 1
17
+ fi
18
+
19
+ # Creating a draft first prevents users from seeing a release with missing
20
+ # downloads. On a retry, reuse the existing draft and its completed assets.
21
+ if ! gh release view "$tag" >/dev/null 2>&1; then
22
+ gh release create "$tag" --draft --generate-notes --verify-tag
23
+ fi
24
+
25
+ for asset in "${assets[@]}"; do
26
+ name=$(basename "$asset")
27
+
28
+ if gh release view "$tag" --json assets \
29
+ --jq '.assets[] | select(.state == "uploaded") | .name' | grep -Fxq "$name"; then
30
+ echo "Already uploaded: $name"
31
+ continue
32
+ fi
33
+
34
+ uploaded=false
35
+ for attempt in 1 2 3; do
36
+ echo "Uploading $name (attempt $attempt of 3)..."
37
+ if gh release upload "$tag" "$asset" --clobber; then
38
+ uploaded=true
39
+ break
40
+ fi
41
+
42
+ if (( attempt < 3 )); then
43
+ delay=$((attempt * 15))
44
+ echo "Upload failed; retrying $name in ${delay}s..." >&2
45
+ sleep "$delay"
46
+ fi
47
+ done
48
+
49
+ if [[ "$uploaded" != true ]]; then
50
+ echo "Failed to upload $name after 3 attempts." >&2
51
+ exit 1
52
+ fi
53
+ done
54
+
55
+ # Tags with a hyphen are prereleases. Publish only after every asset succeeds,
56
+ # so a failed job remains a recoverable draft rather than a partial release.
57
+ if [[ "$tag" == *-* ]]; then
58
+ gh release edit "$tag" --draft=false --prerelease --latest=false
59
+ else
60
+ gh release edit "$tag" --draft=false --prerelease=false --latest
61
+ fi
@@ -214,22 +214,17 @@ jobs:
214
214
  needs: [windows, android, macos]
215
215
  runs-on: ubuntu-latest
216
216
  steps:
217
+ - uses: actions/checkout@v4
218
+
217
219
  - name: Download build artifacts
218
- uses: actions/download-artifact@v4
220
+ uses: actions/download-artifact@v8
219
221
  with:
220
222
  path: dist
221
223
 
222
- # Create (or update) the Release for this tag, attach all files, and
223
- # generate the notes a single time (avoids the duplicate notes you get
224
- # when each build job generates them).
225
- #
226
- # Tags with a hyphen (e.g. v0.3.0-rc1) are marked as pre-releases: GitHub
227
- # keeps the last stable as "Latest", the in-app update check (which uses
228
- # the /releases/latest API) ignores them, and they aren't offered to users.
229
- - name: Publish Release
230
- uses: softprops/action-gh-release@v2
231
- with:
232
- files: dist/**/*
233
- generate_release_notes: true
234
- prerelease: ${{ contains(github.ref_name, '-') }}
235
- make_latest: ${{ !contains(github.ref_name, '-') }}
224
+ # Keep the release as a draft until every asset is uploaded. The helper
225
+ # uploads one file at a time and retries failures without replacing assets
226
+ # that already completed in an earlier attempt.
227
+ - name: Upload and publish Release
228
+ env:
229
+ GH_TOKEN: ${{ github.token }}
230
+ run: bash .github/scripts/publish_release.sh "${{ github.ref_name }}" dist
@@ -33,7 +33,7 @@ src/intervalssync/
33
33
  cli/main.py, env.py, config.py # headless CLI
34
34
  gui/app.py, views, config.py, secrets.py # Flet UI (dual-source: iGPSPORT + Bryton)
35
35
  igpsport/core.py, workout.py # iGPSPORT source
36
- bryton/ddp.py, api.py, core.py # Bryton source
36
+ bryton/ddp.py, api.py, core.py, region.py # Bryton source
37
37
  intervals_icu.py, dropbox_client.py # shared by sources
38
38
  ```
39
39
 
@@ -41,8 +41,8 @@ src/intervalssync/
41
41
  - **`igpsport/core.py`** — `sync(SyncConfig, progress)`: login → list → FIT URL → download → upload. `external_id`: `igpsport_{ride_id}`.
42
42
  - **`igpsport/profile_sync.py`** — `sync_profile_zones`: intervals.icu sport settings + athlete weight → iGPSPORT profile thresholds/zones/weight.
43
43
  - **`igpsport/workout.py`** — planned workouts intervals.icu → iGPSPORT.
44
- - **`bryton/ddp.py`** — Meteor DDP login + `activityList` subscription.
45
- - **`bryton/api.py`** — `GET https://m3.brytonactive.com/api/activity?id=…` (FIT download; Android app API).
44
+ - **`bryton/ddp.py`** — Meteor DDP login + `activityList` subscription. `region.py` holds the international vs China endpoints.
45
+ - **`bryton/api.py`** — `GET https://{session.host}/api/activity?id=…` (FIT download; Android app API).
46
46
  - **`bryton/core.py`** — `sync(SyncConfig, progress)`. `external_id`: `bryton_{activity_id}`.
47
47
  - **`gui/secrets.py`** — async OS vault via `flet-secure-storage`.
48
48
  - **`gui/config.py`** — GUI JSON settings: `enable_igpsport` / `enable_bryton`, per-source usernames, shared sync options.
@@ -58,9 +58,11 @@ src/intervalssync/
58
58
 
59
59
  ## Bryton API notes
60
60
 
61
- 1. Login: Meteor DDP `login` with email + SHA-256 password digest on `m3.brytonactive.com`.
61
+ 1. Login: Meteor DDP `login` with email + SHA-256 password digest on `m3.brytonactive.com` (international) or `active.cn.brytonactive.com` (China).
62
62
  2. List: subscribe `activityList` → `userActivities` collection.
63
63
  3. FIT: REST `GET /api/activity?id={_id}` with `X-User-Id`, `X-Auth-Token`, static `x-api-key` (not the web `activity.detail.2` samples API).
64
+ 4. Workouts: POST `https://{web_host}/workout/upload/{user_id}` (`active.brytonsport.com` international, `active.cn.brytonactive.com` China).
65
+ 5. **Regions are fully separate account databases.** An account created in one region returns `User not found` in the other. GUI `bryton_region`; CLI `INTERVALSSYNC_BRYTON_REGION=international|china`.
64
66
 
65
67
  ## Flet version note
66
68
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: intervalssync
3
- Version: 0.9.3
3
+ Version: 0.9.5
4
4
  Summary: Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu
5
5
  Project-URL: Homepage, https://github.com/jorge-huxley/intervalssync
6
6
  Project-URL: Repository, https://github.com/jorge-huxley/intervalssync
@@ -87,6 +87,7 @@ Description-Content-Type: text/markdown
87
87
  - **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)
88
88
  - **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))
89
89
  - **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)
90
+ - **China Bryton region** — accounts created in the China-region Bryton app use a separate backend; choose **China** in Settings (or `INTERVALSSYNC_BRYTON_REGION=china` in the CLI)
90
91
 
91
92
  > Prefer the terminal, or want to help out? It's open source (Python + [Flet](https://flet.dev), MIT) — see [CONTRIBUTING.md](CONTRIBUTING.md).
92
93
 
@@ -96,6 +97,7 @@ Description-Content-Type: text/markdown
96
97
  2. Unzip it anywhere and double-click the app (e.g. `intervalssync.exe`).
97
98
  3. On first launch, open **Settings** and enter:
98
99
  - your iGPSPORT **email** and **password** (or **phone number** for China — set region to China if you use [app.igpsport.cn](https://app.igpsport.cn/login))
100
+ - for Bryton Active, your **email** and **password** and the matching **region** (International or China)
99
101
  - your intervals.icu **API key** (intervals.icu → Settings → Developer)
100
102
  4. Click **Save**, then go back and press **Sync** (or **Upload to iGPSPORT** / **Upload to Bryton** for planned sessions on your intervals.icu calendar).
101
103
 
@@ -66,6 +66,7 @@
66
66
  - **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)
67
67
  - **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))
68
68
  - **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)
69
+ - **China Bryton region** — accounts created in the China-region Bryton app use a separate backend; choose **China** in Settings (or `INTERVALSSYNC_BRYTON_REGION=china` in the CLI)
69
70
 
70
71
  > Prefer the terminal, or want to help out? It's open source (Python + [Flet](https://flet.dev), MIT) — see [CONTRIBUTING.md](CONTRIBUTING.md).
71
72
 
@@ -75,6 +76,7 @@
75
76
  2. Unzip it anywhere and double-click the app (e.g. `intervalssync.exe`).
76
77
  3. On first launch, open **Settings** and enter:
77
78
  - your iGPSPORT **email** and **password** (or **phone number** for China — set region to China if you use [app.igpsport.cn](https://app.igpsport.cn/login))
79
+ - for Bryton Active, your **email** and **password** and the matching **region** (International or China)
78
80
  - your intervals.icu **API key** (intervals.icu → Settings → Developer)
79
81
  4. Click **Save**, then go back and press **Sync** (or **Upload to iGPSPORT** / **Upload to Bryton** for planned sessions on your intervals.icu calendar).
80
82
 
@@ -43,6 +43,12 @@ echo 'INTERVALSSYNC_INTERVALS_API_KEY=your-intervals-api-key' >> ~/.hermes/.env
43
43
  chmod 600 ~/.hermes/.env
44
44
  ```
45
45
 
46
+ For **China-region** accounts (the 百锐腾运动 app / [www.brytonsports.cn](http://www.brytonsports.cn)), add:
47
+
48
+ ```bash
49
+ echo 'INTERVALSSYNC_BRYTON_REGION=china' >> ~/.hermes/.env
50
+ ```
51
+
46
52
  For a named Hermes profile, use `$HERMES_HOME/.env` (that profile's directory).
47
53
 
48
54
  **intervals.icu API key:** intervals.icu → Settings → Developer.
@@ -176,7 +182,7 @@ Non-secret defaults in `intervalssync-cli` `config.json` (`platformdirs`). Secre
176
182
  ### Activity sync
177
183
 
178
184
  - **iGPSPORT:** list → FIT URL → download → upload (`igpsport_{ride_id}` external_id).
179
- - **Bryton:** DDP login → activity list → `GET /api/activity?id=…` FIT → upload (`bryton_{id}` external_id).
185
+ - **Bryton:** DDP login → activity list → `GET /api/activity?id=…` FIT → upload (`bryton_{id}` external_id). International uses `m3.brytonactive.com`; China uses `active.cn.brytonactive.com` (set `INTERVALSSYNC_BRYTON_REGION=china`).
180
186
  - Skips already on intervals.icu unless `--force-resync`.
181
187
  - **Dropbox** is GUI-only (Settings → connect Dropbox, enable upload). iGPSPORT
182
188
  uses `ride-0-YYYY-MM-DD-HH-MM-SS.fit` or `igpsport_{id}.fit`; Bryton uses
@@ -2,7 +2,7 @@
2
2
  name = "intervalssync"
3
3
  # Placeholder. The real version comes from the git tag, injected by CI at build
4
4
  # time (see .github/workflows/release.yml). Local/dev builds report 0.0.0+dev.
5
- version = "0.9.3"
5
+ version = "0.9.5"
6
6
  description = "Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu"
7
7
  readme = "README.md"
8
8
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.9.5"
@@ -19,8 +19,9 @@ from ..intervals_icu import (
19
19
  upload_fit_file,
20
20
  )
21
21
  from .api import download_fit_to_path
22
- from .ddp import DEFAULT_HOST, BrytonSession, list_activities, login
22
+ from .ddp import BrytonSession, list_activities, login
23
23
  from .exceptions import BrytonSyncError
24
+ from .region import resolve_region
24
25
 
25
26
  ACTIVITY_TYPE_KEEP = ""
26
27
 
@@ -90,7 +91,9 @@ class SyncConfig:
90
91
  intervals_api_key: str | None
91
92
  dropbox_refresh_token: str | None = None
92
93
  dropbox_app_key: str | None = None
93
- host: str = DEFAULT_HOST
94
+ # Explicit endpoint override; normally resolved from bryton_region.
95
+ host: str | None = None
96
+ bryton_region: str = "international"
94
97
  max_activities: int = 5
95
98
  download_dir: Path = Path("downloads")
96
99
  delete_after_upload: bool = True
@@ -118,8 +121,17 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
118
121
  report = progress or _noop
119
122
  result = SyncResult()
120
123
 
124
+ try:
125
+ region = resolve_region(config.bryton_region)
126
+ except ValueError as exc:
127
+ raise BrytonSyncError(str(exc)) from exc
128
+
121
129
  report("Logging in to Bryton Active…")
122
- session = login(config.bryton_email, config.bryton_password, host=config.host)
130
+ session = login(
131
+ config.bryton_email,
132
+ config.bryton_password,
133
+ host=config.host or region.host,
134
+ )
123
135
  report("Logged in.")
124
136
 
125
137
  raw_activities = list_activities(session, limit=config.max_activities)
@@ -205,10 +217,11 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
205
217
  if not config.intervals_api_key:
206
218
  raise BrytonSyncError("intervals.icu API key is required for upload.")
207
219
 
208
- activity_id = upload_fit_file(
220
+ upload_result = upload_fit_file(
209
221
  fit_path, act.title, ext, config.intervals_api_key
210
222
  )
211
- if activity_id:
223
+ if upload_result:
224
+ activity_id = upload_result.activity_id
212
225
  report(f"✓ Uploaded {act.activity_id}: {act.title}")
213
226
  result.uploaded += 1
214
227
  if config.activity_type:
@@ -12,9 +12,12 @@ from typing import Any
12
12
  import requests
13
13
 
14
14
  from .exceptions import BrytonAuthError, BrytonDDPError
15
+ from .region import INTERNATIONAL
15
16
 
16
- DEFAULT_HOST = "m3.brytonactive.com"
17
- WEB_HOST = "active.brytonsport.com"
17
+ # Backwards-compatible aliases for the international endpoints. Region-aware
18
+ # callers should resolve a BrytonRegionConfig instead.
19
+ DEFAULT_HOST = INTERNATIONAL.host
20
+ WEB_HOST = INTERNATIONAL.web_host
18
21
  DDP_CONNECT = {"msg": "connect", "version": "1", "support": ["1", "pre2", "pre1"]}
19
22
 
20
23
 
@@ -361,3 +361,26 @@ def icu_workout_doc_to_bryton_fit(
361
361
  encoder.on_mesg(Profile["mesg_num"]["WORKOUT_STEP"], step)
362
362
 
363
363
  return bytes(encoder.close())
364
+
365
+
366
+ def encoded_step_count(
367
+ workout_doc: dict[str, Any],
368
+ *,
369
+ max_hr: float | None = None,
370
+ ) -> int:
371
+ """Number of steps the encoder will actually write for this workout.
372
+
373
+ Repeats expand into their children plus a repeat marker, so this differs
374
+ from ``len(workout_doc["steps"])``. Bryton's ``info.interval`` array is
375
+ per encoded step, so it has to be built from this, not the raw list.
376
+ """
377
+ raw_steps = workout_doc.get("steps")
378
+ if not isinstance(raw_steps, list) or not raw_steps:
379
+ return 0
380
+ steps = _flatten_steps(
381
+ raw_steps,
382
+ ftp=_num(workout_doc.get("ftp")),
383
+ lthr=_num(workout_doc.get("lthr")),
384
+ max_hr=max_hr if max_hr is not None else _num(workout_doc.get("max_hr")),
385
+ )
386
+ return len(steps or [])
@@ -0,0 +1,57 @@
1
+ """Bryton Active regional endpoints (international vs China).
2
+
3
+ Bryton runs fully separate account databases and Meteor deployments for its
4
+ international and China-region apps. An account that exists on one region's
5
+ server reports ``User not found`` on the other, so the user has to pick the
6
+ region where the account was created.
7
+
8
+ The China backend was found through the regional web clients:
9
+ ``www.brytonsports.cn`` (百锐腾运动) talks to ``active.cn.brytonactive.com``,
10
+ which serves the same DDP API, mobile FIT endpoint and workout upload route as
11
+ the international deployment.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Literal
18
+
19
+ BrytonRegionName = Literal["international", "china"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BrytonRegionConfig:
24
+ name: BrytonRegionName
25
+ # Meteor DDP + mobile REST API (``GET /api/activity``).
26
+ host: str
27
+ # Web backend used for the planned-workout upload (``/workout/upload``).
28
+ web_host: str
29
+
30
+
31
+ INTERNATIONAL = BrytonRegionConfig(
32
+ name="international",
33
+ host="m3.brytonactive.com",
34
+ web_host="active.brytonsport.com",
35
+ )
36
+
37
+ CHINA = BrytonRegionConfig(
38
+ name="china",
39
+ host="active.cn.brytonactive.com",
40
+ web_host="active.cn.brytonactive.com",
41
+ )
42
+
43
+ _REGIONS: dict[BrytonRegionName, BrytonRegionConfig] = {
44
+ "international": INTERNATIONAL,
45
+ "china": CHINA,
46
+ }
47
+
48
+
49
+ def resolve_region(name: str | None) -> BrytonRegionConfig:
50
+ """Return the region config for a name; default is international."""
51
+ if not name or name == "international":
52
+ return INTERNATIONAL
53
+ if name == "china":
54
+ return CHINA
55
+ raise ValueError(
56
+ f"Unknown Bryton region: {name!r} (expected 'international' or 'china')"
57
+ )
@@ -7,17 +7,24 @@ intervals.icu ``workout_doc`` to Bryton-native FIT and POST multipart to
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import json
10
11
  import re
11
12
  from dataclasses import dataclass, field
12
13
  from datetime import date, datetime, timedelta
13
14
  from typing import Any, Callable
15
+ from uuid import uuid4
14
16
 
15
17
  import requests
16
18
 
17
19
  from .. import intervals_icu
18
- from .ddp import WEB_HOST, BrytonSession, call_method, login
20
+ from .ddp import BrytonSession, call_method, login
19
21
  from .exceptions import BrytonSyncError
20
- from .fit_encode import bryton_hr_uses_mhr, icu_workout_doc_to_bryton_fit
22
+ from .fit_encode import (
23
+ bryton_hr_uses_mhr,
24
+ encoded_step_count,
25
+ icu_workout_doc_to_bryton_fit,
26
+ )
27
+ from .region import INTERNATIONAL, resolve_region
21
28
 
22
29
  _CYCLING_TYPES = frozenset(
23
30
  {
@@ -89,6 +96,9 @@ class BrytonWorkoutUploadConfig:
89
96
  workout_days_ahead: int = 1
90
97
  uploaded_workouts: dict[str, str] = field(default_factory=dict)
91
98
  force_resync: bool = False
99
+ bryton_region: str = "international"
100
+ # Explicit endpoint override; normally resolved from bryton_region.
101
+ web_host: str | None = None
92
102
 
93
103
 
94
104
  def apply_uploaded_bryton_workout_map(
@@ -186,10 +196,46 @@ def _sanitize_workout_filename(name: str, *, event_id: int) -> str:
186
196
 
187
197
 
188
198
  def _auto_workout_filename() -> str:
199
+ # Names are a fallback deduplication key, so time alone is insufficient
200
+ # when multiple uploads (including separate processes) share a timestamp.
189
201
  now = datetime.now()
190
202
  return (
191
203
  f"bsWO{now.month:02d}{now.day:02d}_"
192
- f"{now.hour:02d}{now.minute:02d}{now.second:02d}"
204
+ f"{now.hour:02d}{now.minute:02d}{now.second:02d}_{uuid4().hex}"
205
+ )
206
+
207
+
208
+ def build_workout_info(
209
+ title: str,
210
+ plan_date: str,
211
+ workout_doc: dict[str, Any] | None = None,
212
+ *,
213
+ description: str = "",
214
+ max_hr: float | None = None,
215
+ ) -> str:
216
+ """Build the ``info`` blob Bryton stores alongside a workout file.
217
+
218
+ A workout carries its schedule here, not in the FIT, and there is no
219
+ separate plan object in the API -- ``plan`` is the whole mechanism. An
220
+ upload without this blob is inert: visible and editable in the app, but
221
+ absent from its Training calendar and impossible to send to the head unit.
222
+ TrainingPeaks-sourced workouts arrive with ``plan`` populated, which is why
223
+ they work and plain uploads do not.
224
+ """
225
+ # One entry per ENCODED step: repeats expand in the FIT, so the raw list
226
+ # would be short and the app's step count would disagree with the file.
227
+ n_steps = encoded_step_count(workout_doc, max_hr=max_hr) if workout_doc else 0
228
+ interval = [{"target_type": ""} for _ in range(n_steps)]
229
+ return json.dumps(
230
+ {
231
+ "name": title,
232
+ "description": description,
233
+ "plan": [plan_date] if plan_date else [],
234
+ "provider": "bryton",
235
+ "interval": interval,
236
+ "create_time": int(datetime.now().timestamp() * 1000),
237
+ "ver": 5,
238
+ }
193
239
  )
194
240
 
195
241
 
@@ -198,6 +244,7 @@ def upload_workout_fit(
198
244
  fit_bytes: bytes,
199
245
  name: str,
200
246
  *,
247
+ info: str | None = None,
201
248
  http: requests.Session | None = None,
202
249
  ) -> bool:
203
250
  """Upload a FIT workout to Bryton Active web; return True on success."""
@@ -208,20 +255,23 @@ def upload_workout_fit(
208
255
  "X-User-Id": session.user_id,
209
256
  "X-Auth-Token": session.auth_token,
210
257
  }
258
+ payload = {"name": filename, "provider": "bryton"}
259
+ if info:
260
+ payload["info"] = info
211
261
  client = http or requests.Session()
212
262
  resp = client.post(
213
263
  url,
214
264
  files={"file": (filename, fit_bytes, "application/octet-stream")},
215
- data={"name": filename, "provider": "bryton"},
265
+ data=payload,
216
266
  headers=headers,
217
267
  timeout=120,
218
268
  )
219
269
  return resp.ok
220
270
 
221
271
 
222
- def web_login(email: str, password: str) -> BrytonSession:
272
+ def web_login(email: str, password: str, *, host: str | None = None) -> BrytonSession:
223
273
  """Log in to the Bryton Active website backend."""
224
- return login(email, password, host=WEB_HOST)
274
+ return login(email, password, host=host or INTERNATIONAL.web_host)
225
275
 
226
276
 
227
277
  def upload_workouts(
@@ -237,8 +287,17 @@ def upload_workouts(
237
287
  days = max(1, config.workout_days_ahead)
238
288
  newest = config.newest or (today + timedelta(days=days - 1))
239
289
 
290
+ try:
291
+ region = resolve_region(config.bryton_region)
292
+ except ValueError as exc:
293
+ raise BrytonSyncError(str(exc)) from exc
294
+
240
295
  report("Logging in to Bryton Active…")
241
- session = web_login(config.bryton_email, config.bryton_password)
296
+ session = web_login(
297
+ config.bryton_email,
298
+ config.bryton_password,
299
+ host=config.web_host or region.web_host,
300
+ )
242
301
  report("Logged in.")
243
302
 
244
303
  live_ids, live_names = _fetch_workout_library(session)
@@ -319,7 +378,17 @@ def upload_workouts(
319
378
  )
320
379
 
321
380
  report(f"Uploading {workout.name}…")
322
- if upload_workout_fit(session, fit_bytes, upload_name, http=http):
381
+ # Direct attribute access, deliberately: a refactor that drops
382
+ # start_date must raise here rather than quietly emit plan:[], which
383
+ # would silently reintroduce the inert-upload bug this commit fixes.
384
+ info = build_workout_info(
385
+ workout.name,
386
+ workout.start_date,
387
+ workout.workout_doc,
388
+ description=workout.description,
389
+ max_hr=encode_max_hr,
390
+ )
391
+ if upload_workout_fit(session, fit_bytes, upload_name, info=info, http=http):
323
392
  after_ids, _ = _fetch_workout_library(session)
324
393
  new_ids = after_ids - ids_before
325
394
  stored_value = next(iter(new_ids), upload_name)
@@ -31,6 +31,8 @@ class CliConfig:
31
31
  delete_after_upload: bool = True
32
32
  force_resync: bool = False
33
33
  activity_type: str = ""
34
+ # iGPSPORT ride id (decimal string) → intervals.icu activity id.
35
+ uploaded_activities: dict[str, str] = field(default_factory=dict)
34
36
  # Planned workouts: intervals.icu event id → iGPSPORT workoutId.
35
37
  uploaded_workouts: dict[str, int] = field(default_factory=dict)
36
38
  # Planned workouts: intervals.icu event id → Bryton FIT filename stem.
@@ -14,6 +14,7 @@ IGPSPORT_PASSWORD_KEY = "INTERVALSSYNC_IGPSPORT_PASSWORD"
14
14
  IGPSPORT_REGION_KEY = "INTERVALSSYNC_IGPSPORT_REGION"
15
15
  BRYTON_EMAIL_KEY = "INTERVALSSYNC_BRYTON_EMAIL"
16
16
  BRYTON_PASSWORD_KEY = "INTERVALSSYNC_BRYTON_PASSWORD"
17
+ BRYTON_REGION_KEY = "INTERVALSSYNC_BRYTON_REGION"
17
18
  INTERVALS_API_KEY_KEY = "INTERVALSSYNC_INTERVALS_API_KEY"
18
19
 
19
20
  IGPSPORT_REQUIRED_KEYS = (
@@ -31,12 +32,15 @@ _IGPSPORT_BODY = (
31
32
  f" {IGPSPORT_USER_KEY}=<email>\n"
32
33
  f" {IGPSPORT_PASSWORD_KEY}=<password>\n"
33
34
  f" {INTERVALS_API_KEY_KEY}=<api-key>\n"
34
- f" {IGPSPORT_REGION_KEY}=international # or china for app.igpsport.cn"
35
+ f" # optional: international (default) or china for app.igpsport.cn\n"
36
+ f" {IGPSPORT_REGION_KEY}=international"
35
37
  )
36
38
  _BRYTON_BODY = (
37
39
  f" {BRYTON_EMAIL_KEY}=<email>\n"
38
40
  f" {BRYTON_PASSWORD_KEY}=<password>\n"
39
- f" {INTERVALS_API_KEY_KEY}=<api-key>"
41
+ f" {INTERVALS_API_KEY_KEY}=<api-key>\n"
42
+ f" # optional: international (default) or china for the China-region Bryton app\n"
43
+ f" {BRYTON_REGION_KEY}=international"
40
44
  )
41
45
 
42
46
  ENV_OVERRIDE_HINT = (
@@ -77,6 +81,7 @@ class BrytonCredentials:
77
81
  bryton_email: str
78
82
  bryton_password: str
79
83
  intervals_api_key: str
84
+ bryton_region: str = "international"
80
85
 
81
86
 
82
87
  def _default_hermes_env_path() -> Path:
@@ -175,10 +180,18 @@ def load_bryton_credentials(env_path: Path) -> BrytonCredentials:
175
180
  if missing:
176
181
  raise CliConfigError(_missing_keys_message(env_path, missing, source="bryton"))
177
182
 
183
+ region = values.get(BRYTON_REGION_KEY, "international") or "international"
184
+ if region not in ("international", "china"):
185
+ raise CliConfigError(
186
+ f"Invalid {BRYTON_REGION_KEY}={region!r} in {env_path} "
187
+ "(expected 'international' or 'china')."
188
+ )
189
+
178
190
  return BrytonCredentials(
179
191
  bryton_email=values[BRYTON_EMAIL_KEY],
180
192
  bryton_password=values[BRYTON_PASSWORD_KEY],
181
193
  intervals_api_key=values[INTERVALS_API_KEY_KEY],
194
+ bryton_region=region,
182
195
  )
183
196
 
184
197