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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
The Garmin Connect API library comes with two examples:

- **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls
- **`demo.py`** - Comprehensive demo providing access to **158+ API methods** organized into **14 categories** for easy navigation
- **`demo.py`** - Comprehensive demo providing access to **159+ API methods** organized into **14 categories** for easy navigation

```bash
$ ./demo.py
Expand Down Expand Up @@ -50,13 +50,13 @@ Make your selection:

## API Coverage Statistics

- **Total API Methods**: 158+ methods (snapshot)
- **Total API Methods**: 159+ methods (snapshot)
- **Categories**: 14 organized sections
- **User & Profile**: 4 methods (basic user info, settings)
- **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges)
- **Advanced Health Metrics**: 19 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance, training load balance, daily training status, activity training load)
- **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates)
- **Activities & Workouts**: 38 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, activity type/subtype filtering, earliest-upcoming-workout lookup, activity photos)
- **Activities & Workouts**: 39 methods (comprehensive activity, workout management, typed workout uploads including strength and swim CSS targets, in-place edit, scheduling, push to device, import, activity type/subtype filtering, earliest-upcoming-workout lookup, activity photos)
- **Body Composition & Weight**: 7 methods (weight tracking, body composition)
- **Goals & Achievements**: 15 methods (challenges, badges, goals)
- **Device & Technical**: 7 methods (device info, settings)
Expand Down Expand Up @@ -128,7 +128,7 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e ".[example]"

python3 ./example.py # simple getting-started example
python3 ./demo.py # comprehensive demo (158+ API methods)
python3 ./demo.py # comprehensive demo (159+ API methods)
```

## 🛠️ Development
Expand Down Expand Up @@ -420,6 +420,29 @@ client.unschedule_workout(scheduled_workout_id)
client.push_workout_to_device(result["workoutId"], device_id)
```

**Swimming CSS targets:** `SwimCssOffsetTarget` represents a signed offset from
Critical Swim Speed, rather than an absolute pace range. Supply it as the
secondary target; the primary target can be omitted (Garmin `no.target`):

```python
from garminconnect.workout import (
SwimCssOffsetTarget,
create_targeted_distance_interval_step,
)

step = create_targeted_distance_interval_step(
100.0,
step_order=1,
secondary_target=SwimCssOffsetTarget(offset_seconds=5.0),
)
```

Positive offsets are slower than CSS; negative offsets are faster. The offset
is serialized as `secondaryTargetValueOne`, with `secondaryTargetValueTwo=0`.
The time-based targeted helper also accepts secondary-only targets. These
helpers retain their existing interval step/end-condition behavior; swimming
stroke, rest, and pool settings are configured separately.

**Available workout classes:** `RunningWorkout`, `CyclingWorkout`, `SwimmingWorkout`, `WalkingWorkout`, `HikingWorkout`, `StrengthWorkout`, `MultiSportWorkout`, `FitnessEquipmentWorkout`

**Strength workouts** are rep-based. Build each exercise with `create_strength_set(category, step_order, sets, reps, rest_seconds, exercise_name="", weight_kg=None)` and identify exercises with a `category` / `exercise` pair from the bundled catalog in `garminconnect.exercises` (1,527 exercises across 47 categories, with `resolve(name)` and `find(term)` helpers):
Expand Down Expand Up @@ -515,7 +538,7 @@ activity has photos attached.

### Additional Resources
- **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide
- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 158+ API methods
- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 159+ API methods
- **API Documentation**: Comprehensive method documentation in source code
- **Test Cases**: Real-world usage examples in `tests/` directory

Expand Down
70 changes: 70 additions & 0 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ def __init__(self):
"desc": "Get photos attached to an activity (interactive)",
"key": "get_activity_images",
},
"D": {
"desc": "Upload typed swimming workout with CSS offset targets (sample)",
"key": "upload_swimming_css_workout",
},
},
},
"6": {
Expand Down Expand Up @@ -2641,6 +2645,69 @@ def upload_swimming_workout_data(api: Garmin) -> None:
print(f"❌ Error uploading swimming workout: {e}")


def _find_interval_step(steps: list[dict]) -> dict | None:
"""Return the first interval step, searching inside repeat groups."""
for step in steps:
if step.get("stepType", {}).get("stepTypeKey") == "interval":
return step
nested = _find_interval_step(step.get("workoutSteps") or [])
if nested:
return nested
return None


def upload_swimming_css_workout_data(api: Garmin) -> None:
"""Upload a typed swimming workout with CSS-offset targets, then read it back."""
try:
import sys
from pathlib import Path

test_data_path = Path(__file__).parent / "test_data"
if str(test_data_path) not in sys.path:
sys.path.insert(0, str(test_data_path))

from sample_swimming_workout import create_sample_swimming_css_workout

print("🏊 Creating and uploading swimming workout with CSS +5s intervals...")
workout = create_sample_swimming_css_workout()
result = api.upload_swimming_workout(workout)
if not result or not result.get("workoutId"):
print("❌ Failed to upload swimming CSS workout")
return
workout_id = result["workoutId"]
print(f"✅ Uploaded workout {workout_id}; reading it back from Garmin...")

# Show what Garmin actually stored, so the CSS target can be checked
# against what was sent (type 17 'swim.css.offset', value 5.0).
stored = api.get_workout_by_id(workout_id)
steps = (stored.get("workoutSegments") or [{}])[0].get("workoutSteps") or []
interval = _find_interval_step(steps)
if interval is None:
print("⚠️ No interval step found in the stored workout")
return
call_and_display(
lambda: {
key: interval.get(key)
for key in (
"targetType",
"secondaryTargetType",
"secondaryTargetValueOne",
"secondaryTargetValueTwo",
)
},
method_name="get_workout_by_id",
api_call_desc=f"api.get_workout_by_id({workout_id}) - stored interval targets",
)
print("💡 Also open the workout in Garmin Connect to check it shows CSS +5s")
except ImportError as e:
print(f"❌ Error: {e}")
print(
"💡 Install pydantic with: pip install pydantic or pip install garminconnect[workout]"
)
except Exception as e:
print(f"❌ Error uploading swimming CSS workout: {e}")


def upload_walking_workout_data(api: Garmin) -> None:
"""Upload a typed walking workout."""
try:
Expand Down Expand Up @@ -4945,6 +5012,9 @@ def execute_api_call(api: Garmin, key: str) -> None:
"upload_running_workout": lambda: upload_running_workout_data(api),
"upload_cycling_workout": lambda: upload_cycling_workout_data(api),
"upload_swimming_workout": lambda: upload_swimming_workout_data(api),
"upload_swimming_css_workout": lambda: upload_swimming_css_workout_data(
api
),
"upload_walking_workout": lambda: upload_walking_workout_data(api),
"upload_hiking_workout": lambda: upload_hiking_workout_data(api),
"upload_strength_workout": lambda: upload_strength_workout_data(api),
Expand Down
62 changes: 44 additions & 18 deletions garminconnect/workout.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class TargetType:
HEART_RATE_LAP = 8
POWER_LAP = 9
RESISTANCE = 15
SWIM_CSS_OFFSET = 17


# Weight unit for strength workout target loads.
Expand Down Expand Up @@ -406,6 +407,27 @@ class PaceTarget(_RangeTarget):
target_type_key: ClassVar[str] = "pace.zone"


class SwimCssOffsetTarget(BaseModel):
"""Secondary swim target relative to the configured Critical Swim Speed.

``offset_seconds`` is a signed pace offset, not an absolute speed in m/s.
Positive values indicate a slower pace and negative values a faster pace.
Garmin stores the offset in secondaryTargetValueOne and uses zero for
secondaryTargetValueTwo. No range ordering applies to this target.
"""

target_type: ClassVar[int] = TargetType.SWIM_CSS_OFFSET
target_type_key: ClassVar[str] = "swim.css.offset"
zone_number: ClassVar[None] = None
upper_limit: ClassVar[float] = 0.0
offset_seconds: float = Field(allow_inf_nan=False)

@property
def lower_limit(self) -> float:
"""Return the offset stored in Garmin's first target value."""
return self.offset_seconds


IntensityTarget = _RangeTarget | _ZoneTarget


Expand Down Expand Up @@ -495,19 +517,23 @@ def create_interval_step(


def _target_kwargs(
target: IntensityTarget,
secondary_target: IntensityTarget | None,
target: IntensityTarget | None,
secondary_target: IntensityTarget | SwimCssOffsetTarget | None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"target_type": {
"workoutTargetTypeId": target.target_type,
"workoutTargetTypeKey": target.target_type_key,
"displayOrder": 1,
},
"target_value_one": target.lower_limit,
"target_value_two": target.upper_limit,
"zone_number": target.zone_number,
}
if isinstance(target, SwimCssOffsetTarget):
raise ValueError("SwimCssOffsetTarget must be supplied as secondary_target")
kwargs: dict[str, Any] = {}
if target is not None:
kwargs = {
"target_type": {
"workoutTargetTypeId": target.target_type,
"workoutTargetTypeKey": target.target_type_key,
"displayOrder": 1,
},
"target_value_one": target.lower_limit,
"target_value_two": target.upper_limit,
"zone_number": target.zone_number,
}
if secondary_target is not None:
kwargs |= {
"secondary_target_type": {
Expand All @@ -525,10 +551,10 @@ def _target_kwargs(
def create_targeted_interval_step(
duration_seconds: float,
step_order: int,
target: IntensityTarget,
secondary_target: IntensityTarget | None = None,
target: IntensityTarget | None = None,
secondary_target: IntensityTarget | SwimCssOffsetTarget | None = None,
) -> ExecutableStep:
"""Create a targeted interval step."""
"""Create a targeted interval step, optionally with only a secondary target."""
return create_interval_step(
duration_seconds, step_order, **_target_kwargs(target, secondary_target)
)
Expand Down Expand Up @@ -580,10 +606,10 @@ def create_distance_interval_step(
def create_targeted_distance_interval_step(
distance_meters: float,
step_order: int,
target: IntensityTarget,
secondary_target: IntensityTarget | None = None,
target: IntensityTarget | None = None,
secondary_target: IntensityTarget | SwimCssOffsetTarget | None = None,
) -> ExecutableStep:
"""Create a targeted interval step that ends after a distance in meters."""
"""Create a targeted distance step, optionally with only a secondary target."""
return create_distance_interval_step(
distance_meters, step_order, **_target_kwargs(target, secondary_target)
)
Expand Down
44 changes: 44 additions & 0 deletions test_data/sample_swimming_workout.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Sample swimming workout data using typed workout models."""

from garminconnect.workout import (
SwimCssOffsetTarget,
SwimmingWorkout,
WorkoutSegment,
create_cooldown_step,
create_interval_step,
create_recovery_step,
create_repeat_group,
create_targeted_interval_step,
create_warmup_step,
)

Expand Down Expand Up @@ -42,3 +44,45 @@ def create_sample_swimming_workout() -> SwimmingWorkout:
)
],
)


def create_sample_swimming_css_workout() -> SwimmingWorkout:
"""Create a sample swimming workout with CSS-offset interval targets.

Same structure as ``create_sample_swimming_workout`` (time-based steps),
except each interval carries a secondary ``SwimCssOffsetTarget`` of
+5 seconds, so the CSS target is the only difference between the two.
"""
return SwimmingWorkout(
workoutName="Swimming CSS Intervals",
description="A sample swimming workout with intervals at CSS +5 seconds.",
estimatedDurationInSecs=2400, # 40 minutes
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType={
"sportTypeId": 3,
"sportTypeKey": "swimming",
"displayOrder": 3,
},
workoutSteps=[
create_warmup_step(300.0, step_order=1), # 5 min warmup
create_repeat_group(
iterations=8,
workout_steps=[
create_targeted_interval_step(
90.0,
step_order=2,
secondary_target=SwimCssOffsetTarget(
offset_seconds=5.0
),
), # 1.5 min interval at CSS +5s
create_recovery_step(30.0, step_order=3), # 30 sec recovery
],
step_order=2,
),
create_cooldown_step(180.0, step_order=3), # 3 min cooldown
],
)
],
)
1 change: 1 addition & 0 deletions tests/test_workout_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def test_target_type_ids() -> None:
assert TargetType.HEART_RATE_LAP == 8
assert TargetType.POWER_LAP == 9
assert TargetType.RESISTANCE == 15
assert TargetType.SWIM_CSS_OFFSET == 17


def test_step_type_ids() -> None:
Expand Down
Loading
Loading