Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions garminconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ def _validate_date_format(date_str: str, param_name: str = "date") -> str:
return date_str


def _validate_date_range(start: str, end: str) -> tuple[str, str]:
"""Validate 'start'/'end' are well-formed dates with start <= end."""
start = _validate_date_format(start, "start")
end = _validate_date_format(end, "end")
if datetime.strptime(start, DATE_FORMAT_STR) > datetime.strptime(
end, DATE_FORMAT_STR
):
raise ValueError("start date cannot be after end date")
return start, end


def _validate_positive_number(
value: int | float, param_name: str = "value"
) -> int | float:
Expand Down Expand Up @@ -1401,6 +1412,19 @@ def get_max_metrics(self, cdate: str) -> dict[str, Any]:

return self.connectapi(url)

def get_max_metrics_range(self, start: str, end: str) -> dict[str, Any]:
"""Return max metric data for a date range ('start'/'end' format 'YYYY-MM-DD').

Unlike `get_max_metrics`, which is limited to a single day, this
queries the same endpoint with distinct start/end dates to fetch a
range in one request.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
start, end = _validate_date_range(start, end)
url = f"{self.garmin_connect_metrics_url}/{start}/{end}"
logger.debug("Requesting max metrics range")

return self.connectapi(url)

def get_lactate_threshold(
self,
*,
Expand Down Expand Up @@ -1743,6 +1767,44 @@ def get_sleep_data(self, cdate: str) -> dict[str, Any]:

return self.connectapi(url, params=params)

def get_sleep_daily(self, start: str, end: str) -> list[dict[str, Any]]:
"""Fetch daily sleep summaries for 'start' and 'end' format 'YYYY-MM-DD'.

Note: The Garmin Connect sleep-stats endpoint has a 28-day limit per
request. For date ranges exceeding 28 days, this method automatically
splits the range into chunks and makes multiple API calls, then merges
the results, de-duplicating by calendar date.
"""
start, end = _validate_date_range(start, end)
start_date = datetime.strptime(start, DATE_FORMAT_STR).date()
end_date = datetime.strptime(end, DATE_FORMAT_STR).date()

results: list[dict[str, Any]] = []
seen: set[str] = set()
current_start = start_date

while current_start <= end_date:
chunk_end = min(current_start + timedelta(days=27), end_date)
url = (
"/sleep-service/stats/sleep/daily/"
f"{current_start.isoformat()}/{chunk_end.isoformat()}"
)
logger.debug(
f"Requesting daily sleep data for chunk: "
f"{current_start.isoformat()} to {chunk_end.isoformat()}"
)
data = self.connectapi(url)
for row in (data or {}).get("individualStats") or []:
cal_date = row.get("calendarDate")
if cal_date and cal_date not in seen:
seen.add(cal_date)
results.append(row)

current_start = chunk_end + timedelta(days=1)

results.sort(key=lambda r: r.get("calendarDate") or "")
return results

def get_stress_data(self, cdate: str) -> dict[str, Any]:
"""Return stress data for 'cdate' format 'YYYY-MM-DD'."""
cdate = _validate_date_format(cdate, "cdate")
Expand Down Expand Up @@ -1772,6 +1834,76 @@ def get_rhr_day(self, cdate: str) -> dict[str, Any]:

return self.connectapi(url, params=params)

def get_rhr_daily(self, start: str, end: str) -> list[dict[str, Any]]:
"""Return daily resting heart rate for a date range ('start'/'end' format 'YYYY-MM-DD').

Unlike `get_rhr_day`, which is limited to a single day, this queries
the same wellness-stats endpoint with distinct fromDate/untilDate to
fetch a range (up to ~1 year) in a single request.
"""
start, end = _validate_date_range(start, end)
url = f"{self.garmin_connect_rhr_url}/{self._require_display_name()}"
params = {
"fromDate": start,
"untilDate": end,
"metricId": 60,
}
logger.debug("Requesting resting heartrate data range")

data = self.connectapi(url, params=params)
rows = ((data or {}).get("allMetrics") or {}).get("metricsMap", {}).get(
"WELLNESS_RESTING_HEART_RATE"
) or []
return [
{"calendarDate": row.get("calendarDate"), "value": row.get("value")}
for row in rows
if row.get("value") is not None
]

def get_calories_daily(self, start: str, end: str) -> list[dict[str, Any]]:
"""Return daily active + resting (BMR) calories for a date range.

'start'/'end' format 'YYYY-MM-DD'. Uses the same wellness-stats
endpoint as `get_rhr_daily` (metric IDs 22 = active calories, 23 = BMR
calories) to fetch both series for the range in a single request.
"""
start, end = _validate_date_range(start, end)
url = f"{self.garmin_connect_rhr_url}/{self._require_display_name()}"
params = {
"fromDate": start,
"untilDate": end,
"metricId": [22, 23],
}
logger.debug("Requesting daily calories data range")

data = self.connectapi(url, params=params)
metrics = ((data or {}).get("allMetrics") or {}).get("metricsMap", {})

def _by_date(key: str) -> dict[str, float]:
return {
row.get("calendarDate"): row.get("value")
for row in (metrics.get(key) or [])
if row.get("calendarDate") is not None and row.get("value") is not None
}

active = _by_date("WELLNESS_ACTIVE_CALORIES")
resting = _by_date("WELLNESS_BMR_CALORIES")
results: list[dict[str, Any]] = []
for cal_date in sorted(set(active) | set(resting)):
a = active.get(cal_date)
r = resting.get(cal_date)
if a is None and r is None:
continue
results.append(
{
"calendarDate": cal_date,
"active": a,
"resting": r,
"total": (a or 0) + (r or 0),
}
)
return results

def get_hrv_data(self, cdate: str) -> dict[str, Any] | None:
"""Return HRV (Heart Rate Variability) data for 'cdate' format 'YYYY-MM-DD'."""
cdate = _validate_date_format(cdate, "cdate")
Expand All @@ -1780,6 +1912,19 @@ def get_hrv_data(self, cdate: str) -> dict[str, Any] | None:

return self.connectapi(url)

def get_hrv_data_range(self, start: str, end: str) -> dict[str, Any] | None:
"""Return HRV (Heart Rate Variability) data for a date range.

'start'/'end' format 'YYYY-MM-DD'. Unlike `get_hrv_data`, which is
limited to a single day, this queries the same endpoint with distinct
start/end dates to fetch a range in one request.
"""
start, end = _validate_date_range(start, end)
url = f"{self.garmin_connect_hrv_url}/daily/{start}/{end}"
logger.debug("Requesting Heart Rate Variability (hrv) data range")

return self.connectapi(url)

def get_training_readiness(self, cdate: str) -> list[dict[str, Any]]:
"""Return training readiness data for 'cdate' format 'YYYY-MM-DD'."""
cdate = _validate_date_format(cdate, "cdate")
Expand Down
178 changes: 172 additions & 6 deletions tests/test_garmin_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,176 @@ def test_get_golf_shot_data_rejects_injected_query_syntax(
garmin.get_golf_shot_data(12345, hole_numbers="1,2&foo=bar")


# ---------------------------------------------------------------------------
# Date-range wellness method tests (max metrics, RHR, calories, sleep, HRV)
# ---------------------------------------------------------------------------


class TestWellnessDailyRangeMethods:
"""URL/param construction and chunking for the *_daily / *_range wellness methods."""

def test_get_max_metrics_range_builds_url_with_distinct_dates(
self, garmin: garminconnect.Garmin
):
with patch.object(garmin, "connectapi", return_value={"vo2Max": 55}) as mock:
result = garmin.get_max_metrics_range("2026-03-01", "2026-03-15")

url = mock.call_args[0][0]
assert url.endswith(
"/metrics-service/metrics/maxmet/daily/2026-03-01/2026-03-15"
)
assert result == {"vo2Max": 55}

def test_get_max_metrics_range_rejects_malformed_date(
self, garmin: garminconnect.Garmin
):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
garmin.get_max_metrics_range("not-a-date", "2026-03-15")

def test_get_hrv_data_range_builds_url_with_distinct_dates(
self, garmin: garminconnect.Garmin
):
with patch.object(
garmin, "connectapi", return_value={"hrvSummaries": []}
) as mock:
result = garmin.get_hrv_data_range("2026-03-01", "2026-03-15")

url = mock.call_args[0][0]
assert url.endswith("/hrv-service/hrv/daily/2026-03-01/2026-03-15")
assert result == {"hrvSummaries": []}

def test_get_rhr_daily_builds_url_and_filters_metric_map(
self, garmin: garminconnect.Garmin
):
payload = {
"allMetrics": {
"metricsMap": {
"WELLNESS_RESTING_HEART_RATE": [
{"calendarDate": "2026-03-01", "value": 52},
{"calendarDate": "2026-03-02", "value": None},
]
}
}
}
with patch.object(garmin, "connectapi", return_value=payload) as mock:
result = garmin.get_rhr_daily("2026-03-01", "2026-03-02")

url, kwargs = mock.call_args[0][0], mock.call_args[1]
assert url.endswith("/userstats-service/wellness/daily/test-display")
assert kwargs["params"] == {
"fromDate": "2026-03-01",
"untilDate": "2026-03-02",
"metricId": 60,
}
# Rows with a null value are dropped.
assert result == [{"calendarDate": "2026-03-01", "value": 52}]

def test_get_rhr_daily_rejects_malformed_date(self, garmin: garminconnect.Garmin):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
garmin.get_rhr_daily("not-a-date", "2026-03-02")

def test_get_calories_daily_merges_active_and_resting(
self, garmin: garminconnect.Garmin
):
payload = {
"allMetrics": {
"metricsMap": {
"WELLNESS_ACTIVE_CALORIES": [
{"calendarDate": "2026-03-01", "value": 400},
],
"WELLNESS_BMR_CALORIES": [
{"calendarDate": "2026-03-01", "value": 1600},
{"calendarDate": "2026-03-02", "value": 1500},
],
}
}
}
with patch.object(garmin, "connectapi", return_value=payload) as mock:
result = garmin.get_calories_daily("2026-03-01", "2026-03-02")

kwargs = mock.call_args[1]
assert kwargs["params"]["metricId"] == [22, 23]
assert result == [
{
"calendarDate": "2026-03-01",
"active": 400,
"resting": 1600,
"total": 2000,
},
{
"calendarDate": "2026-03-02",
"active": None,
"resting": 1500,
"total": 1500,
},
]

def test_get_sleep_daily_single_chunk_dedupes_and_sorts(
self, garmin: garminconnect.Garmin
):
payload = {
"individualStats": [
{"calendarDate": "2026-03-02", "overallSleepScore": 80},
{"calendarDate": "2026-03-01", "overallSleepScore": 75},
{"calendarDate": "2026-03-01", "overallSleepScore": 75},
]
}
with patch.object(garmin, "connectapi", return_value=payload) as mock:
result = garmin.get_sleep_daily("2026-03-01", "2026-03-02")

mock.assert_called_once()
url = mock.call_args[0][0]
assert url.endswith("/sleep-service/stats/sleep/daily/2026-03-01/2026-03-02")
assert [row["calendarDate"] for row in result] == ["2026-03-01", "2026-03-02"]

def test_get_sleep_daily_chunks_ranges_over_28_days(
self, garmin: garminconnect.Garmin
):
# 30-day range should be split into two requests (28 days + 2 days).
with patch.object(
garmin, "connectapi", return_value={"individualStats": []}
) as mock:
garmin.get_sleep_daily("2026-01-01", "2026-01-30")

assert mock.call_count == 2
first_url = mock.call_args_list[0][0][0]
second_url = mock.call_args_list[1][0][0]
assert first_url.endswith(
"/sleep-service/stats/sleep/daily/2026-01-01/2026-01-28"
)
assert second_url.endswith(
"/sleep-service/stats/sleep/daily/2026-01-29/2026-01-30"
)

def test_get_sleep_daily_rejects_start_after_end(
self, garmin: garminconnect.Garmin
):
with pytest.raises(ValueError, match="start date cannot be after end date"):
garmin.get_sleep_daily("2026-03-15", "2026-03-01")

@pytest.mark.parametrize(
"method_name",
[
"get_max_metrics_range",
"get_hrv_data_range",
"get_rhr_daily",
"get_calories_daily",
"get_sleep_daily",
],
)
def test_range_methods_reject_inverted_range_without_api_call(
self, garmin: garminconnect.Garmin, method_name: str
):
"""All *_range/*_daily methods must reject start > end before calling the API."""
method = getattr(garmin, method_name)
with (
patch.object(garmin, "connectapi") as mock,
pytest.raises(ValueError, match="start date cannot be after end date"),
):
method("2026-03-15", "2026-03-01")
mock.assert_not_called()


# ---------------------------------------------------------------------------
# Parameter limit tests
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -656,9 +826,7 @@ def test_connectapi_400_stays_connection_error_not_not_found(self, monkeypatch):
class TestUpdateWorkout:
"""``update_workout`` PUTs the full workout to /workout-service/workout/<id>."""

def test_puts_to_workout_url_with_injected_id(
self, garmin: garminconnect.Garmin
):
def test_puts_to_workout_url_with_injected_id(self, garmin: garminconnect.Garmin):
workout = {"workoutName": "Edited", "sportType": {"sportTypeId": 1}}
with patch.object(garmin, "client") as client:
client.put.return_value = {"workoutId": 123, "workoutName": "Edited"}
Expand All @@ -672,9 +840,7 @@ def test_puts_to_workout_url_with_injected_id(
assert kwargs["json"]["workoutName"] == "Edited"
assert result == {"workoutId": 123, "workoutName": "Edited"}

def test_injected_id_overrides_stray_workout_id(
self, garmin: garminconnect.Garmin
):
def test_injected_id_overrides_stray_workout_id(self, garmin: garminconnect.Garmin):
workout = {"workoutId": 999, "workoutName": "Edited"}
with patch.object(garmin, "client") as client:
garmin.update_workout(123, workout)
Expand Down