intervalssync 0.9.3__py3-none-any.whl
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/__init__.py +3 -0
- intervalssync/_dropbox_app_key.py +7 -0
- intervalssync/_version.py +1 -0
- intervalssync/bryton/__init__.py +1 -0
- intervalssync/bryton/api.py +49 -0
- intervalssync/bryton/core.py +249 -0
- intervalssync/bryton/ddp.py +231 -0
- intervalssync/bryton/exceptions.py +13 -0
- intervalssync/bryton/fit_encode.py +363 -0
- intervalssync/bryton/workout.py +342 -0
- intervalssync/cli/__init__.py +5 -0
- intervalssync/cli/config.py +53 -0
- intervalssync/cli/env.py +189 -0
- intervalssync/cli/main.py +637 -0
- intervalssync/dropbox_client.py +143 -0
- intervalssync/gui/__init__.py +1 -0
- intervalssync/gui/app.py +352 -0
- intervalssync/gui/config.py +102 -0
- intervalssync/gui/profile_sync_ui.py +225 -0
- intervalssync/gui/secrets.py +135 -0
- intervalssync/gui/settings_view.py +716 -0
- intervalssync/gui/support_gamification.py +658 -0
- intervalssync/gui/sync_view.py +486 -0
- intervalssync/gui/system.py +21 -0
- intervalssync/gui/theme.py +216 -0
- intervalssync/gui/update_check.py +55 -0
- intervalssync/igpsport/__init__.py +1 -0
- intervalssync/igpsport/core.py +520 -0
- intervalssync/igpsport/interval_info.py +269 -0
- intervalssync/igpsport/profile_sync.py +391 -0
- intervalssync/igpsport/region.py +80 -0
- intervalssync/igpsport/workout.py +503 -0
- intervalssync/igpsport/zone_map.py +143 -0
- intervalssync/intervals_icu.py +201 -0
- intervalssync-0.9.3.dist-info/METADATA +179 -0
- intervalssync-0.9.3.dist-info/RECORD +39 -0
- intervalssync-0.9.3.dist-info/WHEEL +4 -0
- intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
- intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Map intervals.icu zone definitions onto iGPSPORT fixed zone slots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
POWER_INTERIOR_CAP = 1999
|
|
8
|
+
POWER_LAST_ZONE_END = 2500
|
|
9
|
+
# intervals.icu's open-ended top power zone (e.g. "151%+") has no real upper
|
|
10
|
+
# bound; its API reports this zone's upper bound as this sentinel percentage
|
|
11
|
+
# rather than omitting it.
|
|
12
|
+
OPEN_ZONE_SENTINEL_PCT = 999
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def power_upper_bounds_watts(power_zones_pct: list[float], ftp: float) -> list[int]:
|
|
16
|
+
"""Convert intervals % FTP upper bounds to absolute watt boundaries."""
|
|
17
|
+
if ftp <= 0 or not power_zones_pct:
|
|
18
|
+
return []
|
|
19
|
+
return [int(round(ftp * pct / 100)) for pct in power_zones_pct]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hr_upper_bounds_bpm(hr_zones_bpm: list[float], max_hr: float) -> list[int]:
|
|
23
|
+
"""Return intervals HR zone upper bounds capped at max_hr."""
|
|
24
|
+
if max_hr <= 0 or not hr_zones_bpm:
|
|
25
|
+
return []
|
|
26
|
+
cap = int(round(max_hr))
|
|
27
|
+
bounds = [int(round(bpm)) for bpm in hr_zones_bpm]
|
|
28
|
+
bounds[-1] = min(bounds[-1], cap)
|
|
29
|
+
return bounds
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _prepare_intervals_ends(intervals_ends: list[int], slot_count: int, cap: int) -> list[int]:
|
|
33
|
+
"""Drop open-ended last intervals bound when extra iGPSPORT slots need the cap."""
|
|
34
|
+
ends = list(intervals_ends)
|
|
35
|
+
n = len(ends)
|
|
36
|
+
p = slot_count
|
|
37
|
+
pad_count = p - n
|
|
38
|
+
if pad_count > 0 and n >= 2 and ends[-1] >= cap - pad_count:
|
|
39
|
+
ends = ends[:-1]
|
|
40
|
+
return ends
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _enforce_strictly_increasing(ends: list[int], cap: int) -> list[int]:
|
|
44
|
+
"""Ensure zone end values strictly increase and the last is capped."""
|
|
45
|
+
if not ends:
|
|
46
|
+
return ends
|
|
47
|
+
out = [int(value) for value in ends]
|
|
48
|
+
for index in range(1, len(out)):
|
|
49
|
+
if out[index] <= out[index - 1]:
|
|
50
|
+
out[index] = out[index - 1] + 1
|
|
51
|
+
out[-1] = min(out[-1], cap)
|
|
52
|
+
if len(out) > 1 and out[-1] <= out[-2]:
|
|
53
|
+
index = len(out) - 2
|
|
54
|
+
while index > 0 and out[index] >= out[index + 1]:
|
|
55
|
+
out[index] = out[index + 1] - 1
|
|
56
|
+
index -= 1
|
|
57
|
+
if out[0] >= out[1]:
|
|
58
|
+
out[0] = max(0, out[1] - 1)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _slot_end_values(intervals_ends: list[int], slot_count: int, cap: int) -> list[int]:
|
|
63
|
+
"""Map intervals zone count onto a fixed iGPSPORT slot count."""
|
|
64
|
+
if slot_count <= 0:
|
|
65
|
+
return []
|
|
66
|
+
if not intervals_ends:
|
|
67
|
+
return list(range(cap, cap - slot_count, -1))
|
|
68
|
+
|
|
69
|
+
ends = _prepare_intervals_ends(intervals_ends, slot_count, cap)
|
|
70
|
+
n = len(ends)
|
|
71
|
+
p = slot_count
|
|
72
|
+
pad_count = p - n
|
|
73
|
+
|
|
74
|
+
if n == p:
|
|
75
|
+
result = list(ends)
|
|
76
|
+
elif n < p:
|
|
77
|
+
result = list(ends)
|
|
78
|
+
for index in range(pad_count):
|
|
79
|
+
result.append(cap - pad_count + 1 + index)
|
|
80
|
+
else:
|
|
81
|
+
result = list(ends[: p - 1])
|
|
82
|
+
result.append(ends[-1])
|
|
83
|
+
|
|
84
|
+
return _enforce_strictly_increasing(result, cap)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _apply_end_values(
|
|
88
|
+
end_values: list[int],
|
|
89
|
+
igpsport_zones: list[dict[str, Any]],
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
"""Rewrite start/end on iGPSPORT zone dicts, preserving other fields."""
|
|
92
|
+
out: list[dict[str, Any]] = []
|
|
93
|
+
start = 0
|
|
94
|
+
for index, zone in enumerate(igpsport_zones):
|
|
95
|
+
if not isinstance(zone, dict):
|
|
96
|
+
continue
|
|
97
|
+
updated = dict(zone)
|
|
98
|
+
updated["start"] = start
|
|
99
|
+
updated["end"] = end_values[index]
|
|
100
|
+
start = end_values[index]
|
|
101
|
+
out.append(updated)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def map_power_zones(
|
|
106
|
+
intervals_pct: list[float],
|
|
107
|
+
ftp: float,
|
|
108
|
+
igpsport_zones: list[dict[str, Any]],
|
|
109
|
+
) -> list[dict[str, Any]]:
|
|
110
|
+
"""Map intervals.icu power zones onto iGPSPORT power slots."""
|
|
111
|
+
if not igpsport_zones:
|
|
112
|
+
return []
|
|
113
|
+
intervals_ends = power_upper_bounds_watts(intervals_pct, ftp)
|
|
114
|
+
if (
|
|
115
|
+
intervals_pct
|
|
116
|
+
and intervals_pct[-1] >= OPEN_ZONE_SENTINEL_PCT
|
|
117
|
+
and len(intervals_ends) > 1
|
|
118
|
+
):
|
|
119
|
+
# Drop the open-ended zone's sentinel bound; POWER_LAST_ZONE_END covers it.
|
|
120
|
+
intervals_ends = intervals_ends[:-1]
|
|
121
|
+
slot_count = len(igpsport_zones)
|
|
122
|
+
if slot_count == 1:
|
|
123
|
+
slot_ends = [POWER_LAST_ZONE_END]
|
|
124
|
+
else:
|
|
125
|
+
interior = _slot_end_values(intervals_ends, slot_count - 1, POWER_INTERIOR_CAP)
|
|
126
|
+
interior[-1] = min(interior[-1], POWER_INTERIOR_CAP)
|
|
127
|
+
interior = _enforce_strictly_increasing(interior, POWER_INTERIOR_CAP)
|
|
128
|
+
slot_ends = interior + [POWER_LAST_ZONE_END]
|
|
129
|
+
return _apply_end_values(slot_ends, igpsport_zones)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def map_hr_zones(
|
|
133
|
+
intervals_bpm: list[float],
|
|
134
|
+
max_hr: float,
|
|
135
|
+
igpsport_zones: list[dict[str, Any]],
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
"""Map intervals.icu HR zones onto iGPSPORT heart-rate slots."""
|
|
138
|
+
if not igpsport_zones:
|
|
139
|
+
return []
|
|
140
|
+
cap = int(round(max_hr))
|
|
141
|
+
intervals_ends = hr_upper_bounds_bpm(intervals_bpm, max_hr)
|
|
142
|
+
slot_ends = _slot_end_values(intervals_ends, len(igpsport_zones), cap)
|
|
143
|
+
return _apply_end_values(slot_ends, igpsport_zones)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Shared intervals.icu API helpers.
|
|
2
|
+
|
|
3
|
+
Activity upload/dedup, calendar workout fetch, and sport-settings lookup.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
INTERVALS_UPLOAD_URL = "https://intervals.icu/api/v1/athlete/0/activities"
|
|
16
|
+
INTERVALS_ACTIVITIES_URL = "https://intervals.icu/api/v1/athlete/0/activities"
|
|
17
|
+
INTERVALS_ACTIVITY_URL = "https://intervals.icu/api/v1/activity"
|
|
18
|
+
INTERVALS_EVENTS_URL = "https://intervals.icu/api/v1/athlete/0/events"
|
|
19
|
+
INTERVALS_SPORT_SETTINGS_URL = "https://intervals.icu/api/v1/athlete/0/sport-settings"
|
|
20
|
+
INTERVALS_ATHLETE_URL = "https://intervals.icu/api/v1/athlete/0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CalendarWorkout:
|
|
25
|
+
event_id: int
|
|
26
|
+
name: str
|
|
27
|
+
description: str
|
|
28
|
+
activity_type: str
|
|
29
|
+
workout_doc: dict[str, Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SportSettings:
|
|
34
|
+
ftp: float | None
|
|
35
|
+
lthr: float | None
|
|
36
|
+
max_hr: float | None
|
|
37
|
+
power_zones: list[float]
|
|
38
|
+
hr_zones: list[float]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _num(value: Any) -> float | None:
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return float(value)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _num_list(value: Any) -> list[float]:
|
|
51
|
+
if not isinstance(value, list):
|
|
52
|
+
return []
|
|
53
|
+
out: list[float] = []
|
|
54
|
+
for item in value:
|
|
55
|
+
num = _num(item)
|
|
56
|
+
if num is not None:
|
|
57
|
+
out.append(num)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def upload_fit_file(
|
|
62
|
+
fit_path: Path, title: str, external_id: str, api_key: str
|
|
63
|
+
) -> str | None:
|
|
64
|
+
"""Upload a .fit file; return the new activity id or None on failure."""
|
|
65
|
+
with fit_path.open("rb") as f:
|
|
66
|
+
resp = requests.post(
|
|
67
|
+
INTERVALS_UPLOAD_URL,
|
|
68
|
+
params={"name": title, "external_id": external_id},
|
|
69
|
+
files={"file": (fit_path.name, f, "application/octet-stream")},
|
|
70
|
+
auth=("API_KEY", api_key),
|
|
71
|
+
)
|
|
72
|
+
if resp.status_code not in (200, 201):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
data = resp.json()
|
|
76
|
+
activities = data.get("activities") or []
|
|
77
|
+
if activities and activities[0].get("id"):
|
|
78
|
+
return activities[0]["id"]
|
|
79
|
+
return data.get("id")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_activity_type(activity_id: str, activity_type: str, api_key: str) -> bool:
|
|
83
|
+
"""Set an activity's sport on intervals.icu."""
|
|
84
|
+
resp = requests.put(
|
|
85
|
+
f"{INTERVALS_ACTIVITY_URL}/{activity_id}",
|
|
86
|
+
json={"type": activity_type},
|
|
87
|
+
auth=("API_KEY", api_key),
|
|
88
|
+
)
|
|
89
|
+
return resp.ok
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fetch_uploaded_external_ids(
|
|
93
|
+
api_key: str, oldest: date, newest: date
|
|
94
|
+
) -> set[str]:
|
|
95
|
+
"""Return external_ids already on intervals.icu in a date range."""
|
|
96
|
+
resp = requests.get(
|
|
97
|
+
INTERVALS_ACTIVITIES_URL,
|
|
98
|
+
params={"oldest": oldest.isoformat(), "newest": newest.isoformat()},
|
|
99
|
+
auth=("API_KEY", api_key),
|
|
100
|
+
)
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
return {
|
|
103
|
+
a["external_id"]
|
|
104
|
+
for a in resp.json()
|
|
105
|
+
if isinstance(a, dict) and a.get("external_id")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fetch_calendar_workouts(
|
|
110
|
+
api_key: str,
|
|
111
|
+
oldest: date,
|
|
112
|
+
newest: date,
|
|
113
|
+
*,
|
|
114
|
+
http: requests.Session | None = None,
|
|
115
|
+
) -> list[CalendarWorkout]:
|
|
116
|
+
"""Fetch planned workouts from the intervals.icu calendar."""
|
|
117
|
+
client = http or requests.Session()
|
|
118
|
+
resp = client.get(
|
|
119
|
+
INTERVALS_EVENTS_URL,
|
|
120
|
+
params={
|
|
121
|
+
"category": "WORKOUT",
|
|
122
|
+
"resolve": "true",
|
|
123
|
+
"oldest": oldest.isoformat(),
|
|
124
|
+
"newest": newest.isoformat(),
|
|
125
|
+
},
|
|
126
|
+
auth=("API_KEY", api_key),
|
|
127
|
+
timeout=30,
|
|
128
|
+
)
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
workouts: list[CalendarWorkout] = []
|
|
131
|
+
for event in resp.json():
|
|
132
|
+
if event.get("category") != "WORKOUT":
|
|
133
|
+
continue
|
|
134
|
+
workout_doc = event.get("workout_doc")
|
|
135
|
+
if not isinstance(workout_doc, dict):
|
|
136
|
+
continue
|
|
137
|
+
workouts.append(
|
|
138
|
+
CalendarWorkout(
|
|
139
|
+
event_id=int(event["id"]),
|
|
140
|
+
name=str(event.get("name") or "Workout"),
|
|
141
|
+
description=str(event.get("description") or ""),
|
|
142
|
+
activity_type=str(event.get("type") or "Ride"),
|
|
143
|
+
workout_doc=workout_doc,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return workouts
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fetch_sport_settings(
|
|
150
|
+
api_key: str,
|
|
151
|
+
sport: str = "Ride",
|
|
152
|
+
*,
|
|
153
|
+
http: requests.Session | None = None,
|
|
154
|
+
) -> SportSettings:
|
|
155
|
+
"""Return athlete thresholds and zone definitions from intervals.icu sport settings."""
|
|
156
|
+
client = http or requests.Session()
|
|
157
|
+
resp = client.get(
|
|
158
|
+
f"{INTERVALS_SPORT_SETTINGS_URL}/{sport}",
|
|
159
|
+
auth=("API_KEY", api_key),
|
|
160
|
+
timeout=30,
|
|
161
|
+
)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
data = resp.json()
|
|
164
|
+
if not isinstance(data, dict):
|
|
165
|
+
return SportSettings(None, None, None, [], [])
|
|
166
|
+
return SportSettings(
|
|
167
|
+
ftp=_num(data.get("ftp")),
|
|
168
|
+
lthr=_num(data.get("lthr")),
|
|
169
|
+
max_hr=_num(data.get("max_hr")),
|
|
170
|
+
power_zones=_num_list(data.get("power_zones")),
|
|
171
|
+
hr_zones=_num_list(data.get("hr_zones")),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def fetch_sport_settings_max_hr(
|
|
176
|
+
api_key: str,
|
|
177
|
+
sport: str,
|
|
178
|
+
*,
|
|
179
|
+
http: requests.Session | None = None,
|
|
180
|
+
) -> float | None:
|
|
181
|
+
"""Return athlete max HR (bpm) from intervals.icu sport settings."""
|
|
182
|
+
return fetch_sport_settings(api_key, sport, http=http).max_hr
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def fetch_athlete_weight(
|
|
186
|
+
api_key: str,
|
|
187
|
+
*,
|
|
188
|
+
http: requests.Session | None = None,
|
|
189
|
+
) -> float | None:
|
|
190
|
+
"""Return athlete weight in kg from intervals.icu (`icu_weight`, else `weight`)."""
|
|
191
|
+
client = http or requests.Session()
|
|
192
|
+
resp = client.get(
|
|
193
|
+
INTERVALS_ATHLETE_URL,
|
|
194
|
+
auth=("API_KEY", api_key),
|
|
195
|
+
timeout=30,
|
|
196
|
+
)
|
|
197
|
+
resp.raise_for_status()
|
|
198
|
+
data = resp.json()
|
|
199
|
+
if not isinstance(data, dict):
|
|
200
|
+
return None
|
|
201
|
+
return _num(data.get("icu_weight")) or _num(data.get("weight"))
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: intervalssync
|
|
3
|
+
Version: 0.9.3
|
|
4
|
+
Summary: Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu
|
|
5
|
+
Project-URL: Homepage, https://github.com/jorge-huxley/intervalssync
|
|
6
|
+
Project-URL: Repository, https://github.com/jorge-huxley/intervalssync
|
|
7
|
+
Project-URL: Issues, https://github.com/jorge-huxley/intervalssync/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: <3.14,>=3.13
|
|
11
|
+
Requires-Dist: dropbox>=12.0.2
|
|
12
|
+
Requires-Dist: flet-permission-handler==0.86.1
|
|
13
|
+
Requires-Dist: flet-secure-storage==0.86.1
|
|
14
|
+
Requires-Dist: flet==0.86.1
|
|
15
|
+
Requires-Dist: garmin-fit-sdk>=21.208.0
|
|
16
|
+
Requires-Dist: packaging>=26.2
|
|
17
|
+
Requires-Dist: platformdirs>=4.0
|
|
18
|
+
Requires-Dist: requests>=2.33.1
|
|
19
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<img src="assets/icon.png" width="120" alt="Intervals Sync logo">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<h1 align="center">Intervals Sync</h1>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<strong>The missing intervals.icu sync tool for iGPSPORT and Bryton users.</strong>
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/jorge-huxley/intervalssync?style=flat" alt="License: MIT" /></a>
|
|
34
|
+
<a href="https://github.com/jorge-huxley/intervalssync/releases"><img src="https://img.shields.io/github/v/release/jorge-huxley/intervalssync?style=flat&sort=semver" alt="GitHub release" /></a>
|
|
35
|
+
<a href="https://github.com/jorge-huxley/intervalssync/releases"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Android-lightgrey?style=flat" alt="Platforms" /></a>
|
|
36
|
+
<a href="https://github.com/jorge-huxley/intervalssync/stargazers"><img src="https://img.shields.io/github/stars/jorge-huxley/intervalssync?style=flat&label=stars" alt="GitHub stars" /></a>
|
|
37
|
+
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://ko-fi.com/jorge_huxley"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi" /></a>
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
<p align="center">
|
|
45
|
+
Enter your credentials once, press <strong>Sync</strong>, and your latest rides from
|
|
46
|
+
<strong>iGPSPORT</strong> or <strong>Bryton Active</strong> land on <strong>intervals.icu</strong>. You can also push
|
|
47
|
+
planned workouts from intervals.icu back to iGPSPORT or Bryton Active.
|
|
48
|
+
Free and open source, for <strong>Windows</strong>, <strong>macOS</strong>, and <strong>Android</strong>.
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<p align="center">
|
|
52
|
+
<a href="https://github.com/jorge-huxley/intervalssync/releases/latest">Download</a> ·
|
|
53
|
+
<a href="docs/AGENT.md">CLI & agents</a> ·
|
|
54
|
+
<a href="CONTRIBUTING.md">Contributing</a>
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
<br>
|
|
60
|
+
|
|
61
|
+
<p align="center">
|
|
62
|
+
<img src="docs/img/demo.gif" alt="Intervals Sync syncing rides to intervals.icu, skipping ones already uploaded" width="300">
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+
<details>
|
|
66
|
+
<summary><b>More screenshots</b></summary>
|
|
67
|
+
<br>
|
|
68
|
+
<p align="center">
|
|
69
|
+
<img src="docs/img/sync.png" alt="Sync screen" width="260">
|
|
70
|
+
|
|
71
|
+
<img src="docs/img/settings.png" alt="Settings screen with secure credential fields" width="260">
|
|
72
|
+
</p>
|
|
73
|
+
</details>
|
|
74
|
+
|
|
75
|
+
## Features
|
|
76
|
+
|
|
77
|
+
- Syncs recent rides from **iGPSPORT** or **Bryton Active** to **intervals.icu** (original `.fit` files)
|
|
78
|
+
- **Optional Dropbox upload** — mirror activities to a Dropbox folder from the GUI (iGPSPORT or Bryton sync)
|
|
79
|
+
- **Upload workouts** — push planned cycling workouts from your intervals.icu calendar to iGPSPORT custom workouts or Bryton Active (sync to your head unit from the vendor app)
|
|
80
|
+
- **Skips activities already uploaded** so re-running is safe — with an optional *force re-sync*
|
|
81
|
+
- Lets you choose how many recent activities to process
|
|
82
|
+
- **Workout upload window** in Settings — how many calendar days to upload (default: today only)
|
|
83
|
+
- **Sets the intervals.icu sport type** after upload (e.g. Mountain Bike Ride / Gravel Ride) — iGPSPORT exports everything as a generic "Ride"
|
|
84
|
+
- Optionally deletes the local `.fit` files after a successful upload
|
|
85
|
+
- Stores your credentials in the **OS secure vault** (Windows Credential Manager / macOS Keychain / Android Keystore), never in a file
|
|
86
|
+
- Lets you know when a newer version is available
|
|
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
|
+
- **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
|
+
- **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
|
+
|
|
91
|
+
> 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
|
+
## Download & run (Windows)
|
|
94
|
+
|
|
95
|
+
1. Go to the [Releases](../../releases) page and download the latest `.zip`.
|
|
96
|
+
2. Unzip it anywhere and double-click the app (e.g. `intervalssync.exe`).
|
|
97
|
+
3. On first launch, open **Settings** and enter:
|
|
98
|
+
- 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))
|
|
99
|
+
- your intervals.icu **API key** (intervals.icu → Settings → Developer)
|
|
100
|
+
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
|
+
|
|
102
|
+
Your password and API key are stored in your operating system's **secure
|
|
103
|
+
credential store** — Windows Credential Manager on Windows (the same vault
|
|
104
|
+
Windows uses for its own logins) — never in a plain text file.
|
|
105
|
+
|
|
106
|
+
## Download & run (macOS)
|
|
107
|
+
|
|
108
|
+
1. Go to the [Releases](../../releases) page and download the latest `macos.zip`.
|
|
109
|
+
2. Unzip it and move the app to **Applications** if you want.
|
|
110
|
+
3. Open the app, fill in **Settings** (same fields as above), then **Sync** or **Upload to iGPSPORT** / **Upload to Bryton**.
|
|
111
|
+
|
|
112
|
+
On macOS your credentials are stored in Keychain. Release builds are not
|
|
113
|
+
notarized yet, so macOS may require right-clicking the app and choosing
|
|
114
|
+
**Open** the first time.
|
|
115
|
+
|
|
116
|
+
## Download & run (Android)
|
|
117
|
+
|
|
118
|
+
1. On the [Releases](../../releases) page, download the latest `.apk`.
|
|
119
|
+
2. Open it on your phone. Android will ask you to allow installing from this
|
|
120
|
+
source — accept (Settings → "Install unknown apps" for your browser/files app).
|
|
121
|
+
3. Open the app, fill in **Settings** (same fields as above), then **Sync** or **Upload to iGPSPORT** / **Upload to Bryton**.
|
|
122
|
+
|
|
123
|
+
On Android your credentials are stored in the **Android Keystore**. The app
|
|
124
|
+
isn't on the Play Store, so the "unknown source" prompt is expected.
|
|
125
|
+
|
|
126
|
+
## CLI & automation (AI agents)
|
|
127
|
+
|
|
128
|
+
Headless `intervalssync` CLI — sync from iGPSPORT or Bryton, upload workouts to iGPSPORT or Bryton, sync thresholds and zones to iGPSPORT, JSON on stdout. See [Agent / headless sync](docs/AGENT.md).
|
|
129
|
+
|
|
130
|
+
With [uv](https://docs.astral.sh/uv/) installed, run the latest stable release
|
|
131
|
+
directly from PyPI—no clone or manual upgrade required:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uvx --python 3.13 intervalssync@latest --version
|
|
135
|
+
uvx --python 3.13 intervalssync@latest sync --json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Run from source
|
|
139
|
+
|
|
140
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv sync
|
|
144
|
+
cp .env.example .env # optional: credentials for CLI; IGPSYNC_DROPBOX_APP_KEY for Dropbox
|
|
145
|
+
uv run --env-file .env intervalssync-gui
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Build the desktop apps
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
uv run flet build windows
|
|
152
|
+
uv run flet build macos
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The distributables land in `build/windows/` and `build/macos/`. (Flet downloads
|
|
156
|
+
the Flutter toolchain on the first build; macOS builds require a macOS host.)
|
|
157
|
+
|
|
158
|
+
## Roadmap
|
|
159
|
+
|
|
160
|
+
The app is built with [Flet](https://flet.dev), so the same Python code targets
|
|
161
|
+
desktop and Android today, with **iOS** (`flet build ipa`) possible from the same
|
|
162
|
+
code.
|
|
163
|
+
|
|
164
|
+
## Support
|
|
165
|
+
|
|
166
|
+
Tips on [Ko-fi](https://ko-fi.com/jorge_huxley) help fund bug fixes, releases, and new features. Thank you!
|
|
167
|
+
|
|
168
|
+
## Acknowledgements
|
|
169
|
+
|
|
170
|
+
This project builds on community work around iGPSPORT activity access and
|
|
171
|
+
syncing:
|
|
172
|
+
|
|
173
|
+
- [kamikadzem22/igpsport-unoffical-api](https://github.com/kamikadzem22/igpsport-unoffical-api)
|
|
174
|
+
for documenting and exploring the unofficial iGPSPORT API.
|
|
175
|
+
- [simple4wan/ride-sync](https://github.com/simple4wan/ride-sync) for prior
|
|
176
|
+
work on syncing ride activities between services (including China-region iGPSPORT).
|
|
177
|
+
|
|
178
|
+
This app is an independent project and is not affiliated with iGPSPORT,
|
|
179
|
+
intervals.icu, or the projects listed above.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
intervalssync/__init__.py,sha256=tfaMpqkN3HrMSDRvfJTb70ybI4Vv9clRn_61MEkLSL0,101
|
|
2
|
+
intervalssync/_dropbox_app_key.py,sha256=4H1xeJzDWSU2xsPJuyZ40GIHA_-udSdm0fP3AUsXGbI,211
|
|
3
|
+
intervalssync/_version.py,sha256=xKd3pzbczuMsdB08eLAOqZDUd_q1IRxwZ_ccAFL4c4A,22
|
|
4
|
+
intervalssync/dropbox_client.py,sha256=_rkj_wf_cm_XmY-nfPdYav58AWeURXDIG9g26bGOybc,4685
|
|
5
|
+
intervalssync/intervals_icu.py,sha256=O7E5l0EKCAzvXHkttbDjbGKoCjWxiSWux-ES7DmL2Ec,5792
|
|
6
|
+
intervalssync/bryton/__init__.py,sha256=3n9KqkxkOEMxm8NCDAwjFpQbABakoHHZfPO6VIZBoH0,29
|
|
7
|
+
intervalssync/bryton/api.py,sha256=D_pH1hgUqR41xz2Tt603OA-Yh8iDSGv1PhQnNlWFcJ4,1385
|
|
8
|
+
intervalssync/bryton/core.py,sha256=nzucPMb_1Z2inXFTMSK81WZ6PoNzfZ8tYhS865BNrVg,8913
|
|
9
|
+
intervalssync/bryton/ddp.py,sha256=BEWaYdJ1SFB9Oz0MTCgpqx4elYGOVMUioUWigAyRQ_w,7756
|
|
10
|
+
intervalssync/bryton/exceptions.py,sha256=fMp244M9wy10zF_Xo0Ay7RhZ4QJHwKAgl7Qs-9z3ZDg,278
|
|
11
|
+
intervalssync/bryton/fit_encode.py,sha256=i_zn5yM9JdXk4Cp-uDthOWMlb5RcEwm1uDb90r3RwME,10910
|
|
12
|
+
intervalssync/bryton/workout.py,sha256=bxFiKZPT9uMbHGrnfempICLlWCD1D2y5fkUC7aE7Qas,10942
|
|
13
|
+
intervalssync/cli/__init__.py,sha256=W500E5Z06b_ThlFP0aXa_S-4qNpH7qR_U7SnZ93KRxc,104
|
|
14
|
+
intervalssync/cli/config.py,sha256=88dcx8rEOV0lNXEZBJL3cDLqyFXPe6EmgRdZS_lWMzw,1736
|
|
15
|
+
intervalssync/cli/env.py,sha256=TlDHk7orexUgdMOBJDLFG6qHri_5EKC8ZihzzmdXDio,6187
|
|
16
|
+
intervalssync/cli/main.py,sha256=f6hrmTAps4IwwPcGULh1OpLKZRb45INZKz47xEkeBFM,20752
|
|
17
|
+
intervalssync/gui/__init__.py,sha256=dBeFR4kFHTFyRbuTPQgS1rks-d6fzi28C_FhTqr1Ik8,34
|
|
18
|
+
intervalssync/gui/app.py,sha256=dF8AuKGI4FMz7rK5r54aySKPaRAaCjCVK_DB_qg1044,12483
|
|
19
|
+
intervalssync/gui/config.py,sha256=KbxNkw8wm8SeKTglPvuph4RcWAuwm1EsFClp7V1ekbI,4000
|
|
20
|
+
intervalssync/gui/profile_sync_ui.py,sha256=Mrhk6e9I_Me1Vmv3-GEN_CTz92IXHbI23USw863KMA4,6929
|
|
21
|
+
intervalssync/gui/secrets.py,sha256=DehSkxpDovNaWB3cIkCs-jyX8EkwEUuM4zSAG2oh6Og,4114
|
|
22
|
+
intervalssync/gui/settings_view.py,sha256=Yuknxb6xd6K2j0tau7G43VgJjIaUfxJidN0N3kAMbQ8,25858
|
|
23
|
+
intervalssync/gui/support_gamification.py,sha256=tpy0YZdIpLHloina-V9gSZNb6aQ7QdjKkw3bVEFXVyI,19477
|
|
24
|
+
intervalssync/gui/sync_view.py,sha256=AZzaVNCN_9pH5e2yDyhlZ5D-F7GxQFPjlWaVXaMXNss,19191
|
|
25
|
+
intervalssync/gui/system.py,sha256=bEHI5weqM2jhpRtvp89mTPFVLC1YgK2FtdwbJSgvrAk,622
|
|
26
|
+
intervalssync/gui/theme.py,sha256=7Ws6qY8Ym2UvCmlk9sM4y_9063WwsrBUUvprFf_x8M0,5750
|
|
27
|
+
intervalssync/gui/update_check.py,sha256=BmqOEJjBmF9xNixjZ46floF475Z_cqilTl_qQ-jqyOY,1673
|
|
28
|
+
intervalssync/igpsport/__init__.py,sha256=mHBTahHy1YeOIQc2Fl4J74lf2ruQPCBfhOOCW6WJaBE,60
|
|
29
|
+
intervalssync/igpsport/core.py,sha256=m7CB5P88fPBQe0IBmZ4nSlCtDQcnUDxxgdWDTwu_Njk,18257
|
|
30
|
+
intervalssync/igpsport/interval_info.py,sha256=v34SGmcp2fBRdk_e7CGE8xfX1mxcxoM8XyyBlhsXbiM,9558
|
|
31
|
+
intervalssync/igpsport/profile_sync.py,sha256=wJBhYf3urs-4Aj38nE7fBUU3KNCX2e-lnVGghIU7X3E,12716
|
|
32
|
+
intervalssync/igpsport/region.py,sha256=wInjMU2a7h5bV0lgXmP_H5rkytndB0BJJ2l74rFZQTo,2556
|
|
33
|
+
intervalssync/igpsport/workout.py,sha256=zONmbRBW_OoYyBMc1p_p3FBr8fq-YHpMwmvbWEK5myc,15919
|
|
34
|
+
intervalssync/igpsport/zone_map.py,sha256=pHNslrqXsE6mU5C1RtN53Apti_6A6hqV6QA0F5lehV0,4848
|
|
35
|
+
intervalssync-0.9.3.dist-info/METADATA,sha256=HJMG2jZzFsOnAhgDpLyApdf07U5R2fxiebolVOS_fL8,8521
|
|
36
|
+
intervalssync-0.9.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
37
|
+
intervalssync-0.9.3.dist-info/entry_points.txt,sha256=U4Qpukl4QbpkrmSTDtLx-2ji4NdZ5BW3K7qC-k8ljns,119
|
|
38
|
+
intervalssync-0.9.3.dist-info/licenses/LICENSE,sha256=qiocLTBHBzicxsKt3f85zKhssp0X0NeoBF1dpoYYVPk,1068
|
|
39
|
+
intervalssync-0.9.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jorge Silva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|