Files
python-garminconnect/garminconnect/workout.py

574 lines
15 KiB
Python

"""Typed workout models for Garmin Connect workouts.
This module provides Pydantic models for creating type-safe workout definitions.
Pydantic is an optional dependency - install it with: pip install pydantic
or: pip install garminconnect[workout]
"""
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pydantic import BaseModel, ConfigDict, Field
else:
try:
from pydantic import BaseModel, ConfigDict, Field
except ImportError:
# Fallback if pydantic is not installed
BaseModel = object # type: ignore[assignment,misc]
ConfigDict = dict # type: ignore[assignment,misc]
def Field(*_args: Any, **_kwargs: Any) -> Any: # type: ignore[misc]
"""Placeholder Field function when pydantic is not installed."""
return None
# Sport Type IDs — from /workout-service/workout/types
class SportType:
"""Garmin workout sport type IDs."""
RUNNING = 1
CYCLING = 2
OTHER = 3
SWIMMING = 4
STRENGTH_TRAINING = 5
CARDIO_TRAINING = 6
YOGA = 7
PILATES = 8
HIIT = 9
MULTI_SPORT = 10
MOBILITY = 11
# Step Type IDs — from /workout-service/workout/types
class StepType:
"""Garmin workout step type IDs."""
WARMUP = 1
COOLDOWN = 2
INTERVAL = 3
RECOVERY = 4
REST = 5
REPEAT = 6
OTHER = 7
MAIN = 8
# Condition Type IDs — from /workout-service/workout/types
class ConditionType:
"""Garmin end condition type IDs."""
LAP_BUTTON = 1
TIME = 2
DISTANCE = 3
CALORIES = 4
POWER = 5
HEART_RATE = 6
ITERATIONS = 7
FIXED_REST = 8
FIXED_REPETITION = 9
REPS = 10
# Target Type IDs — from /workout-service/workout/types
class TargetType:
"""Garmin workout target type IDs."""
NO_TARGET = 1
POWER_ZONE = 2
CADENCE = 3
HEART_RATE_ZONE = 4
SPEED_ZONE = 5
PACE_ZONE = 6
GRADE = 7
HEART_RATE_LAP = 8
POWER_LAP = 9
RESISTANCE = 15
# Weight unit for strength workout target loads.
# Garmin stores ``weightValue`` in GRAMS tagged with this kilogram unit.
WEIGHT_UNIT_KILOGRAM = {"unitId": 8, "unitKey": "kilogram", "factor": 1000.0}
class SportTypeModel(BaseModel):
"""Sport type model."""
sportTypeId: int
sportTypeKey: str
displayOrder: int = 1
class EndConditionModel(BaseModel):
"""End condition model for workout steps."""
conditionTypeId: int
conditionTypeKey: str
displayOrder: int
displayable: bool = True
class TargetTypeModel(BaseModel):
"""Target type model for workout steps."""
workoutTargetTypeId: int
workoutTargetTypeKey: str
displayOrder: int
class StrokeTypeModel(BaseModel):
"""Stroke type model (for swimming workouts)."""
strokeTypeId: int = 0
displayOrder: int = 0
class EquipmentTypeModel(BaseModel):
"""Equipment type model."""
equipmentTypeId: int = 0
displayOrder: int = 0
class ExecutableStep(BaseModel):
"""Executable workout step (warmup, interval, recovery, cooldown, etc.)."""
type: str = "ExecutableStepDTO"
stepOrder: int
stepType: dict[str, Any] | None = None
endCondition: dict[str, Any] | None = None
endConditionValue: float | None = None
targetType: dict[str, Any] | None = None
strokeType: dict[str, Any] | None = None
equipmentType: dict[str, Any] | None = None
childStepId: int | None = None
model_config = ConfigDict(extra="allow")
class RepeatGroup(BaseModel):
"""Repeat group for repeating workout steps."""
type: str = "RepeatGroupDTO"
stepOrder: int
stepType: dict[str, Any] | None = None
numberOfIterations: int
workoutSteps: list[ExecutableStep | RepeatGroup]
endCondition: dict[str, Any] | None = None
endConditionValue: float | None = None
childStepId: int | None = None
smartRepeat: bool = False
model_config = ConfigDict(extra="allow")
# Update forward reference (only if pydantic is available)
with suppress(AttributeError, TypeError):
RepeatGroup.model_rebuild()
class WorkoutSegment(BaseModel):
"""Workout segment containing workout steps."""
segmentOrder: int
sportType: dict[str, Any]
workoutSteps: list[ExecutableStep | RepeatGroup]
model_config = ConfigDict(extra="allow")
class BaseWorkout(BaseModel):
"""Base workout model."""
workoutName: str
sportType: dict[str, Any]
estimatedDurationInSecs: int
workoutSegments: list[WorkoutSegment]
author: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
model_config = ConfigDict(extra="allow")
def to_dict(self) -> dict[str, Any]:
"""Convert workout to dictionary for API upload."""
return self.model_dump(exclude_none=True, mode="json")
class RunningWorkout(BaseWorkout):
"""Running workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.RUNNING,
"sportTypeKey": "running",
"displayOrder": 1,
}
)
class CyclingWorkout(BaseWorkout):
"""Cycling workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.CYCLING,
"sportTypeKey": "cycling",
"displayOrder": 2,
}
)
class SwimmingWorkout(BaseWorkout):
"""Swimming workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.SWIMMING,
"sportTypeKey": "swimming",
"displayOrder": 3,
}
)
class WalkingWorkout(BaseWorkout):
"""Walking workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": 17,
"sportTypeKey": "walking",
"displayOrder": 17,
}
)
class MultiSportWorkout(BaseWorkout):
"""Multi-sport workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.MULTI_SPORT,
"sportTypeKey": "multi_sport",
"displayOrder": 10,
}
)
class FitnessEquipmentWorkout(BaseWorkout):
"""Fitness equipment workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.CARDIO_TRAINING,
"sportTypeKey": "cardio_training",
"displayOrder": 6,
}
)
class HikingWorkout(BaseWorkout):
"""Hiking workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": 18,
"sportTypeKey": "hiking",
"displayOrder": 18,
}
)
class StrengthWorkout(BaseWorkout):
"""Strength training workout model.
Strength workouts are rep-based rather than time/distance-based. Build the
steps with :func:`create_strength_exercise_step` /
:func:`create_strength_rest_step` (or the :func:`create_strength_set`
convenience), and identify each exercise with a ``category`` /
``exerciseName`` pair from :mod:`garminconnect.exercises`.
"""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.STRENGTH_TRAINING,
"sportTypeKey": "strength_training",
"displayOrder": 5,
}
)
# Helper functions for creating common workout steps
def create_warmup_step(
duration_seconds: float,
step_order: int = 1,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create a warmup step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.WARMUP,
"stepTypeKey": "warmup",
"displayOrder": 1,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_interval_step(
duration_seconds: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create an interval step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_distance_interval_step(
distance_meters: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create an interval step that ends after a distance in meters."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.DISTANCE,
"conditionTypeKey": "distance",
"displayOrder": 3,
"displayable": True,
},
endConditionValue=distance_meters,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_recovery_step(
duration_seconds: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create a recovery step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.RECOVERY,
"stepTypeKey": "recovery",
"displayOrder": 4,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_cooldown_step(
duration_seconds: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create a cooldown step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.COOLDOWN,
"stepTypeKey": "cooldown",
"displayOrder": 2,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_repeat_group(
iterations: int,
workout_steps: list[ExecutableStep | RepeatGroup],
step_order: int,
) -> RepeatGroup:
"""Create a repeat group."""
return RepeatGroup(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.REPEAT,
"stepTypeKey": "repeat",
"displayOrder": 6,
},
numberOfIterations=iterations,
workoutSteps=workout_steps,
endCondition={
"conditionTypeId": ConditionType.ITERATIONS,
"conditionTypeKey": "iterations",
"displayOrder": 7,
"displayable": False,
},
endConditionValue=float(iterations),
)
def create_strength_exercise_step(
category: str,
step_order: int,
reps: int,
exercise_name: str = "",
weight_kg: float | None = None,
) -> ExecutableStep:
"""Create a rep-based strength exercise step.
Args:
category: Garmin exercise category, e.g. ``"BENCH_PRESS"``. See
:mod:`garminconnect.exercises` for the full list of valid values.
step_order: Position of this step within the segment (1-indexed, unique).
reps: Number of repetitions to perform.
exercise_name: Specific exercise variant, e.g. ``"LAT_PULLDOWN"``. An
empty string shows only the category name.
weight_kg: Optional target weight in kilograms.
"""
extra: dict[str, Any] = {"category": category, "exerciseName": exercise_name}
if weight_kg is not None:
extra["weightValue"] = float(weight_kg) * 1000.0
extra["weightUnit"] = dict(WEIGHT_UNIT_KILOGRAM)
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.REPS,
"conditionTypeKey": "reps",
"displayOrder": 10,
"displayable": True,
},
endConditionValue=float(reps),
targetType={
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
**extra,
)
def create_strength_rest_step(
duration_seconds: float,
step_order: int,
) -> ExecutableStep:
"""Create a timed rest step between strength sets."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.REST,
"stepTypeKey": "rest",
"displayOrder": 5,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=float(duration_seconds),
targetType={
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_strength_set(
category: str,
step_order: int,
sets: int,
reps: int,
rest_seconds: float,
exercise_name: str = "",
weight_kg: float | None = None,
) -> RepeatGroup:
"""Create a full strength exercise block as a repeat group.
Produces ``sets`` repetitions of ``reps`` reps of the exercise followed by
a timed rest, i.e. one "N Sets" block in the Garmin workout editor.
``step_order`` is the order of the repeat group; the inner exercise and rest
steps take ``step_order + 1`` and ``step_order + 2``. Advance the caller's
running order counter by 3 for each block so every ``stepOrder`` is unique.
"""
exercise = create_strength_exercise_step(
category,
step_order + 1,
reps,
exercise_name=exercise_name,
weight_kg=weight_kg,
)
rest = create_strength_rest_step(rest_seconds, step_order + 2)
return create_repeat_group(sets, [exercise, rest], step_order)