diff --git a/README.md b/README.md index 5d48792f..bd5618c1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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 @@ -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): @@ -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 diff --git a/demo.py b/demo.py index 7cd5213e..db89ab80 100755 --- a/demo.py +++ b/demo.py @@ -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": { @@ -2641,6 +2645,77 @@ 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). + # The upload already succeeded, so report a readback failure as such + # rather than as an upload error that might prompt a duplicate upload. + try: + stored = api.get_workout_by_id(workout_id) + except Exception as e: + print( + f"⚠️ Workout {workout_id} was uploaded, but reading it back failed: {e}" + ) + return + 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: @@ -4945,6 +5020,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), diff --git a/garminconnect/workout.py b/garminconnect/workout.py index 17e75fe1..3805866c 100644 --- a/garminconnect/workout.py +++ b/garminconnect/workout.py @@ -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. @@ -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 @@ -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": { @@ -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) ) @@ -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) ) diff --git a/test_data/sample_swimming_workout.py b/test_data/sample_swimming_workout.py index 8502f36b..a565a6bd 100644 --- a/test_data/sample_swimming_workout.py +++ b/test_data/sample_swimming_workout.py @@ -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, ) @@ -16,7 +18,7 @@ def create_sample_swimming_workout() -> SwimmingWorkout: return SwimmingWorkout( workoutName="Swimming Interval Training", description="A sample swimming interval workout with warmup, multiple intervals with recovery, and cooldown.", - estimatedDurationInSecs=2400, # 40 minutes + estimatedDurationInSecs=1440, # 24 minutes: 300 + 8 x (90 + 30) + 180 workoutSegments=[ WorkoutSegment( segmentOrder=1, @@ -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=1440, # 24 minutes: 300 + 8 x (90 + 30) + 180 + 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 + ], + ) + ], + ) diff --git a/tests/test_workout_constants.py b/tests/test_workout_constants.py index 641a1cff..57caff64 100644 --- a/tests/test_workout_constants.py +++ b/tests/test_workout_constants.py @@ -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: diff --git a/tests/test_workout_targets.py b/tests/test_workout_targets.py index 85183f39..4ebee9cf 100644 --- a/tests/test_workout_targets.py +++ b/tests/test_workout_targets.py @@ -14,6 +14,8 @@ PowerZoneTarget, SpeedTarget, StepType, + SwimCssOffsetTarget, + SwimmingWorkout, TargetType, WorkoutSegment, create_distance_interval_step, @@ -203,6 +205,92 @@ def test_targets_survive_workout_serialization(): ) +@pytest.mark.parametrize("offset", [5.0, 0.0, -3.5]) +@pytest.mark.parametrize( + "factory, condition, value", + [ + (create_targeted_interval_step, ConditionType.TIME, 120.0), + (create_targeted_distance_interval_step, ConditionType.DISTANCE, 100.0), + ], +) +def test_css_offset_is_secondary_with_no_primary_target( + offset, factory, condition, value +): + data = _dump( + factory( + value, + step_order=2, + secondary_target=SwimCssOffsetTarget(offset_seconds=offset), + ) + ) + assert data["targetType"] == { + "workoutTargetTypeId": TargetType.NO_TARGET, + "workoutTargetTypeKey": "no.target", + "displayOrder": 1, + } + assert data["secondaryTargetType"] == { + "workoutTargetTypeId": 17, + "workoutTargetTypeKey": "swim.css.offset", + "displayOrder": 2, + } + assert data["secondaryTargetValueOne"] == offset + assert data["secondaryTargetValueTwo"] == 0.0 + assert data["endCondition"]["conditionTypeId"] == condition + assert data["endConditionValue"] == value + assert data["stepOrder"] == 2 + for key in ( + "targetValueOne", + "targetValueTwo", + "zoneNumber", + "secondaryZoneNumber", + ): + assert key not in data + + +@pytest.mark.parametrize("offset", [float("nan"), float("inf"), float("-inf")]) +def test_css_offset_rejects_non_finite_values(offset): + with pytest.raises(ValidationError): + SwimCssOffsetTarget(offset_seconds=offset) + + +@pytest.mark.parametrize( + "factory", [create_targeted_interval_step, create_targeted_distance_interval_step] +) +def test_css_offset_rejects_primary_placement(factory): + with pytest.raises(ValueError, match="must be supplied as secondary_target"): + factory(100.0, step_order=1, target=SwimCssOffsetTarget(offset_seconds=5)) + + +def test_secondary_only_helper_keeps_untargeted_payload_unchanged(): + assert _dump(create_targeted_interval_step(120.0, 1)) == _dump( + create_interval_step(120.0, 1) + ) + assert _dump(create_targeted_distance_interval_step(100.0, 1)) == _dump( + create_distance_interval_step(100.0, 1) + ) + + +def test_css_offset_survives_swimming_workout_serialization(): + step = create_targeted_distance_interval_step( + 100.0, 1, secondary_target=SwimCssOffsetTarget(offset_seconds=5) + ) + workout = SwimmingWorkout( + workoutName="CSS intervals", + estimatedDurationInSecs=0, + workoutSegments=[ + WorkoutSegment( + segmentOrder=1, + sportType={"sportTypeId": 3, "sportTypeKey": "swimming"}, + workoutSteps=[step], + ) + ], + ) + data = workout.to_dict()["workoutSegments"][0]["workoutSteps"][0] + assert data["secondaryTargetType"]["workoutTargetTypeId"] == 17 + assert data["secondaryTargetValueOne"] == 5.0 + assert data["secondaryTargetValueTwo"] == 0.0 + + def test_repeat_group_can_skip_its_last_rest_step(): """Garmin's repeat editor supports skipping the final recovery/rest step.""" group = create_repeat_group(