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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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(),
Expand Down
26 changes: 25 additions & 1 deletion garminconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"))
Expand Down
35 changes: 35 additions & 0 deletions tests/test_garmin_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down