574 lines
15 KiB
Python
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)
|