diff --git a/README.md b/README.md index 2dfd745d..54c2e791 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,11 @@ Make your selection: ## API Coverage Statistics -- **Total API Methods**: 134+ unique endpoints (snapshot) +- **Total API Methods**: 137+ unique endpoints (snapshot) - **Categories**: 13 organized sections - **User & Profile**: 4 methods (basic user info, settings) - **Daily Health & Activity**: 9 methods (today's health data) -- **Advanced Health Metrics**: 12 methods (fitness metrics, HRV, VO2, training readiness, running tolerance) +- **Advanced Health Metrics**: 15 methods (fitness metrics, HRV, VO2, training readiness, training zones, running tolerance) - **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates) - **Activities & Workouts**: 38 methods (comprehensive activity, workout management, typed workout uploads, scheduling, import, edit description / exercise sets) - **Body Composition & Weight**: 8 methods (weight tracking, body composition) diff --git a/demo.py b/demo.py index d34b9848..bd061b48 100755 --- a/demo.py +++ b/demo.py @@ -227,6 +227,18 @@ def __init__(self): "desc": f"Get running tolerance from '{config.week_start.isoformat()}' to '{config.today.isoformat()}'", "key": "get_running_tolerance", }, + "c": { + "desc": "Get configured heart rate zones", + "key": "get_heart_rate_zones", + }, + "d": { + "desc": "Get configured power zones for all sports", + "key": "get_power_zones", + }, + "e": { + "desc": "Get configured cycling power zones", + "key": "get_power_zones_for_sport", + }, }, }, "4": { @@ -3966,6 +3978,22 @@ def execute_api_call(api: Garmin, key: str) -> None: api_call_desc=f"api.get_stress_data('{config.today.isoformat()}')", ), "get_lactate_threshold": lambda: get_lactate_threshold_data(api), + "get_heart_rate_zones": lambda: call_and_display( + api.get_heart_rate_zones, + method_name="get_heart_rate_zones", + api_call_desc="api.get_heart_rate_zones()", + ), + "get_power_zones": lambda: call_and_display( + api.get_power_zones, + method_name="get_power_zones", + api_call_desc="api.get_power_zones()", + ), + "get_power_zones_for_sport": lambda: call_and_display( + api.get_power_zones_for_sport, + "CYCLING", + method_name="get_power_zones_for_sport", + api_call_desc="api.get_power_zones_for_sport('CYCLING')", + ), "get_intensity_minutes_data": lambda: call_and_display( api.get_intensity_minutes_data, config.today.isoformat(), diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index 58c22284..baf09648 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -344,8 +344,9 @@ def __init__( self.garmin_connect_daily_summary_url = "/usersummary-service/usersummary/daily" self.garmin_connect_metrics_url = "/metrics-service/metrics/maxmet/daily" self.garmin_connect_biometric_url = "/biometric-service/biometric" - self.garmin_connect_biometric_stats_url = "/biometric-service/stats" + self.garmin_connect_heart_rate_zones_url = "/biometric-service/heartRateZones" + self.garmin_connect_power_zones_url = "/biometric-service/powerZones" self.garmin_connect_daily_hydration_url = ( "/usersummary-service/usersummary/hydration/daily" ) @@ -2573,6 +2574,29 @@ def get_cycling_ftp( logger.debug("Requesting latest cycling FTP") return self.connectapi(url) + def get_heart_rate_zones(self) -> list[dict[str, Any]]: + """Return configured heart rate zones for all sport profiles.""" + logger.debug("Requesting heart rate zones") + return self.connectapi(self.garmin_connect_heart_rate_zones_url) + + def get_power_zones(self) -> list[dict[str, Any]]: + """Return configured power zones for all supported sports.""" + url = f"{self.garmin_connect_power_zones_url}/sports/all" + logger.debug("Requesting power zones for all sports") + return self.connectapi(url) + + def get_power_zones_for_sport(self, sport: str) -> dict[str, Any]: + """Return configured power zones for a Garmin sport key.""" + if not isinstance(sport, str) or not sport.strip(): + raise ValueError("sport must be a non-empty string") + normalized_sport = sport.strip().upper() + if not re.fullmatch(r"[A-Z_]+", normalized_sport): + raise ValueError("sport must contain only letters and underscores") + + url = f"{self.garmin_connect_power_zones_url}/sport/{normalized_sport}" + logger.debug("Requesting power zones for sport %s", normalized_sport) + return self.connectapi(url) + def get_activity(self, activity_id: str) -> dict[str, Any]: """Return activity summary, including basic splits.""" activity_id = str(_validate_positive_integer(int(activity_id), "activity_id")) diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 32a45694..653f80b3 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -126,6 +126,41 @@ def test_get_training_readiness_builds_url_with_date( assert "/metrics-service/metrics/trainingreadiness/2026-03-15" in url assert result == payload + def test_get_heart_rate_zones_builds_url(self, garmin: garminconnect.Garmin): + payload = [{"sport": "RUNNING", "zone1Floor": 120}] + with patch.object(garmin, "connectapi", return_value=payload) as mock: + result = garmin.get_heart_rate_zones() + + mock.assert_called_once_with("/biometric-service/heartRateZones") + assert result == payload + + def test_get_power_zones_builds_all_sports_url(self, garmin: garminconnect.Garmin): + payload = [{"sport": "CYCLING", "zone1Floor": 100}] + with patch.object(garmin, "connectapi", return_value=payload) as mock: + result = garmin.get_power_zones() + + mock.assert_called_once_with("/biometric-service/powerZones/sports/all") + assert result == payload + + def test_get_power_zones_for_sport_normalizes_sport_key( + self, garmin: garminconnect.Garmin + ): + payload = {"sport": "CROSS_COUNTRY_SKIING", "zone1Floor": 100} + with patch.object(garmin, "connectapi", return_value=payload) as mock: + result = garmin.get_power_zones_for_sport(" cross_country_skiing ") + + mock.assert_called_once_with( + "/biometric-service/powerZones/sport/CROSS_COUNTRY_SKIING" + ) + assert result == payload + + @pytest.mark.parametrize("sport", ["", " ", "cycling/running", 123, None]) + def test_get_power_zones_for_sport_rejects_invalid_keys( + self, garmin: garminconnect.Garmin, sport: object + ): + with pytest.raises(ValueError, match="sport must"): + garmin.get_power_zones_for_sport(sport) # type: ignore[arg-type] + def test_get_stress_data_builds_url_with_date(self, garmin: garminconnect.Garmin): with patch.object(garmin, "connectapi", return_value={"avgStress": 25}) as mock: garmin.get_stress_data("2026-03-15")