0.3.11 with curl_cffi as optional tls extra (FreeBSD support)

This commit is contained in:
2026-09-11 18:41:55 -04:00
commit 2720927aa7
12 changed files with 11315 additions and 0 deletions

3743
garminconnect/__init__.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
"""Helpers for parsing the positional metrics returned by activity detail endpoints."""
from __future__ import annotations
from typing import Any
def parse_activity_detail_metrics(details: dict[str, Any]) -> list[dict[str, Any]]:
"""Resolve positional activity detail samples into per-sample dicts keyed by metric name.
`details` is the raw response from `Garmin.get_activity_details()`. Each sample in
`activityDetailMetrics` stores values positionally in `metrics`; the position-to-name
mapping is given by `metricDescriptors[].metricsIndex`/`key` and varies by device and
activity type. Descriptors with a missing, non-int, or negative `metricsIndex` are
skipped, and a sample missing a channel entirely (index out of range for that sample)
simply omits that key rather than raising. Duration keys (`sumDuration`,
`sumElapsedDuration`, `sumMovingDuration`) are not equivalent and are passed through
unchanged under their own names — callers must pick the one they mean.
"""
index_to_key: dict[int, str] = {}
for descriptor in details.get("metricDescriptors") or []:
key = descriptor.get("key")
index = descriptor.get("metricsIndex")
if (
not isinstance(key, str)
or not isinstance(index, int)
or isinstance(index, bool)
):
continue
if index < 0:
continue
index_to_key[index] = key
parsed: list[dict[str, Any]] = []
for sample in details.get("activityDetailMetrics") or []:
metrics = sample.get("metrics") or []
parsed.append(
{
key: metrics[index]
for index, key in index_to_key.items()
if index < len(metrics)
}
)
return parsed

1734
garminconnect/client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
from typing import Any
class GarminConnectConnectionError(Exception):
"""Raised when communication ended in error.
``response`` carries the original HTTP response when the error wraps an
HTTP failure, so callers can inspect status codes and bodies.
"""
response: Any = None
class GarminConnectNotFoundError(GarminConnectConnectionError):
"""Raised when a requested resource does not exist (HTTP 404).
Subclasses GarminConnectConnectionError for backwards compatibility, so
existing ``except GarminConnectConnectionError`` handlers keep working while
callers can now catch a missing resource specifically (e.g. deleting an
already-deleted workout).
"""
class GarminConnectTooManyRequestsError(Exception):
"""Raised when rate limit is exceeded."""
class GarminConnectAuthenticationError(Exception):
"""Raised when authentication is failed."""
class GarminConnectInvalidFileFormatError(Exception):
"""Raised when an invalid file format is provided."""

2635
garminconnect/exercises.py Normal file

File diff suppressed because it is too large Load Diff

518
garminconnect/fit.py Normal file
View File

@ -0,0 +1,518 @@
# type: ignore # Complex binary data handling - mypy errors expected
import time
from datetime import datetime
from io import BytesIO
from struct import pack, unpack
from typing import Any
def _calcCRC(crc: int, byte: int) -> int:
table = [
0x0000,
0xCC01,
0xD801,
0x1400,
0xF001,
0x3C00,
0x2800,
0xE401,
0xA001,
0x6C00,
0x7800,
0xB401,
0x5000,
0x9C01,
0x8801,
0x4400,
]
# compute checksum of lower four bits of byte
tmp = table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
crc = crc ^ tmp ^ table[byte & 0xF]
# now compute checksum of upper four bits of byte
tmp = table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
return crc ^ tmp ^ table[(byte >> 4) & 0xF]
class FitBaseType:
"""BaseType Definition.
see FIT Protocol Document(Page.20)
"""
enum = {
"#": 0,
"endian": 0,
"field": 0x00,
"name": "enum",
"invalid": 0xFF,
"size": 1,
}
sint8 = {
"#": 1,
"endian": 0,
"field": 0x01,
"name": "sint8",
"invalid": 0x7F,
"size": 1,
}
uint8 = {
"#": 2,
"endian": 0,
"field": 0x02,
"name": "uint8",
"invalid": 0xFF,
"size": 1,
}
sint16 = {
"#": 3,
"endian": 1,
"field": 0x83,
"name": "sint16",
"invalid": 0x7FFF,
"size": 2,
}
uint16 = {
"#": 4,
"endian": 1,
"field": 0x84,
"name": "uint16",
"invalid": 0xFFFF,
"size": 2,
}
sint32 = {
"#": 5,
"endian": 1,
"field": 0x85,
"name": "sint32",
"invalid": 0x7FFFFFFF,
"size": 4,
}
uint32 = {
"#": 6,
"endian": 1,
"field": 0x86,
"name": "uint32",
"invalid": 0xFFFFFFFF,
"size": 4,
}
string = {
"#": 7,
"endian": 0,
"field": 0x07,
"name": "string",
"invalid": 0x00,
"size": 1,
}
float32 = {
"#": 8,
"endian": 1,
"field": 0x88,
"name": "float32",
"invalid": 0xFFFFFFFF,
"size": 2,
}
float64 = {
"#": 9,
"endian": 1,
"field": 0x89,
"name": "float64",
"invalid": 0xFFFFFFFFFFFFFFFF,
"size": 4,
}
uint8z = {
"#": 10,
"endian": 0,
"field": 0x0A,
"name": "uint8z",
"invalid": 0x00,
"size": 1,
}
uint16z = {
"#": 11,
"endian": 1,
"field": 0x8B,
"name": "uint16z",
"invalid": 0x0000,
"size": 2,
}
uint32z = {
"#": 12,
"endian": 1,
"field": 0x8C,
"name": "uint32z",
"invalid": 0x00000000,
"size": 4,
}
byte = {
"#": 13,
"endian": 0,
"field": 0x0D,
"name": "byte",
"invalid": 0xFF,
"size": 1,
} # array of byte, field is invalid if all bytes are invalid
@staticmethod
def get_format(basetype: int) -> str:
formats = {
0: "B",
1: "b",
2: "B",
3: "h",
4: "H",
5: "i",
6: "I",
7: "s",
8: "f",
9: "d",
10: "B",
11: "H",
12: "I",
13: "c",
}
return formats[basetype["#"]]
@staticmethod
def pack(basetype: dict[str, Any], value: Any) -> bytes:
"""Function to avoid DeprecationWarning."""
if basetype["#"] in (1, 2, 3, 4, 5, 6, 10, 11, 12):
value = int(value)
fmt = FitBaseType.get_format(basetype)
return pack(fmt, value)
class Fit:
HEADER_SIZE = 12
# not sure if this is the mesg_num
GMSG_NUMS = {
"file_id": 0,
"device_info": 23,
"weight_scale": 30,
"file_creator": 49,
"blood_pressure": 51,
}
class FitEncoder(Fit):
FILE_TYPE = 9
LMSG_TYPE_FILE_INFO = 0
LMSG_TYPE_FILE_CREATOR = 1
LMSG_TYPE_DEVICE_INFO = 2
def __init__(self) -> None:
self.buf = BytesIO()
self.write_header() # create header first
self.device_info_defined = False
def __str__(self) -> str:
orig_pos = self.buf.tell()
self.buf.seek(0)
lines = []
while True:
b = self.buf.read(16)
if not b:
break
lines.append(" ".join([f"{ord(c):02x}" for c in b]))
self.buf.seek(orig_pos)
return "\n".join(lines)
def write_header(
self,
header_size: int = 12, # Fit.HEADER_SIZE
protocol_version: int = 16,
profile_version: int = 108,
data_size: int = 0,
data_type: bytes = b".FIT",
) -> None:
self.buf.seek(0)
s = pack(
"BBHI4s",
header_size,
protocol_version,
profile_version,
data_size,
data_type,
)
self.buf.write(s)
def _build_content_block(self, content: dict[str, Any]) -> bytes:
field_defs = []
values = []
for num, basetype, value, scale in content:
s = pack("BBB", num, basetype["size"], basetype["field"])
field_defs.append(s)
if value is None:
# invalid value
value = basetype["invalid"]
elif scale is not None:
value *= scale
values.append(FitBaseType.pack(basetype, value))
return (b"".join(field_defs), b"".join(values))
def write_file_info(
self,
serial_number: int | None = None,
time_created: datetime | None = None,
manufacturer: int | None = None,
product: int | None = None,
number: int | None = None,
) -> None:
if time_created is None:
time_created = datetime.now()
content = [
(3, FitBaseType.uint32z, serial_number, None),
(4, FitBaseType.uint32, self.timestamp(time_created), None),
(1, FitBaseType.uint16, manufacturer, None),
(2, FitBaseType.uint16, product, None),
(5, FitBaseType.uint16, number, None),
(0, FitBaseType.enum, self.FILE_TYPE, None), # type
]
fields, values = self._build_content_block(content)
# create fixed content
msg_number = self.GMSG_NUMS["file_id"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(
b"".join(
[
# definition
self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_FILE_INFO
),
fixed_content,
fields,
# record
self.record_header(lmsg_type=self.LMSG_TYPE_FILE_INFO),
values,
]
)
)
def write_file_creator(
self,
software_version: int | None = None,
hardware_version: int | None = None,
) -> None:
content = [
(0, FitBaseType.uint16, software_version, None),
(1, FitBaseType.uint8, hardware_version, None),
]
fields, values = self._build_content_block(content)
msg_number = self.GMSG_NUMS["file_creator"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(
b"".join(
[
# definition
self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_FILE_CREATOR
),
fixed_content,
fields,
# record
self.record_header(lmsg_type=self.LMSG_TYPE_FILE_CREATOR),
values,
]
)
)
def write_device_info(
self,
timestamp: datetime,
serial_number: int | None = None,
cum_operationg_time: int | None = None,
manufacturer: int | None = None,
product: int | None = None,
software_version: int | None = None,
battery_voltage: int | None = None,
device_index: int | None = None,
device_type: int | None = None,
hardware_version: int | None = None,
battery_status: int | None = None,
) -> None:
content = [
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
(3, FitBaseType.uint32z, serial_number, 1),
(7, FitBaseType.uint32, cum_operationg_time, 1),
(8, FitBaseType.uint32, None, None), # unknown field(undocumented)
(2, FitBaseType.uint16, manufacturer, 1),
(4, FitBaseType.uint16, product, 1),
(5, FitBaseType.uint16, software_version, 100),
(10, FitBaseType.uint16, battery_voltage, 256),
(0, FitBaseType.uint8, device_index, 1),
(1, FitBaseType.uint8, device_type, 1),
(6, FitBaseType.uint8, hardware_version, 1),
(11, FitBaseType.uint8, battery_status, None),
]
fields, values = self._build_content_block(content)
if not self.device_info_defined:
header = self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_DEVICE_INFO
)
msg_number = self.GMSG_NUMS["device_info"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(header + fixed_content + fields)
self.device_info_defined = True
header = self.record_header(lmsg_type=self.LMSG_TYPE_DEVICE_INFO)
self.buf.write(header + values)
def record_header(self, definition: bool = False, lmsg_type: int = 0) -> bytes:
msg = 0
if definition:
msg = 1 << 6 # 6th bit is a definition message
return pack("B", msg + lmsg_type)
def crc(self) -> int:
orig_pos = self.buf.tell()
self.buf.seek(0)
crc = 0
while True:
b = self.buf.read(1)
if not b:
break
crc = _calcCRC(crc, unpack("b", b)[0])
self.buf.seek(orig_pos)
return pack("H", crc)
def finish(self) -> None:
"""re-weite file-header, then append crc to end of file."""
data_size = self.get_size() - self.HEADER_SIZE
self.write_header(data_size=data_size)
crc = self.crc()
self.buf.seek(0, 2)
self.buf.write(crc)
def get_size(self) -> int:
orig_pos = self.buf.tell()
self.buf.seek(0, 2)
size = self.buf.tell()
self.buf.seek(orig_pos)
return size
def getvalue(self) -> bytes:
return self.buf.getvalue()
def timestamp(self, t: datetime | float) -> float:
"""The timestamp in fit protocol is seconds since
UTC 00:00 Dec 31 1989 (631065600).
"""
if isinstance(t, datetime):
t = time.mktime(t.timetuple())
return t - 631065600
class FitEncoderBloodPressure(FitEncoder):
# Here might be dragons - no idea what lsmg stand for, found 14 somewhere in the deepest web
LMSG_TYPE_BLOOD_PRESSURE = 14
def __init__(self) -> None:
super().__init__()
self.blood_pressure_monitor_defined = False
def write_blood_pressure(
self,
timestamp: datetime | int | float,
diastolic_blood_pressure: int | None = None,
systolic_blood_pressure: int | None = None,
mean_arterial_pressure: int | None = None,
map_3_sample_mean: int | None = None,
map_morning_values: int | None = None,
map_evening_values: int | None = None,
heart_rate: int | None = None,
) -> None:
# BLOOD PRESSURE FILE MESSAGES
content = [
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
(0, FitBaseType.uint16, systolic_blood_pressure, 1),
(1, FitBaseType.uint16, diastolic_blood_pressure, 1),
(2, FitBaseType.uint16, mean_arterial_pressure, 1),
(3, FitBaseType.uint16, map_3_sample_mean, 1),
(4, FitBaseType.uint16, map_morning_values, 1),
(5, FitBaseType.uint16, map_evening_values, 1),
(6, FitBaseType.uint8, heart_rate, 1),
]
fields, values = self._build_content_block(content)
if not self.blood_pressure_monitor_defined:
header = self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE
)
msg_number = self.GMSG_NUMS["blood_pressure"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(header + fixed_content + fields)
self.blood_pressure_monitor_defined = True
header = self.record_header(lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE)
self.buf.write(header + values)
class FitEncoderWeight(FitEncoder):
LMSG_TYPE_WEIGHT_SCALE = 3
def __init__(self) -> None:
super().__init__()
self.weight_scale_defined = False
def write_weight_scale(
self,
timestamp: datetime | int | float,
weight: int | float,
percent_fat: int | float | None = None,
percent_hydration: int | float | None = None,
visceral_fat_mass: int | float | None = None,
bone_mass: int | float | None = None,
muscle_mass: int | float | None = None,
basal_met: int | float | None = None,
active_met: int | float | None = None,
physique_rating: int | float | None = None,
metabolic_age: int | float | None = None,
visceral_fat_rating: int | float | None = None,
bmi: int | float | None = None,
) -> None:
content = [
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
(0, FitBaseType.uint16, weight, 100),
(1, FitBaseType.uint16, percent_fat, 100),
(2, FitBaseType.uint16, percent_hydration, 100),
(3, FitBaseType.uint16, visceral_fat_mass, 100),
(4, FitBaseType.uint16, bone_mass, 100),
(5, FitBaseType.uint16, muscle_mass, 100),
(7, FitBaseType.uint16, basal_met, 4),
(9, FitBaseType.uint16, active_met, 4),
(8, FitBaseType.uint8, physique_rating, 1),
(10, FitBaseType.uint8, metabolic_age, 1),
(11, FitBaseType.uint8, visceral_fat_rating, 1),
(13, FitBaseType.uint16, bmi, 10),
]
fields, values = self._build_content_block(content)
if not self.weight_scale_defined:
header = self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE
)
msg_number = self.GMSG_NUMS["weight_scale"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(header + fixed_content + fields)
self.weight_scale_defined = True
header = self.record_header(lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE)
self.buf.write(header + values)

593
garminconnect/typed.py Normal file
View File

@ -0,0 +1,593 @@
"""Optional Pydantic response models for typed Garmin Connect API access.
Experimental — model shapes and the ``g.typed`` surface may change between
minor releases until the pattern stabilises. Pin a specific version if you
depend on typed response shapes.
The typed namespace wraps a small, curated set of high-value endpoints. All
other endpoints remain available via the standard ``g.get_*()`` methods with
``dict[str, Any]`` responses — this layer is purely additive.
Usage:
from garminconnect import Garmin
g = Garmin(email, password)
g.login()
raw = g.get_stats("2026-04-21") # dict[str, Any] — unchanged
stats = g.typed.get_stats("2026-04-21") # DailyStats (Pydantic)
print(stats.total_steps, stats.resting_heart_rate)
Install the optional dependency first::
pip install 'garminconnect[typed]'
On validation failure, raises :class:`GarminConnectResponseValidationError`
with the unvalidated response preserved as ``.raw`` so callers can still
access the data.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypeVar
try:
from pydantic import BaseModel, ConfigDict, Field
from pydantic import ValidationError as _PydanticValidationError
except ImportError as _exc: # pragma: no cover - exercised via integration
raise ImportError(
"The `typed` namespace requires pydantic. Install it with:\n"
" pip install 'garminconnect[typed]'"
) from _exc
_M = TypeVar("_M", bound=BaseModel)
if TYPE_CHECKING:
from . import Garmin
class GarminConnectResponseValidationError(Exception):
"""Raised when a Garmin response fails Pydantic validation.
The unvalidated response is available as ``raw`` so callers can still
inspect the data. The underlying :class:`pydantic.ValidationError` is
available as ``pydantic_error``.
"""
def __init__(
self,
message: str,
raw: Any,
pydantic_error: _PydanticValidationError,
) -> None:
super().__init__(message)
self.raw = raw
self.pydantic_error = pydantic_error
# ---------------------------------------------------------------------------
# Base
# ---------------------------------------------------------------------------
# ``extra='allow'`` is deliberate: Garmin occasionally adds fields with new
# firmware / subscription tiers, and we don't want validation failures for
# benign additions. ``populate_by_name=True`` lets callers construct models
# using either the Python attribute name or the JSON alias, which is useful
# for tests.
_COMMON_CONFIG = ConfigDict(
extra="allow",
populate_by_name=True,
)
class _BaseResponse(BaseModel):
model_config = _COMMON_CONFIG
# ---------------------------------------------------------------------------
# Daily Stats (get_stats / get_user_summary)
# ---------------------------------------------------------------------------
class DailyStats(_BaseResponse):
"""Daily summary returned by ``get_stats`` and ``get_user_summary``.
Only the most commonly consumed fields are modelled explicitly; additional
fields are accessible through ``model_extra`` or by dumping the model.
"""
user_profile_id: int | None = Field(default=None, alias="userProfileId")
calendar_date: str | None = Field(default=None, alias="calendarDate")
total_steps: int | None = Field(default=None, alias="totalSteps")
daily_step_goal: int | None = Field(default=None, alias="dailyStepGoal")
total_distance_meters: float | None = Field(
default=None, alias="totalDistanceMeters"
)
total_kilocalories: float | None = Field(default=None, alias="totalKilocalories")
active_kilocalories: float | None = Field(default=None, alias="activeKilocalories")
bmr_kilocalories: float | None = Field(default=None, alias="bmrKilocalories")
wellness_kilocalories: float | None = Field(
default=None, alias="wellnessKilocalories"
)
min_heart_rate: int | None = Field(default=None, alias="minHeartRate")
max_heart_rate: int | None = Field(default=None, alias="maxHeartRate")
resting_heart_rate: int | None = Field(default=None, alias="restingHeartRate")
sleeping_seconds: int | None = Field(default=None, alias="sleepingSeconds")
sedentary_seconds: int | None = Field(default=None, alias="sedentarySeconds")
active_seconds: int | None = Field(default=None, alias="activeSeconds")
highly_active_seconds: int | None = Field(default=None, alias="highlyActiveSeconds")
moderate_intensity_minutes: int | None = Field(
default=None, alias="moderateIntensityMinutes"
)
vigorous_intensity_minutes: int | None = Field(
default=None, alias="vigorousIntensityMinutes"
)
floors_ascended: float | None = Field(default=None, alias="floorsAscended")
floors_descended: float | None = Field(default=None, alias="floorsDescended")
average_stress_level: int | None = Field(default=None, alias="averageStressLevel")
max_stress_level: int | None = Field(default=None, alias="maxStressLevel")
stress_duration: int | None = Field(default=None, alias="stressDuration")
rest_stress_duration: int | None = Field(default=None, alias="restStressDuration")
body_battery_charged_value: int | None = Field(
default=None, alias="bodyBatteryChargedValue"
)
body_battery_drained_value: int | None = Field(
default=None, alias="bodyBatteryDrainedValue"
)
body_battery_highest_value: int | None = Field(
default=None, alias="bodyBatteryHighestValue"
)
body_battery_lowest_value: int | None = Field(
default=None, alias="bodyBatteryLowestValue"
)
privacy_protected: bool | None = Field(default=None, alias="privacyProtected")
# ---------------------------------------------------------------------------
# Sleep (get_sleep_data)
# ---------------------------------------------------------------------------
class SleepScoreValue(_BaseResponse):
"""One component of the Garmin sleep score breakdown (value + qualifier)."""
value: int | None = None
qualifier_key: str | None = Field(default=None, alias="qualifierKey")
class SleepScores(_BaseResponse):
"""Sub-scores that make up the overall nightly sleep score."""
overall: SleepScoreValue | None = None
total_duration: SleepScoreValue | None = Field(default=None, alias="totalDuration")
stress: SleepScoreValue | None = None
awake_count: SleepScoreValue | None = Field(default=None, alias="awakeCount")
rem_percentage: SleepScoreValue | None = Field(default=None, alias="remPercentage")
restlessness: SleepScoreValue | None = None
light_percentage: SleepScoreValue | None = Field(
default=None, alias="lightPercentage"
)
deep_percentage: SleepScoreValue | None = Field(
default=None, alias="deepPercentage"
)
class DailySleepDTO(_BaseResponse):
"""Nested sleep summary inside a :class:`SleepData` response."""
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
calendar_date: str | None = Field(default=None, alias="calendarDate")
sleep_time_seconds: int | None = Field(default=None, alias="sleepTimeSeconds")
nap_time_seconds: int | None = Field(default=None, alias="napTimeSeconds")
sleep_window_confirmed: bool | None = Field(
default=None, alias="sleepWindowConfirmed"
)
deep_sleep_seconds: int | None = Field(default=None, alias="deepSleepSeconds")
light_sleep_seconds: int | None = Field(default=None, alias="lightSleepSeconds")
rem_sleep_seconds: int | None = Field(default=None, alias="remSleepSeconds")
awake_sleep_seconds: int | None = Field(default=None, alias="awakeSleepSeconds")
sleep_start_timestamp_gmt: int | None = Field(
default=None, alias="sleepStartTimestampGMT"
)
sleep_end_timestamp_gmt: int | None = Field(
default=None, alias="sleepEndTimestampGMT"
)
sleep_start_timestamp_local: int | None = Field(
default=None, alias="sleepStartTimestampLocal"
)
sleep_end_timestamp_local: int | None = Field(
default=None, alias="sleepEndTimestampLocal"
)
avg_sleep_hrv: float | None = Field(default=None, alias="avgSleepHRV")
avg_spo2: float | None = Field(default=None, alias="avgSpO2")
avg_respiration_value: float | None = Field(
default=None, alias="avgRespirationValue"
)
lowest_respiration_value: float | None = Field(
default=None, alias="lowestRespirationValue"
)
highest_respiration_value: float | None = Field(
default=None, alias="highestRespirationValue"
)
sleep_scores: SleepScores | None = Field(default=None, alias="sleepScores")
class SleepData(_BaseResponse):
"""Response for ``get_sleep_data``.
The most useful summary lives under ``daily_sleep_dto``; callers that want
per-minute heart rate / movement / SpO2 arrays should use the raw dict via
``g.get_sleep_data`` since those arrays are large and rarely needed in
typed form.
"""
daily_sleep_dto: DailySleepDTO | None = Field(default=None, alias="dailySleepDTO")
# ---------------------------------------------------------------------------
# HRV (get_hrv_data)
# ---------------------------------------------------------------------------
class HrvBaseline(_BaseResponse):
"""Personal HRV baseline ranges derived from the user's history."""
low_upper: float | None = Field(default=None, alias="lowUpper")
balanced_low: float | None = Field(default=None, alias="balancedLow")
balanced_upper: float | None = Field(default=None, alias="balancedUpper")
marker_value: float | None = Field(default=None, alias="markerValue")
class HrvSummary(_BaseResponse):
"""Summary of HRV stats (weekly / last-night averages, status, feedback)."""
calendar_date: str | None = Field(default=None, alias="calendarDate")
weekly_avg: float | None = Field(default=None, alias="weeklyAvg")
last_night_avg: float | None = Field(default=None, alias="lastNightAvg")
last_night_5_min_high: float | None = Field(default=None, alias="lastNight5MinHigh")
status: str | None = None
feedback_phrase: str | None = Field(default=None, alias="feedbackPhrase")
baseline: HrvBaseline | None = None
class HrvData(_BaseResponse):
"""Response for ``get_hrv_data``.
Note: ``get_hrv_data`` may return ``None`` if HRV data is not available for
the requested date. The typed wrapper preserves this — ``g.typed.get_hrv_data``
returns ``HrvData | None``.
"""
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
hrv_summary: HrvSummary | None = Field(default=None, alias="hrvSummary")
hrv_readings: list[dict[str, Any]] | None = Field(default=None, alias="hrvReadings")
start_timestamp_gmt: str | None = Field(default=None, alias="startTimestampGMT")
end_timestamp_gmt: str | None = Field(default=None, alias="endTimestampGMT")
start_timestamp_local: str | None = Field(default=None, alias="startTimestampLocal")
end_timestamp_local: str | None = Field(default=None, alias="endTimestampLocal")
sleep_start_timestamp_gmt: str | None = Field(
default=None, alias="sleepStartTimestampGMT"
)
sleep_end_timestamp_gmt: str | None = Field(
default=None, alias="sleepEndTimestampGMT"
)
# ---------------------------------------------------------------------------
# Body Battery (get_body_battery)
# ---------------------------------------------------------------------------
class BodyBatteryEntry(_BaseResponse):
"""One entry from ``get_body_battery``.
``get_body_battery`` always returns a list; for a single date the list has
one entry. ``body_battery_values_array`` is a list of ``[timestamp, level]``
pairs sampled throughout the day.
"""
date: str | None = None
charged: int | None = None
drained: int | None = None
start_timestamp_gmt: str | None = Field(default=None, alias="startTimestampGMT")
end_timestamp_gmt: str | None = Field(default=None, alias="endTimestampGMT")
start_timestamp_local: str | None = Field(default=None, alias="startTimestampLocal")
end_timestamp_local: str | None = Field(default=None, alias="endTimestampLocal")
body_battery_values_array: list[list[Any]] | None = Field(
default=None, alias="bodyBatteryValuesArray"
)
body_battery_value_descriptors_dto_list: list[dict[str, Any]] | None = Field(
default=None, alias="bodyBatteryValueDescriptorDTOList"
)
# ---------------------------------------------------------------------------
# Training Readiness (get_training_readiness)
# ---------------------------------------------------------------------------
class TrainingReadiness(_BaseResponse):
"""One snapshot from ``get_training_readiness``.
The endpoint returns a list of snapshots — typically one per wake-up event
or scheduled update. Use the snapshot with the most recent ``timestamp``
for the current reading.
``recovery_time`` is reported in **minutes**. When
``recovery_time_change_phrase == 'REACHED_ZERO'`` the user is fully
recovered regardless of the numeric value (Garmin keeps the last assigned
value after the clock drains).
"""
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
calendar_date: str | None = Field(default=None, alias="calendarDate")
timestamp: str | None = None
timestamp_local: str | None = Field(default=None, alias="timestampLocal")
device_id: int | None = Field(default=None, alias="deviceId")
score: int | None = None
level: str | None = None
feedback_long: str | None = Field(default=None, alias="feedbackLong")
feedback_short: str | None = Field(default=None, alias="feedbackShort")
sleep_score: int | None = Field(default=None, alias="sleepScore")
sleep_score_factor_percent: int | None = Field(
default=None, alias="sleepScoreFactorPercent"
)
sleep_score_factor_feedback: str | None = Field(
default=None, alias="sleepScoreFactorFeedback"
)
recovery_time: int | None = Field(default=None, alias="recoveryTime")
recovery_time_factor_percent: int | None = Field(
default=None, alias="recoveryTimeFactorPercent"
)
recovery_time_factor_feedback: str | None = Field(
default=None, alias="recoveryTimeFactorFeedback"
)
recovery_time_change_phrase: str | None = Field(
default=None, alias="recoveryTimeChangePhrase"
)
acwr_factor_percent: int | None = Field(default=None, alias="acwrFactorPercent")
acwr_factor_feedback: str | None = Field(default=None, alias="acwrFactorFeedback")
hrv_factor_percent: int | None = Field(default=None, alias="hrvFactorPercent")
hrv_factor_feedback: str | None = Field(default=None, alias="hrvFactorFeedback")
stress_history_factor_percent: int | None = Field(
default=None, alias="stressHistoryFactorPercent"
)
stress_history_factor_feedback: str | None = Field(
default=None, alias="stressHistoryFactorFeedback"
)
input_context: str | None = Field(default=None, alias="inputContext")
# ---------------------------------------------------------------------------
# Activity (get_activities_by_date)
# ---------------------------------------------------------------------------
class ActivityType(_BaseResponse):
"""Garmin activity type classification (``typeKey`` is the main lookup)."""
type_id: int | None = Field(default=None, alias="typeId")
type_key: str | None = Field(default=None, alias="typeKey")
parent_type_id: int | None = Field(default=None, alias="parentTypeId")
is_hidden: bool | None = Field(default=None, alias="isHidden")
class Activity(_BaseResponse):
"""One activity from ``get_activities_by_date``.
Strength-training activities populate ``total_sets``, ``total_reps`` and
``total_volume``; other activity types leave those fields as ``None``.
"""
activity_id: int | None = Field(default=None, alias="activityId")
activity_name: str | None = Field(default=None, alias="activityName")
start_time_local: str | None = Field(default=None, alias="startTimeLocal")
start_time_gmt: str | None = Field(default=None, alias="startTimeGMT")
activity_type: ActivityType | None = Field(default=None, alias="activityType")
duration: float | None = None
moving_duration: float | None = Field(default=None, alias="movingDuration")
elapsed_duration: float | None = Field(default=None, alias="elapsedDuration")
distance: float | None = None
elevation_gain: float | None = Field(default=None, alias="elevationGain")
elevation_loss: float | None = Field(default=None, alias="elevationLoss")
average_speed: float | None = Field(default=None, alias="averageSpeed")
max_speed: float | None = Field(default=None, alias="maxSpeed")
average_hr: float | None = Field(default=None, alias="averageHR")
max_hr: float | None = Field(default=None, alias="maxHR")
calories: float | None = None
bmr_calories: float | None = Field(default=None, alias="bmrCalories")
avg_power: float | None = Field(default=None, alias="avgPower")
max_power: float | None = Field(default=None, alias="maxPower")
normalized_power: float | None = Field(default=None, alias="normPower")
aerobic_training_effect: float | None = Field(
default=None, alias="aerobicTrainingEffect"
)
anaerobic_training_effect: float | None = Field(
default=None, alias="anaerobicTrainingEffect"
)
activity_training_load: float | None = Field(
default=None, alias="activityTrainingLoad"
)
training_effect_label: str | None = Field(default=None, alias="trainingEffectLabel")
average_running_cadence: float | None = Field(
default=None, alias="averageRunningCadenceInStepsPerMinute"
)
max_running_cadence: float | None = Field(
default=None, alias="maxRunningCadenceInStepsPerMinute"
)
total_sets: int | None = Field(default=None, alias="totalSets")
active_sets: int | None = Field(default=None, alias="activeSets")
total_reps: int | None = Field(default=None, alias="totalReps")
total_volume: float | None = Field(default=None, alias="totalVolume")
# ---------------------------------------------------------------------------
# Wrapper
# ---------------------------------------------------------------------------
class TypedGarmin:
"""Typed namespace accessor for a curated set of Garmin Connect endpoints.
Access via the :attr:`Garmin.typed` cached property, never instantiate
directly::
g = Garmin(email, password)
g.login()
stats = g.typed.get_stats("2026-04-21")
Each method is a thin wrapper around the corresponding ``Garmin`` method
that validates the response with a Pydantic model. On validation failure,
raises :class:`GarminConnectResponseValidationError` with the unvalidated
response available as ``.raw``.
**Experimental.** Model shapes and method signatures may change in future
releases; pin a specific version if you depend on them.
"""
def __init__(self, garmin: Garmin) -> None:
self._garmin = garmin
@staticmethod
def _validate(model_cls: type[_M], raw: Any, method_name: str) -> _M:
try:
return model_cls.model_validate(raw)
except _PydanticValidationError as exc:
raise GarminConnectResponseValidationError(
f"Response from {method_name}() failed {model_cls.__name__} "
f"validation: {exc}",
raw=raw,
pydantic_error=exc,
) from exc
# -- Daily stats ---------------------------------------------------------
def get_stats(self, cdate: str) -> DailyStats:
"""Return daily stats for ``cdate`` as a :class:`DailyStats` model."""
raw = self._garmin.get_stats(cdate)
return self._validate(DailyStats, raw, "get_stats")
def get_user_summary(self, cdate: str) -> DailyStats:
"""Return the user summary for ``cdate`` as a :class:`DailyStats` model."""
raw = self._garmin.get_user_summary(cdate)
return self._validate(DailyStats, raw, "get_user_summary")
# -- Sleep ---------------------------------------------------------------
def get_sleep_data(self, cdate: str) -> SleepData:
"""Return sleep data for ``cdate`` as a :class:`SleepData` model."""
raw = self._garmin.get_sleep_data(cdate)
return self._validate(SleepData, raw, "get_sleep_data")
# -- HRV -----------------------------------------------------------------
def get_hrv_data(self, cdate: str) -> HrvData | None:
"""Return HRV data for ``cdate`` as :class:`HrvData`, or ``None`` if absent."""
raw = self._garmin.get_hrv_data(cdate)
if raw is None:
return None
return self._validate(HrvData, raw, "get_hrv_data")
# -- Body battery --------------------------------------------------------
def get_body_battery(
self, startdate: str, enddate: str | None = None
) -> list[BodyBatteryEntry]:
"""Return body battery entries between ``startdate`` and ``enddate``."""
raw = self._garmin.get_body_battery(startdate, enddate)
if not isinstance(raw, list):
return []
return [
self._validate(BodyBatteryEntry, item, "get_body_battery") for item in raw
]
# -- Training readiness --------------------------------------------------
def get_training_readiness(self, cdate: str) -> list[TrainingReadiness]:
"""Return training readiness snapshots for ``cdate``.
The underlying endpoint may return either a list of snapshots or a
single snapshot object depending on account/firmware behavior. This
wrapper normalizes both shapes to ``list[TrainingReadiness]``.
"""
raw = self._garmin.get_training_readiness(cdate)
if not raw:
# Empty list / empty dict / None — no snapshots available.
return []
if isinstance(raw, list):
return [
self._validate(TrainingReadiness, item, "get_training_readiness")
for item in raw
]
if isinstance(raw, dict):
return [self._validate(TrainingReadiness, raw, "get_training_readiness")]
return []
# -- Activities ----------------------------------------------------------
def get_activities_by_date(
self,
startdate: str,
enddate: str | None = None,
activitytype: str | None = None,
sortorder: str | None = None,
) -> list[Activity]:
"""Return activities between two dates as a list of :class:`Activity`."""
raw = self._garmin.get_activities_by_date(
startdate, enddate, activitytype, sortorder
)
if not isinstance(raw, list):
return []
return [
self._validate(Activity, item, "get_activities_by_date") for item in raw
]
__all__ = [
"Activity",
"ActivityType",
"BodyBatteryEntry",
"DailySleepDTO",
"DailyStats",
"GarminConnectResponseValidationError",
"HrvBaseline",
"HrvData",
"HrvSummary",
"SleepData",
"SleepScoreValue",
"SleepScores",
"TrainingReadiness",
"TypedGarmin",
]

573
garminconnect/workout.py Normal file
View 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)