diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index d728650..525a0f4 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -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: @@ -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. + """ + 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, *, @@ -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") @@ -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") @@ -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") diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 9d66c12..a87af12 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -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 # --------------------------------------------------------------------------- @@ -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/.""" - 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"} @@ -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)