Files

594 lines
23 KiB
Python
Raw Permalink Normal View History

"""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",
]