0.3.11 with curl_cffi as optional tls extra (FreeBSD support)
This commit is contained in:
3743
garminconnect/__init__.py
Normal file
3743
garminconnect/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
44
garminconnect/activity_details.py
Normal file
44
garminconnect/activity_details.py
Normal 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
1734
garminconnect/client.py
Normal file
File diff suppressed because it is too large
Load Diff
33
garminconnect/exceptions.py
Normal file
33
garminconnect/exceptions.py
Normal 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
2635
garminconnect/exercises.py
Normal file
File diff suppressed because it is too large
Load Diff
518
garminconnect/fit.py
Normal file
518
garminconnect/fit.py
Normal 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
593
garminconnect/typed.py
Normal 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
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