intervalssync 0.9.4__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.4 → intervalssync-0.9.5}/.env.example +2 -0
- intervalssync-0.9.5/.github/scripts/publish_release.sh +61 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/workflows/release.yml +10 -15
- {intervalssync-0.9.4 → intervalssync-0.9.5}/CLAUDE.md +6 -4
- {intervalssync-0.9.4 → intervalssync-0.9.5}/PKG-INFO +3 -1
- {intervalssync-0.9.4 → intervalssync-0.9.5}/README.md +2 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/AGENT.md +7 -1
- {intervalssync-0.9.4 → intervalssync-0.9.5}/pyproject.toml +1 -1
- intervalssync-0.9.5/src/intervalssync/_version.py +1 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/core.py +15 -3
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/ddp.py +5 -2
- intervalssync-0.9.5/src/intervalssync/bryton/region.py +57 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/workout.py +17 -4
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/env.py +15 -2
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/main.py +2 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/config.py +1 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/settings_view.py +16 -1
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/sync_view.py +2 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/core.py +36 -4
- intervalssync-0.9.5/tests/test_bryton_region.py +85 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_cli.py +44 -1
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_config.py +3 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_igpsport_core.py +110 -1
- intervalssync-0.9.4/src/intervalssync/_version.py +0 -1
- {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/FUNDING.yml +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/pull_request_template.md +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/workflows/test.yml +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/.gitignore +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/.python-version +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/CONTRIBUTING.md +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/LICENSE +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/assets/icon.png +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/img/demo.gif +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/img/settings.png +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/img/sync.png +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/main.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/__init__.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/_dropbox_app_key.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/__init__.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/api.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/exceptions.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/fit_encode.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/__init__.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/config.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/dropbox_client.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/__init__.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/app.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/profile_sync_ui.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/secrets.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/support_gamification.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/system.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/theme.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/update_check.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/__init__.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/interval_info.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/profile_sync.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/region.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/workout.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/zone_map.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/intervals_icu.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.fit +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.json +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.fit +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.json +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_activity_checkpoints.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_core.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_fit_encode.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_fit_native.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_workout.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_dropbox_client.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_gui_app.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_gui_sync.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_intervals_icu.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_profile_sync.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_secrets.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_support_gamification.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_update_check.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_workout.py +0 -0
- {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_zone_map.py +0 -0
- {intervalssync-0.9.4 → 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)
|
|
@@ -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
|
|
|
@@ -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
|
+
)
|
|
@@ -17,13 +17,14 @@ from uuid import uuid4
|
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
19
|
from .. import intervals_icu
|
|
20
|
-
from .ddp import
|
|
20
|
+
from .ddp import BrytonSession, call_method, login
|
|
21
21
|
from .exceptions import BrytonSyncError
|
|
22
22
|
from .fit_encode import (
|
|
23
23
|
bryton_hr_uses_mhr,
|
|
24
24
|
encoded_step_count,
|
|
25
25
|
icu_workout_doc_to_bryton_fit,
|
|
26
26
|
)
|
|
27
|
+
from .region import INTERNATIONAL, resolve_region
|
|
27
28
|
|
|
28
29
|
_CYCLING_TYPES = frozenset(
|
|
29
30
|
{
|
|
@@ -95,6 +96,9 @@ class BrytonWorkoutUploadConfig:
|
|
|
95
96
|
workout_days_ahead: int = 1
|
|
96
97
|
uploaded_workouts: dict[str, str] = field(default_factory=dict)
|
|
97
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
|
|
98
102
|
|
|
99
103
|
|
|
100
104
|
def apply_uploaded_bryton_workout_map(
|
|
@@ -265,9 +269,9 @@ def upload_workout_fit(
|
|
|
265
269
|
return resp.ok
|
|
266
270
|
|
|
267
271
|
|
|
268
|
-
def web_login(email: str, password: str) -> BrytonSession:
|
|
272
|
+
def web_login(email: str, password: str, *, host: str | None = None) -> BrytonSession:
|
|
269
273
|
"""Log in to the Bryton Active website backend."""
|
|
270
|
-
return login(email, password, host=
|
|
274
|
+
return login(email, password, host=host or INTERNATIONAL.web_host)
|
|
271
275
|
|
|
272
276
|
|
|
273
277
|
def upload_workouts(
|
|
@@ -283,8 +287,17 @@ def upload_workouts(
|
|
|
283
287
|
days = max(1, config.workout_days_ahead)
|
|
284
288
|
newest = config.newest or (today + timedelta(days=days - 1))
|
|
285
289
|
|
|
290
|
+
try:
|
|
291
|
+
region = resolve_region(config.bryton_region)
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
raise BrytonSyncError(str(exc)) from exc
|
|
294
|
+
|
|
286
295
|
report("Logging in to Bryton Active…")
|
|
287
|
-
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
|
+
)
|
|
288
301
|
report("Logged in.")
|
|
289
302
|
|
|
290
303
|
live_ids, live_names = _fetch_workout_library(session)
|
|
@@ -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
|
|
|
@@ -93,6 +93,7 @@ def _build_bryton_sync_config(
|
|
|
93
93
|
bryton_email=credentials.bryton_email,
|
|
94
94
|
bryton_password=credentials.bryton_password,
|
|
95
95
|
intervals_api_key=credentials.intervals_api_key,
|
|
96
|
+
bryton_region=credentials.bryton_region,
|
|
96
97
|
max_activities=max_activities if max_activities is not None else config.max_activities,
|
|
97
98
|
download_dir=Path(download_dir or config.download_dir),
|
|
98
99
|
delete_after_upload=(
|
|
@@ -208,6 +209,7 @@ def _build_bryton_workout_upload_config(
|
|
|
208
209
|
bryton_email=credentials.bryton_email,
|
|
209
210
|
bryton_password=credentials.bryton_password,
|
|
210
211
|
intervals_api_key=credentials.intervals_api_key,
|
|
212
|
+
bryton_region=credentials.bryton_region,
|
|
211
213
|
workout_days_ahead=(
|
|
212
214
|
workout_days_ahead if workout_days_ahead is not None else config.workout_days_ahead
|
|
213
215
|
),
|
|
@@ -30,6 +30,7 @@ class AppConfig:
|
|
|
30
30
|
igp_user: str = ""
|
|
31
31
|
igp_region: str = "international"
|
|
32
32
|
bryton_user: str = ""
|
|
33
|
+
bryton_region: str = "international"
|
|
33
34
|
max_activities: int = 5
|
|
34
35
|
download_dir: str = field(default_factory=_default_download_dir)
|
|
35
36
|
# Remove each .fit file once it has been uploaded to intervals.icu.
|
|
@@ -96,6 +96,20 @@ async def build_settings_view(
|
|
|
96
96
|
value=config.enable_bryton,
|
|
97
97
|
active_color=colors["accent"],
|
|
98
98
|
)
|
|
99
|
+
bryton_region_value = (
|
|
100
|
+
config.bryton_region
|
|
101
|
+
if config.bryton_region in ("international", "china")
|
|
102
|
+
else "international"
|
|
103
|
+
)
|
|
104
|
+
bryton_region = ft.Dropdown(
|
|
105
|
+
label="Bryton region",
|
|
106
|
+
value=bryton_region_value,
|
|
107
|
+
options=[
|
|
108
|
+
ft.dropdown.Option("international", "International"),
|
|
109
|
+
ft.dropdown.Option("china", "China"),
|
|
110
|
+
],
|
|
111
|
+
helper_text="Use China if your account was created in the China-region Bryton app",
|
|
112
|
+
)
|
|
99
113
|
bryton_user = _input_field(
|
|
100
114
|
label="Bryton Active email",
|
|
101
115
|
value=config.bryton_user,
|
|
@@ -519,7 +533,7 @@ async def build_settings_view(
|
|
|
519
533
|
)
|
|
520
534
|
bryton_credentials = ft.Column(
|
|
521
535
|
spacing=theme.SPACE_SM,
|
|
522
|
-
controls=[bryton_user, bryton_password],
|
|
536
|
+
controls=[bryton_region, bryton_user, bryton_password],
|
|
523
537
|
)
|
|
524
538
|
workout_sync_section = ft.Container(
|
|
525
539
|
visible=config.enable_igpsport or config.enable_bryton,
|
|
@@ -598,6 +612,7 @@ async def build_settings_view(
|
|
|
598
612
|
config.igp_user = igp_user.value.strip()
|
|
599
613
|
config.igp_region = igp_region.value or "international"
|
|
600
614
|
config.bryton_user = bryton_user.value.strip()
|
|
615
|
+
config.bryton_region = bryton_region.value or "international"
|
|
601
616
|
try:
|
|
602
617
|
config.max_activities = max(1, int(max_activities.value))
|
|
603
618
|
except (TypeError, ValueError):
|
|
@@ -258,6 +258,7 @@ def build_sync_view(
|
|
|
258
258
|
intervals_api_key=api_key,
|
|
259
259
|
dropbox_refresh_token=dropbox_refresh_token,
|
|
260
260
|
dropbox_app_key=dropbox_app_key,
|
|
261
|
+
bryton_region=config.bryton_region,
|
|
261
262
|
max_activities=config.max_activities,
|
|
262
263
|
download_dir=Path(config.download_dir),
|
|
263
264
|
delete_after_upload=config.delete_after_upload,
|
|
@@ -335,6 +336,7 @@ def build_sync_view(
|
|
|
335
336
|
workout_days_ahead=config.workout_days_ahead,
|
|
336
337
|
uploaded_workouts=dict(config.uploaded_bryton_workouts),
|
|
337
338
|
force_resync=config.force_resync,
|
|
339
|
+
bryton_region=config.bryton_region,
|
|
338
340
|
)
|
|
339
341
|
|
|
340
342
|
uploaded_count = 0
|
|
@@ -51,12 +51,14 @@ IGP_TIME_FORMATS = (
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def parse_igp_start_time(value: str) -> datetime | None:
|
|
54
|
+
def parse_igp_start_time(value: str, *, require_time: bool = False) -> datetime | None:
|
|
55
55
|
"""Parse an iGPSPORT activity start time; return None if unrecognised."""
|
|
56
56
|
if not value or not isinstance(value, str):
|
|
57
57
|
return None
|
|
58
58
|
text = value.strip()
|
|
59
59
|
for fmt in IGP_TIME_FORMATS:
|
|
60
|
+
if require_time and "%H" not in fmt:
|
|
61
|
+
continue
|
|
60
62
|
try:
|
|
61
63
|
return datetime.strptime(text, fmt)
|
|
62
64
|
except ValueError:
|
|
@@ -95,7 +97,7 @@ def dropbox_filename_for(activity: "Activity", use_date: bool = True) -> str:
|
|
|
95
97
|
fallback = f"{external_id_for(activity.ride_id)}.fit"
|
|
96
98
|
if not use_date:
|
|
97
99
|
return fallback
|
|
98
|
-
start = parse_igp_start_time(activity.start_time)
|
|
100
|
+
start = parse_igp_start_time(activity.start_time, require_time=True)
|
|
99
101
|
if start is None:
|
|
100
102
|
return fallback
|
|
101
103
|
return f"ride-0-{start:%Y-%m-%d-%H-%M-%S}.fit"
|
|
@@ -114,6 +116,7 @@ class Activity:
|
|
|
114
116
|
ride_id: int
|
|
115
117
|
title: str
|
|
116
118
|
start_time: str
|
|
119
|
+
fit_url: str | None = field(default=None, repr=False, compare=False)
|
|
117
120
|
|
|
118
121
|
|
|
119
122
|
@dataclass
|
|
@@ -204,7 +207,36 @@ def list_activities(
|
|
|
204
207
|
) -> list[Activity]:
|
|
205
208
|
"""Return the most recent activities, newest first, capped at max_activities."""
|
|
206
209
|
cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
|
|
207
|
-
|
|
210
|
+
activities = _list_activities_query(session, max_activities, cfg)
|
|
211
|
+
# The list endpoint can return only YYYY.MM.DD. Enrich the shared Activity
|
|
212
|
+
# before logging, CLI JSON, date-range checks, or Dropbox skip decisions.
|
|
213
|
+
for activity in activities:
|
|
214
|
+
if parse_igp_start_time(activity.start_time, require_time=True) is not None:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
detail = fetch_activity_detail(session, activity.ride_id, cfg)
|
|
218
|
+
except (requests.RequestException, ValueError):
|
|
219
|
+
# Keep the list date for date-range checks. Dropbox uses the unique
|
|
220
|
+
# ride ID when the full timestamp is unavailable, never fake midnight.
|
|
221
|
+
continue
|
|
222
|
+
start_time = detail.get("startTime") or detail.get("StartTime")
|
|
223
|
+
if parse_igp_start_time(start_time, require_time=True) is not None:
|
|
224
|
+
activity.start_time = start_time
|
|
225
|
+
activity.fit_url = detail.get("fitUrl")
|
|
226
|
+
return activities
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fetch_activity_detail(
|
|
230
|
+
session: requests.Session, ride_id: int, region: IgpRegionConfig
|
|
231
|
+
) -> dict:
|
|
232
|
+
"""Fetch the full start timestamp and FIT URL from the activity detail API."""
|
|
233
|
+
response = session.get(
|
|
234
|
+
f"{region.gateway_base}/queryActivityDetail/{ride_id}", timeout=30
|
|
235
|
+
)
|
|
236
|
+
response.raise_for_status()
|
|
237
|
+
body = response.json()
|
|
238
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
239
|
+
return data if isinstance(data, dict) else {}
|
|
208
240
|
|
|
209
241
|
|
|
210
242
|
def _list_activities_query(
|
|
@@ -514,7 +546,7 @@ def sync(
|
|
|
514
546
|
if any_upload_enabled and not intervals_needs_it and not dropbox_needs_it:
|
|
515
547
|
continue
|
|
516
548
|
|
|
517
|
-
fit_url = resolve_fit_url(session, auth_headers, act.ride_id, region)
|
|
549
|
+
fit_url = act.fit_url or resolve_fit_url(session, auth_headers, act.ride_id, region)
|
|
518
550
|
if not fit_url:
|
|
519
551
|
report(f"⚠ Could not resolve FIT URL for {act.ride_id}; skipping.")
|
|
520
552
|
result.failed += 1
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for Bryton regional endpoint selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from intervalssync.bryton import core, workout
|
|
8
|
+
from intervalssync.bryton.ddp import BrytonSession
|
|
9
|
+
from intervalssync.bryton.exceptions import BrytonSyncError
|
|
10
|
+
from intervalssync.bryton.region import CHINA, INTERNATIONAL, resolve_region
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_resolve_region_defaults_to_international():
|
|
14
|
+
assert resolve_region(None) is INTERNATIONAL
|
|
15
|
+
assert resolve_region("") is INTERNATIONAL
|
|
16
|
+
assert resolve_region("international") is INTERNATIONAL
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_resolve_region_china():
|
|
20
|
+
assert resolve_region("china") is CHINA
|
|
21
|
+
assert CHINA.host == "active.cn.brytonactive.com"
|
|
22
|
+
assert CHINA.web_host == "active.cn.brytonactive.com"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resolve_region_rejects_unknown_name():
|
|
26
|
+
with pytest.raises(ValueError, match="Unknown Bryton region"):
|
|
27
|
+
resolve_region("eu")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_sync_logs_in_to_china_host(monkeypatch, tmp_path):
|
|
31
|
+
captured: dict[str, str] = {}
|
|
32
|
+
|
|
33
|
+
def fake_login(email, password, *, host):
|
|
34
|
+
captured["host"] = host
|
|
35
|
+
return BrytonSession(user_id="u1", auth_token="tok", host=host)
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(core, "login", fake_login)
|
|
38
|
+
monkeypatch.setattr(core, "list_activities", lambda *a, **k: [])
|
|
39
|
+
|
|
40
|
+
cfg = core.SyncConfig(
|
|
41
|
+
bryton_email="a@b.com",
|
|
42
|
+
bryton_password="pw",
|
|
43
|
+
intervals_api_key="key",
|
|
44
|
+
bryton_region="china",
|
|
45
|
+
download_dir=tmp_path,
|
|
46
|
+
list_activities=False,
|
|
47
|
+
download_fit=False,
|
|
48
|
+
upload_intervals=False,
|
|
49
|
+
)
|
|
50
|
+
core.sync(cfg)
|
|
51
|
+
|
|
52
|
+
assert captured["host"] == CHINA.host
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_sync_rejects_unknown_region(tmp_path):
|
|
56
|
+
cfg = core.SyncConfig(
|
|
57
|
+
bryton_email="a@b.com",
|
|
58
|
+
bryton_password="pw",
|
|
59
|
+
intervals_api_key="key",
|
|
60
|
+
bryton_region="eu",
|
|
61
|
+
download_dir=tmp_path,
|
|
62
|
+
)
|
|
63
|
+
with pytest.raises(BrytonSyncError, match="Unknown Bryton region"):
|
|
64
|
+
core.sync(cfg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_workout_upload_uses_china_web_host(monkeypatch):
|
|
68
|
+
captured: dict[str, str] = {}
|
|
69
|
+
|
|
70
|
+
def fake_web_login(email, password, *, host=None):
|
|
71
|
+
captured["host"] = host or ""
|
|
72
|
+
raise BrytonSyncError("stop before network")
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(workout, "web_login", fake_web_login)
|
|
75
|
+
cfg = workout.BrytonWorkoutUploadConfig(
|
|
76
|
+
bryton_email="a@b.com",
|
|
77
|
+
bryton_password="pw",
|
|
78
|
+
intervals_api_key="key",
|
|
79
|
+
bryton_region="china",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
with pytest.raises(BrytonSyncError, match="stop before network"):
|
|
83
|
+
workout.upload_workouts(cfg)
|
|
84
|
+
|
|
85
|
+
assert captured["host"] == CHINA.web_host
|
|
@@ -66,6 +66,27 @@ def test_load_igpsport_credentials_rejects_invalid_region(tmp_path):
|
|
|
66
66
|
cli_env.load_igpsport_credentials(env_file)
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def test_load_bryton_credentials_defaults_region(tmp_path):
|
|
70
|
+
env_file = tmp_path / ".env"
|
|
71
|
+
_write_bryton_env(env_file)
|
|
72
|
+
creds = cli_env.load_bryton_credentials(env_file)
|
|
73
|
+
assert creds.bryton_region == "international"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_load_bryton_credentials_china_region(tmp_path):
|
|
77
|
+
env_file = tmp_path / ".env"
|
|
78
|
+
_write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "china"})
|
|
79
|
+
creds = cli_env.load_bryton_credentials(env_file)
|
|
80
|
+
assert creds.bryton_region == "china"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_load_bryton_credentials_rejects_invalid_region(tmp_path):
|
|
84
|
+
env_file = tmp_path / ".env"
|
|
85
|
+
_write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "eu"})
|
|
86
|
+
with pytest.raises(cli_env.CliConfigError, match="INTERVALSSYNC_BRYTON_REGION"):
|
|
87
|
+
cli_env.load_bryton_credentials(env_file)
|
|
88
|
+
|
|
89
|
+
|
|
69
90
|
def test_parse_dotenv_comments_and_quotes():
|
|
70
91
|
text = """
|
|
71
92
|
# comment
|
|
@@ -356,13 +377,35 @@ def test_upload_workouts_json_sync_error(tmp_path: Path, monkeypatch, capsys):
|
|
|
356
377
|
assert payload["error"] == "fetch failed"
|
|
357
378
|
|
|
358
379
|
|
|
380
|
+
def test_sync_bryton_passes_region(tmp_path: Path, monkeypatch, capsys):
|
|
381
|
+
env_file = tmp_path / ".env"
|
|
382
|
+
_write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "china"})
|
|
383
|
+
monkeypatch.setattr(cli_config, "CONFIG_DIR", tmp_path)
|
|
384
|
+
monkeypatch.setattr(cli_config, "CONFIG_PATH", tmp_path / "config.json")
|
|
385
|
+
|
|
386
|
+
def fake_sync(config, progress=None):
|
|
387
|
+
assert config.bryton_region == "china"
|
|
388
|
+
return cli.BrytonSyncResult()
|
|
389
|
+
|
|
390
|
+
monkeypatch.setattr(cli, "bryton_sync", fake_sync)
|
|
391
|
+
|
|
392
|
+
args = cli._build_parser().parse_args(
|
|
393
|
+
["sync", "--source", "bryton", "--env-file", str(env_file), "--json"]
|
|
394
|
+
)
|
|
395
|
+
assert cli.cmd_sync(args) == cli.EXIT_OK
|
|
396
|
+
|
|
397
|
+
payload = json.loads(capsys.readouterr().out)
|
|
398
|
+
assert payload["source"] == "bryton"
|
|
399
|
+
|
|
400
|
+
|
|
359
401
|
def test_upload_workouts_bryton_json_success(tmp_path: Path, monkeypatch, capsys):
|
|
360
402
|
env_file = tmp_path / ".env"
|
|
361
|
-
_write_bryton_env(env_file)
|
|
403
|
+
_write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "china"})
|
|
362
404
|
monkeypatch.setattr(cli_config, "CONFIG_DIR", tmp_path)
|
|
363
405
|
monkeypatch.setattr(cli_config, "CONFIG_PATH", tmp_path / "config.json")
|
|
364
406
|
|
|
365
407
|
def fake_upload(config, progress=None):
|
|
408
|
+
assert config.bryton_region == "china"
|
|
366
409
|
if progress:
|
|
367
410
|
progress("uploading bryton")
|
|
368
411
|
return bryton_workout.BrytonWorkoutUploadResult(
|
|
@@ -12,6 +12,7 @@ def test_defaults():
|
|
|
12
12
|
assert cfg.enable_igpsport is True
|
|
13
13
|
assert cfg.igp_region == "international"
|
|
14
14
|
assert cfg.enable_bryton is False
|
|
15
|
+
assert cfg.bryton_region == "international"
|
|
15
16
|
assert cfg.delete_after_upload is True
|
|
16
17
|
assert cfg.force_resync is False
|
|
17
18
|
assert cfg.activity_type == "" # "" = leave the uploaded sport untouched
|
|
@@ -81,6 +82,7 @@ def test_save_and_load_bryton_flags(tmp_path, monkeypatch):
|
|
|
81
82
|
enable_igpsport=False,
|
|
82
83
|
enable_bryton=True,
|
|
83
84
|
bryton_user="rider@example.com",
|
|
85
|
+
bryton_region="china",
|
|
84
86
|
)
|
|
85
87
|
config_module.save(cfg)
|
|
86
88
|
|
|
@@ -88,6 +90,7 @@ def test_save_and_load_bryton_flags(tmp_path, monkeypatch):
|
|
|
88
90
|
assert loaded.enable_igpsport is False
|
|
89
91
|
assert loaded.enable_bryton is True
|
|
90
92
|
assert loaded.bryton_user == "rider@example.com"
|
|
93
|
+
assert loaded.bryton_region == "china"
|
|
91
94
|
assert "activity_source" not in json.loads(path.read_text(encoding="utf-8"))
|
|
92
95
|
|
|
93
96
|
|
|
@@ -81,6 +81,11 @@ def test_parse_igp_start_time_formats():
|
|
|
81
81
|
|
|
82
82
|
def test_dropbox_filename_for_dotted_date():
|
|
83
83
|
act = core.Activity(1, "Ride", "2026.07.19")
|
|
84
|
+
assert core.dropbox_filename_for(act) == "igpsport_1.fit"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_dropbox_filename_preserves_actual_midnight():
|
|
88
|
+
act = core.Activity(1, "Ride", "2026-07-19 00:00:00")
|
|
84
89
|
assert core.dropbox_filename_for(act) == "ride-0-2026-07-19-00-00-00.fit"
|
|
85
90
|
|
|
86
91
|
|
|
@@ -474,7 +479,7 @@ def test_list_activities_paginates_when_api_caps_page_size():
|
|
|
474
479
|
|
|
475
480
|
def test_list_activities_caps_when_server_returns_extra():
|
|
476
481
|
class FakeSession:
|
|
477
|
-
def get(self, url, params=None):
|
|
482
|
+
def get(self, url, params=None, **kwargs):
|
|
478
483
|
rows = [{"rideId": i, "title": "", "startTime": ""} for i in range(20)]
|
|
479
484
|
return FakeResponse(json_data={"data": {"rows": rows}})
|
|
480
485
|
|
|
@@ -482,6 +487,68 @@ def test_list_activities_caps_when_server_returns_extra():
|
|
|
482
487
|
assert len(acts) == 5
|
|
483
488
|
|
|
484
489
|
|
|
490
|
+
@pytest.mark.parametrize("region", ["international", "china"])
|
|
491
|
+
def test_list_activities_enriches_date_only_times_from_detail(region):
|
|
492
|
+
calls = []
|
|
493
|
+
|
|
494
|
+
class FakeSession:
|
|
495
|
+
def get(self, url, params=None, **kwargs):
|
|
496
|
+
calls.append(url)
|
|
497
|
+
if "queryMyActivity" in url:
|
|
498
|
+
return FakeResponse(json_data={"data": {"rows": [
|
|
499
|
+
{"rideId": 1, "startTime": "2026.10.27"},
|
|
500
|
+
{"rideId": 2, "startTime": "2026.10.27"},
|
|
501
|
+
]}})
|
|
502
|
+
ride_id = int(url.rsplit("/", 1)[1])
|
|
503
|
+
return FakeResponse(json_data={"data": {
|
|
504
|
+
"startTime": f"2026-10-27 {ride_id + 7:02}:42:08",
|
|
505
|
+
"fitUrl": f"https://fit.test/{ride_id}.fit",
|
|
506
|
+
}})
|
|
507
|
+
|
|
508
|
+
activities = core.list_activities(FakeSession(), 2, region)
|
|
509
|
+
assert [act.start_time for act in activities] == [
|
|
510
|
+
"2026-10-27 08:42:08", "2026-10-27 09:42:08",
|
|
511
|
+
]
|
|
512
|
+
assert [core.dropbox_filename_for(act) for act in activities] == [
|
|
513
|
+
"ride-0-2026-10-27-08-42-08.fit", "ride-0-2026-10-27-09-42-08.fit",
|
|
514
|
+
]
|
|
515
|
+
assert activities[0].fit_url == "https://fit.test/1.fit"
|
|
516
|
+
assert len(calls) == 3
|
|
517
|
+
assert all(url.startswith(core.resolve_region(region).gateway_base) for url in calls)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@pytest.mark.parametrize("detail", [None, [], {"startTime": "2026.10.27"},
|
|
521
|
+
{"startTime": "invalid"}])
|
|
522
|
+
def test_list_activities_keeps_date_without_inventing_time(detail):
|
|
523
|
+
class FakeSession:
|
|
524
|
+
def get(self, url, **kwargs):
|
|
525
|
+
if "queryMyActivity" in url:
|
|
526
|
+
return FakeResponse(json_data={"data": {"rows": [
|
|
527
|
+
{"rideId": 1, "startTime": "2026.10.27"},
|
|
528
|
+
]}})
|
|
529
|
+
return FakeResponse(json_data={"data": detail})
|
|
530
|
+
|
|
531
|
+
activity, = core.list_activities(FakeSession(), 1)
|
|
532
|
+
assert activity.start_time == "2026.10.27"
|
|
533
|
+
assert core.dropbox_filename_for(activity) == "igpsport_1.fit"
|
|
534
|
+
assert core._activity_date_range([activity]) == (date(2026, 10, 26), date(2026, 10, 28))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@pytest.mark.parametrize("error", [core.requests.Timeout(), ValueError("bad JSON")])
|
|
538
|
+
def test_list_activities_survives_unavailable_detail(error):
|
|
539
|
+
class FakeSession:
|
|
540
|
+
def get(self, url, **kwargs):
|
|
541
|
+
if "queryMyActivity" in url:
|
|
542
|
+
return FakeResponse(json_data={"data": {"rows": [
|
|
543
|
+
{"rideId": 1, "startTime": "2026.10.27"},
|
|
544
|
+
]}})
|
|
545
|
+
raise error
|
|
546
|
+
|
|
547
|
+
activity, = core.list_activities(FakeSession(), 1)
|
|
548
|
+
assert activity.start_time == "2026.10.27"
|
|
549
|
+
assert core.dropbox_filename_for(activity) == "igpsport_1.fit"
|
|
550
|
+
|
|
551
|
+
|
|
485
552
|
# --------------------------------------------------------------------------- #
|
|
486
553
|
# sync() orchestration
|
|
487
554
|
# --------------------------------------------------------------------------- #
|
|
@@ -490,6 +557,7 @@ def test_list_activities_caps_when_server_returns_extra():
|
|
|
490
557
|
def stub_sync(monkeypatch, tmp_path):
|
|
491
558
|
"""Replace every network leaf so sync() runs offline. Returns a recorder."""
|
|
492
559
|
rec = {
|
|
560
|
+
"list_activities": core.list_activities,
|
|
493
561
|
"downloaded": [],
|
|
494
562
|
"uploaded": [],
|
|
495
563
|
"dropbox": [],
|
|
@@ -598,6 +666,47 @@ def _config(tmp_path, **overrides):
|
|
|
598
666
|
return core.SyncConfig(**base)
|
|
599
667
|
|
|
600
668
|
|
|
669
|
+
def test_sync_uses_detail_times_for_output_and_dropbox_skip_checks(monkeypatch, stub_sync):
|
|
670
|
+
from intervalssync.cli.main import _igpsport_result_payload
|
|
671
|
+
|
|
672
|
+
class FakeSession:
|
|
673
|
+
def get(self, url, **kwargs):
|
|
674
|
+
if "queryMyActivity" in url:
|
|
675
|
+
return FakeResponse(json_data={"data": {"rows": [
|
|
676
|
+
{"rideId": 1, "startTime": "2026.10.27"},
|
|
677
|
+
{"rideId": 2, "startTime": "2026.10.27"},
|
|
678
|
+
]}})
|
|
679
|
+
ride_id = int(url.rsplit("/", 1)[1])
|
|
680
|
+
return FakeResponse(json_data={"data": {
|
|
681
|
+
"startTime": f"2026-10-27 {ride_id + 7:02}:42:08",
|
|
682
|
+
"fitUrl": f"https://fit.test/{ride_id}.fit",
|
|
683
|
+
}})
|
|
684
|
+
|
|
685
|
+
monkeypatch.setattr(core.requests, "Session", FakeSession)
|
|
686
|
+
monkeypatch.setattr(core, "list_activities", stub_sync["list_activities"])
|
|
687
|
+
|
|
688
|
+
def unexpected_resolve(*args):
|
|
689
|
+
pytest.fail("The FIT URL from the detail response should be reused")
|
|
690
|
+
|
|
691
|
+
monkeypatch.setattr(core, "resolve_fit_url", unexpected_resolve)
|
|
692
|
+
stub_sync["dropbox_existing"] = {
|
|
693
|
+
"ride-0-2026-10-27-00-00-00.fit",
|
|
694
|
+
"ride-0-2026-10-27-08-42-08.fit",
|
|
695
|
+
}
|
|
696
|
+
messages = []
|
|
697
|
+
result = core.sync(_config(
|
|
698
|
+
stub_sync["tmp"], max_activities=2, list_activities=True,
|
|
699
|
+
upload_intervals=False, upload_dropbox=True,
|
|
700
|
+
), messages.append)
|
|
701
|
+
assert result.skipped_dropbox == 1
|
|
702
|
+
assert result.uploaded_dropbox == 1
|
|
703
|
+
assert stub_sync["dropbox"][0][0] == "ride-0-2026-10-27-09-42-08.fit"
|
|
704
|
+
assert stub_sync["downloaded"] == ["igpsport_2.fit"]
|
|
705
|
+
assert any("2026-10-27 09:42:08" in message for message in messages)
|
|
706
|
+
payload = _igpsport_result_payload(result, ok=True)
|
|
707
|
+
assert payload["activities"][1]["start_time"] == "2026-10-27 09:42:08"
|
|
708
|
+
|
|
709
|
+
|
|
601
710
|
def test_sync_rejects_malformed_activity_discovery_before_fit_work(monkeypatch, tmp_path):
|
|
602
711
|
resolved = []
|
|
603
712
|
downloaded = []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit
RENAMED
|
File without changes
|
{intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|