feat: add health stats tools (sleep, HR, stress, body battery, HRV, SpO2)
This commit is contained in:
102
server.py
102
server.py
@@ -130,6 +130,108 @@ def get_activity_details(activity_id: str) -> str:
|
|||||||
return f"Error fetching activity details: {exc}"
|
return f"Error fetching activity details: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_sleep(date: str = "") -> str:
|
||||||
|
"""Get sleep data for a date (YYYY-MM-DD): score, stages, duration. Defaults to today."""
|
||||||
|
if err := _check_auth():
|
||||||
|
return err
|
||||||
|
cdate = date or _today()
|
||||||
|
try:
|
||||||
|
result = _client.get_sleep_data(cdate)
|
||||||
|
if not result:
|
||||||
|
return f"No sleep data found for {cdate}"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except GarminConnectAuthenticationError:
|
||||||
|
return "Authentication error. Call authenticate() again."
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error fetching sleep data: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_heart_rate(date: str = "") -> str:
|
||||||
|
"""Get heart rate data for a date (YYYY-MM-DD): resting HR and daily timeline. Defaults to today."""
|
||||||
|
if err := _check_auth():
|
||||||
|
return err
|
||||||
|
cdate = date or _today()
|
||||||
|
try:
|
||||||
|
result = _client.get_heart_rates(cdate)
|
||||||
|
if not result:
|
||||||
|
return f"No heart rate data found for {cdate}"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except GarminConnectAuthenticationError:
|
||||||
|
return "Authentication error. Call authenticate() again."
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error fetching heart rate data: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_stress(date: str = "") -> str:
|
||||||
|
"""Get stress level timeline for a date (YYYY-MM-DD). Defaults to today."""
|
||||||
|
if err := _check_auth():
|
||||||
|
return err
|
||||||
|
cdate = date or _today()
|
||||||
|
try:
|
||||||
|
result = _client.get_stress_data(cdate)
|
||||||
|
if not result:
|
||||||
|
return f"No stress data found for {cdate}"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except GarminConnectAuthenticationError:
|
||||||
|
return "Authentication error. Call authenticate() again."
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error fetching stress data: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_body_battery(date: str = "") -> str:
|
||||||
|
"""Get body battery charge/drain values for a date (YYYY-MM-DD). Defaults to today."""
|
||||||
|
if err := _check_auth():
|
||||||
|
return err
|
||||||
|
cdate = date or _today()
|
||||||
|
try:
|
||||||
|
result = _client.get_body_battery(cdate, cdate)
|
||||||
|
if not result:
|
||||||
|
return f"No body battery data found for {cdate}"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except GarminConnectAuthenticationError:
|
||||||
|
return "Authentication error. Call authenticate() again."
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error fetching body battery data: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_hrv(date: str = "") -> str:
|
||||||
|
"""Get HRV status and nightly average for a date (YYYY-MM-DD). Defaults to today."""
|
||||||
|
if err := _check_auth():
|
||||||
|
return err
|
||||||
|
cdate = date or _today()
|
||||||
|
try:
|
||||||
|
result = _client.get_hrv_data(cdate)
|
||||||
|
if not result:
|
||||||
|
return f"No HRV data found for {cdate}"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except GarminConnectAuthenticationError:
|
||||||
|
return "Authentication error. Call authenticate() again."
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error fetching HRV data: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_spo2(date: str = "") -> str:
|
||||||
|
"""Get SpO2 (blood oxygen) readings for a date (YYYY-MM-DD). Defaults to today."""
|
||||||
|
if err := _check_auth():
|
||||||
|
return err
|
||||||
|
cdate = date or _today()
|
||||||
|
try:
|
||||||
|
result = _client.get_spo2_data(cdate)
|
||||||
|
if not result:
|
||||||
|
return f"No SpO2 data found for {cdate}"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except GarminConnectAuthenticationError:
|
||||||
|
return "Authentication error. Call authenticate() again."
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error fetching SpO2 data: {exc}"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
_startup_login()
|
_startup_login()
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
@@ -145,3 +145,70 @@ def test_get_activity_details_error():
|
|||||||
server._client = MagicMock()
|
server._client = MagicMock()
|
||||||
server._client.get_activity_details.side_effect = Exception("not found")
|
server._client.get_activity_details.side_effect = Exception("not found")
|
||||||
assert "Error fetching activity details" in server.get_activity_details("999")
|
assert "Error fetching activity details" in server.get_activity_details("999")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sleep_success():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_sleep_data.return_value = {
|
||||||
|
"dailySleepDTO": {"sleepScores": {"overall": {"value": 85}}}
|
||||||
|
}
|
||||||
|
data = json.loads(server.get_sleep("2026-05-17"))
|
||||||
|
assert data["dailySleepDTO"]["sleepScores"]["overall"]["value"] == 85
|
||||||
|
server._client.get_sleep_data.assert_called_once_with("2026-05-17")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sleep_no_data():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_sleep_data.return_value = None
|
||||||
|
assert server.get_sleep("2026-05-17") == "No sleep data found for 2026-05-17"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_heart_rate_success():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_heart_rates.return_value = {"restingHeartRate": 52}
|
||||||
|
data = json.loads(server.get_heart_rate("2026-05-17"))
|
||||||
|
assert data["restingHeartRate"] == 52
|
||||||
|
server._client.get_heart_rates.assert_called_once_with("2026-05-17")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_stress_success():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_stress_data.return_value = {"stressValuesArray": [[1000, 35]]}
|
||||||
|
data = json.loads(server.get_stress("2026-05-17"))
|
||||||
|
assert "stressValuesArray" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_body_battery_success():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_body_battery.return_value = [{"bodyBatteryStatList": []}]
|
||||||
|
data = json.loads(server.get_body_battery("2026-05-17"))
|
||||||
|
assert isinstance(data, list)
|
||||||
|
server._client.get_body_battery.assert_called_once_with("2026-05-17", "2026-05-17")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_hrv_success():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_hrv_data.return_value = {"hrvSummary": {"weeklyAvg": 45}}
|
||||||
|
data = json.loads(server.get_hrv("2026-05-17"))
|
||||||
|
assert data["hrvSummary"]["weeklyAvg"] == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_spo2_success():
|
||||||
|
server._auth_state = "authenticated"
|
||||||
|
server._client = MagicMock()
|
||||||
|
server._client.get_spo2_data.return_value = {"spO2HourlyAverages": []}
|
||||||
|
data = json.loads(server.get_spo2("2026-05-17"))
|
||||||
|
assert "spO2HourlyAverages" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_tools_unauthenticated():
|
||||||
|
server._auth_state = "unauthenticated"
|
||||||
|
for fn in (server.get_sleep, server.get_heart_rate, server.get_stress,
|
||||||
|
server.get_body_battery, server.get_hrv, server.get_spo2):
|
||||||
|
assert "Not authenticated" in fn("2026-05-17")
|
||||||
|
|||||||
Reference in New Issue
Block a user