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.
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.env.example +2 -0
- intervalssync-0.9.5/.github/scripts/publish_release.sh +61 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/workflows/release.yml +10 -15
- {intervalssync-0.9.3 → intervalssync-0.9.5}/CLAUDE.md +6 -4
- {intervalssync-0.9.3 → intervalssync-0.9.5}/PKG-INFO +3 -1
- {intervalssync-0.9.3 → intervalssync-0.9.5}/README.md +2 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/AGENT.md +7 -1
- {intervalssync-0.9.3 → intervalssync-0.9.5}/pyproject.toml +1 -1
- intervalssync-0.9.5/src/intervalssync/_version.py +1 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/core.py +18 -5
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/ddp.py +5 -2
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/fit_encode.py +23 -0
- intervalssync-0.9.5/src/intervalssync/bryton/region.py +57 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/workout.py +77 -8
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/config.py +2 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/env.py +15 -2
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/main.py +18 -1
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/config.py +3 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/settings_view.py +16 -1
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/sync_view.py +21 -2
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/core.py +133 -30
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/intervals_icu.py +92 -13
- intervalssync-0.9.5/tests/test_activity_checkpoints.py +87 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_core.py +7 -2
- intervalssync-0.9.5/tests/test_bryton_region.py +85 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_workout.py +176 -2
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_cli.py +137 -8
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_config.py +6 -0
- intervalssync-0.9.5/tests/test_gui_sync.py +84 -0
- intervalssync-0.9.5/tests/test_igpsport_core.py +1250 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_intervals_icu.py +4 -0
- intervalssync-0.9.3/src/intervalssync/_version.py +0 -1
- intervalssync-0.9.3/tests/test_igpsport_core.py +0 -608
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/FUNDING.yml +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/pull_request_template.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.github/workflows/test.yml +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.gitignore +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/.python-version +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/CONTRIBUTING.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/LICENSE +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/assets/icon.png +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/img/demo.gif +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/img/settings.png +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/docs/img/sync.png +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/main.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/_dropbox_app_key.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/api.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/bryton/exceptions.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/cli/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/dropbox_client.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/app.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/profile_sync_ui.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/secrets.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/support_gamification.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/system.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/theme.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/gui/update_check.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/interval_info.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/profile_sync.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/region.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/workout.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/src/intervalssync/igpsport/zone_map.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.fit +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.json +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.fit +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.json +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_fit_encode.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_bryton_fit_native.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_dropbox_client.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_gui_app.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_profile_sync.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_secrets.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_support_gamification.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_update_check.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_workout.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.5}/tests/test_zone_map.py +0 -0
- {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@
|
|
220
|
+
uses: actions/download-artifact@v8
|
|
219
221
|
with:
|
|
220
222
|
path: dist
|
|
221
223
|
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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://
|
|
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
|
+
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
220
|
+
upload_result = upload_fit_file(
|
|
209
221
|
fit_path, act.title, ext, config.intervals_api_key
|
|
210
222
|
)
|
|
211
|
-
if
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
20
|
+
from .ddp import BrytonSession, call_method, login
|
|
19
21
|
from .exceptions import BrytonSyncError
|
|
20
|
-
from .fit_encode import
|
|
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=
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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"
|
|
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
|
|