0.3.11 with curl_cffi as optional tls extra (FreeBSD support)
This commit is contained in:
573
garminconnect/workout.py
Normal file
573
garminconnect/workout.py
Normal file
@ -0,0 +1,573 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user