3744 lines
151 KiB
Python
3744 lines
151 KiB
Python
"""Python 3 API wrapper for Garmin Connect."""
|
|
|
|
import contextlib
|
|
import functools
|
|
import logging
|
|
import numbers
|
|
import os
|
|
import random
|
|
import re
|
|
import time
|
|
from collections.abc import Callable
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from .typed import TypedGarmin
|
|
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
from requests import HTTPError
|
|
|
|
from . import client
|
|
from .activity_details import (
|
|
parse_activity_detail_metrics as parse_activity_detail_metrics,
|
|
)
|
|
from .fit import FitEncoderWeight # type: ignore[attr-defined]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Regex used to extract an HTTP status code from the client's error messages.
|
|
# The underlying client raises GarminConnectConnectionError with a message of
|
|
# the form "API Error {status} - {detail}", so we parse it to decide whether
|
|
# a failure is retryable (5xx) or not (4xx, auth, etc.).
|
|
_STATUS_CODE_RE = re.compile(r"(?:API Error|Error|HTTP)\s*(\d{3})")
|
|
|
|
# Constants for validation
|
|
MAX_ACTIVITY_LIMIT = 1000
|
|
MAX_HYDRATION_ML = 10000 # 10 liters
|
|
# Safety cap on server-driven pagination loops (get_activities_by_date,
|
|
# get_goals). Termination of those loops otherwise depends entirely on the
|
|
# server eventually returning an empty page, so a hostile/broken server could
|
|
# loop the client forever with unbounded memory growth. 2000 pages at 20-30
|
|
# items per page (40k-60k items) is far beyond any legitimate account.
|
|
MAX_PAGINATED_REQUESTS = 2000
|
|
DATE_FORMAT_REGEX = r"^\d{4}-\d{2}-\d{2}$"
|
|
DATE_FORMAT_STR = "%Y-%m-%d"
|
|
|
|
# Holes 1-18, separated by ',' or '-'; '-' is a plain delimiter, not a range
|
|
# operator (e.g. "1-18" selects holes 1 and 18, not the full front/back).
|
|
# Garmin's API only accepts '-' as the separator (commas 400 in any encoding),
|
|
# so callers may pass ',' for convenience but it gets normalized to '-' before
|
|
# the request is sent.
|
|
HOLE_NUMBERS_REGEX = r"^([1-9]|1[0-8])([,-]([1-9]|1[0-8]))*$"
|
|
SPORT_KEY_REGEX = r"^[A-Z_]+$"
|
|
VALID_WEIGHT_UNITS = {"kg", "lbs"}
|
|
|
|
|
|
# Add validation utilities
|
|
def _validate_date_format(date_str: str, param_name: str = "date") -> str:
|
|
"""Validate date string format YYYY-MM-DD."""
|
|
if not isinstance(date_str, str):
|
|
raise ValueError(f"{param_name} must be a string")
|
|
|
|
# Remove any extra whitespace
|
|
date_str = date_str.strip()
|
|
|
|
if not re.fullmatch(DATE_FORMAT_REGEX, date_str):
|
|
raise ValueError(
|
|
f"{param_name} must be in format 'YYYY-MM-DD', got: {date_str}"
|
|
)
|
|
|
|
try:
|
|
# Validate that it's a real date
|
|
datetime.strptime(date_str, DATE_FORMAT_STR)
|
|
except ValueError as e:
|
|
raise ValueError(f"invalid {param_name}: {e}") from e
|
|
|
|
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:
|
|
"""Validate that a number is positive."""
|
|
if not isinstance(value, numbers.Real):
|
|
raise ValueError(f"{param_name} must be a number")
|
|
|
|
if isinstance(value, bool):
|
|
raise ValueError(f"{param_name} must be a number, not bool")
|
|
|
|
if value <= 0:
|
|
raise ValueError(f"{param_name} must be positive, got: {value}")
|
|
|
|
return value
|
|
|
|
|
|
def _validate_non_negative_integer(value: int, param_name: str = "value") -> int:
|
|
"""Validate that a value is a non-negative integer."""
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise ValueError(f"{param_name} must be an integer")
|
|
|
|
if value < 0:
|
|
raise ValueError(f"{param_name} must be non-negative, got: {value}")
|
|
|
|
return value
|
|
|
|
|
|
def _validate_positive_integer(value: int, param_name: str = "value") -> int:
|
|
"""Validate that a value is a positive integer."""
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise ValueError(f"{param_name} must be an integer")
|
|
if value <= 0:
|
|
raise ValueError(f"{param_name} must be a positive integer, got: {value}")
|
|
return value
|
|
|
|
|
|
def _validate_hole_numbers(value: str, param_name: str = "hole_numbers") -> str:
|
|
"""Validate a golf hole-numbers string: holes 1-18 separated by ',' or '-'.
|
|
|
|
Spaces around separators are tolerated (e.g. "1, 2, 3" or "1 - 18").
|
|
"""
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"{param_name} must be a string")
|
|
# Remove spaces so callers can use "1, 2, 3" or "1 - 18" and still pass
|
|
# a clean hyphen-separated string to Garmin's API.
|
|
value = value.replace(" ", "")
|
|
if not re.fullmatch(HOLE_NUMBERS_REGEX, value):
|
|
raise ValueError(
|
|
f"{param_name} must be holes 1-18 separated by ',' or '-', got: {value!r}"
|
|
)
|
|
return value
|
|
|
|
|
|
def _validate_sport_key(value: str, param_name: str = "sport") -> str:
|
|
"""Validate and normalize a Garmin sport key (letters and underscores only)."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f"{param_name} must be a non-empty string")
|
|
normalized = value.strip().upper()
|
|
if not re.fullmatch(SPORT_KEY_REGEX, normalized):
|
|
raise ValueError(f"{param_name} must contain only letters and underscores")
|
|
return normalized
|
|
|
|
|
|
def _validate_uuid(value: str, param_name: str = "uuid") -> str:
|
|
"""Validate a UUID string (with or without hyphens)."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f"{param_name} must be a non-empty string")
|
|
value = value.strip()
|
|
if not re.fullmatch(
|
|
r"^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$",
|
|
value,
|
|
):
|
|
raise ValueError(f"{param_name} must be a valid UUID, got: {value!r}")
|
|
return value
|
|
|
|
|
|
def _fmt_ts(dt: datetime) -> str:
|
|
# Use ms precision to match server expectations
|
|
return dt.replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
|
|
|
|
def _validate_json_exists(response: requests.Response) -> dict[str, Any] | None:
|
|
if response.status_code == 204:
|
|
return None
|
|
return response.json()
|
|
|
|
|
|
def _looks_like_json(value: str) -> bool:
|
|
"""Return True if the value appears to be JSON rather than a file path.
|
|
|
|
The tokenstore argument may be either a filesystem path or an inline JSON
|
|
token string. Structural detection is more reliable than a length threshold,
|
|
which misclassifies long legitimate paths as JSON and short JSON strings as
|
|
paths.
|
|
"""
|
|
stripped = value.strip()
|
|
return stripped.startswith(("{", "["))
|
|
|
|
|
|
def _extract_status_code(exc: BaseException) -> int | None:
|
|
"""Best-effort extraction of an HTTP status code from an exception.
|
|
|
|
Checks ``status_code`` and ``response.status_code`` attributes, then
|
|
parses ``"API Error NNN"`` / ``"HTTP NNN"`` patterns out of the message
|
|
(the client raises ``GarminConnectConnectionError`` with messages like
|
|
``"API Error 503 - ..."``).
|
|
"""
|
|
status = getattr(exc, "status_code", None)
|
|
if isinstance(status, int):
|
|
return status
|
|
resp = getattr(exc, "response", None)
|
|
status = getattr(resp, "status_code", None)
|
|
if isinstance(status, int):
|
|
return status
|
|
match = _STATUS_CODE_RE.search(str(exc))
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def _has_network_cause(exc: BaseException) -> bool:
|
|
"""Walk ``__cause__`` / ``__context__`` looking for a raw network error."""
|
|
seen: set[int] = set()
|
|
cur: BaseException | None = exc
|
|
while cur is not None and id(cur) not in seen:
|
|
seen.add(id(cur))
|
|
if isinstance(cur, requests.ConnectionError | requests.Timeout):
|
|
return True
|
|
cur = cur.__cause__ or cur.__context__
|
|
return False
|
|
|
|
|
|
def _is_retryable(exc: BaseException) -> bool:
|
|
"""Return True for transient errors worth retrying.
|
|
|
|
Retries 5xx server errors and genuine network failures. Never retries
|
|
401 (auth), 429 (rate-limit) or 4xx (client) errors — those are
|
|
deterministic and caller-actionable.
|
|
"""
|
|
if isinstance(
|
|
exc, GarminConnectAuthenticationError | GarminConnectTooManyRequestsError
|
|
):
|
|
return False
|
|
if isinstance(exc, GarminConnectConnectionError):
|
|
status = _extract_status_code(exc)
|
|
if status is None:
|
|
return _has_network_cause(exc)
|
|
return 500 <= status < 600
|
|
return isinstance(exc, requests.ConnectionError | requests.Timeout)
|
|
|
|
|
|
def _backoff_delay(attempt: int, obj: Any) -> float:
|
|
"""Exponential backoff with 50-100% jitter, bounded by ``retry_max_wait``."""
|
|
min_wait = getattr(obj, "retry_min_wait", 1.0)
|
|
max_wait = getattr(obj, "retry_max_wait", 10.0)
|
|
base = min(max_wait, min_wait * (2**attempt))
|
|
return base * (0.5 + random.random() * 0.5) # noqa: S311 jitter, not crypto
|
|
|
|
|
|
def _handle_api_errors(
|
|
label: str,
|
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
"""Decorator: uniform error translation + optional retry for Garmin API calls.
|
|
|
|
Translates transport-level exceptions to domain-specific
|
|
``GarminConnectAuthenticationError`` (401), ``GarminConnectTooManyRequestsError``
|
|
(429) or ``GarminConnectConnectionError`` (all other HTTP / network failures),
|
|
preserving the original ``.response`` where available so callers can still
|
|
introspect the underlying response.
|
|
|
|
Retries are controlled by ``self.retry_attempts`` (default ``3``; set to
|
|
``0`` to disable). When enabled, only 5xx server errors and raw connection
|
|
/ timeout failures are retried, with exponential backoff plus jitter
|
|
between ``retry_min_wait`` and ``retry_max_wait`` seconds. 401 / 429 / 4xx
|
|
always fail fast.
|
|
"""
|
|
|
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
@functools.wraps(func)
|
|
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
path = args[0] if args else kwargs.get("path", "<unknown>")
|
|
attempts = max(0, getattr(self, "retry_attempts", 0))
|
|
for attempt in range(attempts + 1):
|
|
try:
|
|
return func(self, *args, **kwargs)
|
|
except (
|
|
GarminConnectAuthenticationError,
|
|
GarminConnectTooManyRequestsError,
|
|
):
|
|
# Already domain-specific and never retryable.
|
|
raise
|
|
except (HTTPError, GarminConnectConnectionError) as e:
|
|
status = _extract_status_code(e)
|
|
if status == 401:
|
|
raise GarminConnectAuthenticationError(
|
|
f"Authentication failed: {e}"
|
|
) from e
|
|
if status == 429:
|
|
raise GarminConnectTooManyRequestsError(
|
|
f"Rate limit exceeded: {e}"
|
|
) from e
|
|
if status == 404:
|
|
not_found_exc = GarminConnectNotFoundError(
|
|
f"{label} client error ({status}): {e}"
|
|
)
|
|
resp = getattr(e, "response", None)
|
|
if resp is not None:
|
|
not_found_exc.response = resp
|
|
raise not_found_exc from e
|
|
if status and 400 <= status < 500:
|
|
new_exc = GarminConnectConnectionError(
|
|
f"{label} client error ({status}): {e}"
|
|
)
|
|
resp = getattr(e, "response", None)
|
|
if resp is not None:
|
|
new_exc.response = resp
|
|
raise new_exc from e
|
|
# 5xx or no parseable status — retryable path.
|
|
if attempt < attempts:
|
|
delay = _backoff_delay(attempt, self)
|
|
logger.warning(
|
|
"%s attempt %d/%d failed (path=%s status=%s) — "
|
|
"retrying in %.1fs: %s",
|
|
label,
|
|
attempt + 1,
|
|
attempts + 1,
|
|
path,
|
|
status,
|
|
delay,
|
|
e,
|
|
)
|
|
time.sleep(delay)
|
|
continue
|
|
logger.exception(
|
|
"%s failed for path '%s' (status=%s)", label, path, status
|
|
)
|
|
new_exc = GarminConnectConnectionError(f"{label} HTTP error: {e}")
|
|
resp = getattr(e, "response", None)
|
|
if resp is not None:
|
|
new_exc.response = resp
|
|
raise new_exc from e
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
if attempt < attempts:
|
|
delay = _backoff_delay(attempt, self)
|
|
logger.warning(
|
|
"%s network error attempt %d/%d (path=%s) — "
|
|
"retrying in %.1fs: %s",
|
|
label,
|
|
attempt + 1,
|
|
attempts + 1,
|
|
path,
|
|
delay,
|
|
e,
|
|
)
|
|
time.sleep(delay)
|
|
continue
|
|
logger.exception("Network error during %s path=%s", label, path)
|
|
raise GarminConnectConnectionError(f"Connection error: {e}") from e
|
|
except Exception as e:
|
|
logger.exception("Connection error during %s path=%s", label, path)
|
|
raise GarminConnectConnectionError(f"Connection error: {e}") from e
|
|
# Unreachable: loop returns, retries, or raises.
|
|
raise RuntimeError(f"{label} retry loop exited without a result")
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
class Garmin:
|
|
"""Class for fetching data from Garmin Connect."""
|
|
|
|
def __init__(
|
|
self,
|
|
email: str | None = None,
|
|
password: str | None = None,
|
|
is_cn: bool = False,
|
|
prompt_mfa: Callable[[], str] | None = None,
|
|
return_on_mfa: bool = False,
|
|
retry_attempts: int = 3,
|
|
retry_min_wait: float = 1.0,
|
|
retry_max_wait: float = 10.0,
|
|
verify_login: bool = True,
|
|
) -> None:
|
|
"""Create a new class instance.
|
|
|
|
:param retry_attempts: Retries for transient 5xx / network errors on
|
|
``connectapi``, ``connectwebproxy`` and ``download``. Defaults to
|
|
``3``; set to ``0`` to disable. 401, 429 and 4xx always fail fast
|
|
regardless.
|
|
:param retry_min_wait: Initial backoff in seconds; grows exponentially
|
|
with jitter between attempts.
|
|
:param retry_max_wait: Upper bound on the backoff in seconds.
|
|
:param verify_login: When ``True`` (default), each login strategy's
|
|
token is validated against the API tier before the chain accepts
|
|
it, so a token the API rejects (401/403) is discarded and the next
|
|
strategy is tried. Set ``False`` for the legacy "first token wins"
|
|
behavior.
|
|
"""
|
|
# Validate input types
|
|
if email is not None and not isinstance(email, str):
|
|
raise ValueError("email must be a string or None")
|
|
if password is not None and not isinstance(password, str):
|
|
raise ValueError("password must be a string or None")
|
|
if not isinstance(is_cn, bool):
|
|
raise ValueError("is_cn must be a boolean")
|
|
if not isinstance(return_on_mfa, bool):
|
|
raise ValueError("return_on_mfa must be a boolean")
|
|
if isinstance(retry_attempts, bool) or not isinstance(retry_attempts, int):
|
|
raise ValueError("retry_attempts must be an int")
|
|
if retry_attempts < 0:
|
|
raise ValueError("retry_attempts must be non-negative")
|
|
if not isinstance(verify_login, bool):
|
|
raise ValueError("verify_login must be a boolean")
|
|
|
|
self.username = email
|
|
self.password = password
|
|
self.is_cn = is_cn
|
|
self.prompt_mfa = prompt_mfa
|
|
self.return_on_mfa = return_on_mfa
|
|
self.retry_attempts = retry_attempts
|
|
self.retry_min_wait = float(retry_min_wait)
|
|
self.retry_max_wait = float(retry_max_wait)
|
|
self.verify_login = verify_login
|
|
|
|
self.garmin_connect_user_settings_url = (
|
|
"/userprofile-service/userprofile/user-settings"
|
|
)
|
|
self.garmin_connect_userprofile_settings_url = (
|
|
"/userprofile-service/userprofile/settings"
|
|
)
|
|
self.garmin_connect_devices_url = "/device-service/deviceregistration/devices"
|
|
self.garmin_connect_device_url = "/device-service/deviceservice"
|
|
|
|
self.garmin_connect_devicemessage_url = "/device-service/devicemessage/messages"
|
|
|
|
self.garmin_connect_primary_device_url = (
|
|
"/web-gateway/device-info/primary-training-device"
|
|
)
|
|
|
|
self.garmin_connect_solar_url = "/web-gateway/solar"
|
|
self.garmin_connect_weight_url = "/weight-service"
|
|
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"
|
|
)
|
|
self.garmin_connect_set_hydration_url = (
|
|
"/usersummary-service/usersummary/hydration/log"
|
|
)
|
|
self.garmin_connect_daily_stats_steps_url = (
|
|
"/usersummary-service/stats/steps/daily"
|
|
)
|
|
self.garmin_connect_weekly_stats_steps_url = (
|
|
"/usersummary-service/stats/steps/weekly"
|
|
)
|
|
self.garmin_connect_weekly_stats_stress_url = (
|
|
"/usersummary-service/stats/stress/weekly"
|
|
)
|
|
self.garmin_connect_weekly_stats_intensity_minutes_url = (
|
|
"/usersummary-service/stats/im/weekly"
|
|
)
|
|
self.garmin_connect_personal_record_url = (
|
|
"/personalrecord-service/personalrecord/prs"
|
|
)
|
|
self.garmin_connect_earned_badges_url = "/badge-service/badge/earned"
|
|
self.garmin_connect_available_badges_url = "/badge-service/badge/available"
|
|
self.garmin_connect_adhoc_challenges_url = (
|
|
"/adhocchallenge-service/adHocChallenge/historical"
|
|
)
|
|
self.garmin_connect_badge_challenges_url = (
|
|
"/badgechallenge-service/badgeChallenge/completed"
|
|
)
|
|
self.garmin_connect_available_badge_challenges_url = (
|
|
"/badgechallenge-service/badgeChallenge/available"
|
|
)
|
|
self.garmin_connect_non_completed_badge_challenges_url = (
|
|
"/badgechallenge-service/badgeChallenge/non-completed"
|
|
)
|
|
self.garmin_connect_inprogress_virtual_challenges_url = (
|
|
"/badgechallenge-service/virtualChallenge/inProgress"
|
|
)
|
|
self.garmin_connect_daily_sleep_url = (
|
|
"/wellness-service/wellness/dailySleepData"
|
|
)
|
|
self.garmin_connect_daily_stress_url = "/wellness-service/wellness/dailyStress"
|
|
self.garmin_connect_hill_score_url = "/metrics-service/metrics/hillscore"
|
|
|
|
self.garmin_connect_daily_body_battery_url = (
|
|
"/wellness-service/wellness/bodyBattery/reports/daily"
|
|
)
|
|
|
|
self.garmin_connect_body_battery_events_url = (
|
|
"/wellness-service/wellness/bodyBattery/events"
|
|
)
|
|
|
|
self.garmin_connect_blood_pressure_endpoint = (
|
|
"/bloodpressure-service/bloodpressure/range"
|
|
)
|
|
|
|
self.garmin_connect_set_blood_pressure_endpoint = (
|
|
"/bloodpressure-service/bloodpressure"
|
|
)
|
|
|
|
self.garmin_connect_endurance_score_url = (
|
|
"/metrics-service/metrics/endurancescore"
|
|
)
|
|
self.garmin_connect_running_tolerance_url = (
|
|
"/metrics-service/metrics/runningtolerance/stats"
|
|
)
|
|
self.garmin_connect_menstrual_calendar_url = (
|
|
"/periodichealth-service/menstrualcycle/calendar"
|
|
)
|
|
|
|
self.garmin_connect_menstrual_dayview_url = (
|
|
"/periodichealth-service/menstrualcycle/dayview"
|
|
)
|
|
self.garmin_connect_pregnancy_snapshot_url = (
|
|
"/periodichealth-service/menstrualcycle/pregnancysnapshot"
|
|
)
|
|
self.garmin_connect_goals_url = "/goal-service/goal/goals"
|
|
|
|
self.garmin_connect_rhr_url = "/userstats-service/wellness/daily"
|
|
|
|
self.garmin_connect_hrv_url = "/hrv-service/hrv"
|
|
|
|
self.garmin_connect_training_readiness_url = (
|
|
"/metrics-service/metrics/trainingreadiness"
|
|
)
|
|
|
|
self.garmin_connect_race_predictor_url = (
|
|
"/metrics-service/metrics/racepredictions"
|
|
)
|
|
self.garmin_connect_training_status_url = (
|
|
"/metrics-service/metrics/trainingstatus/aggregated"
|
|
)
|
|
self.garmin_connect_user_summary_chart = (
|
|
"/wellness-service/wellness/dailySummaryChart"
|
|
)
|
|
self.garmin_connect_floors_chart_daily_url = (
|
|
"/wellness-service/wellness/floorsChartData/daily"
|
|
)
|
|
self.garmin_connect_heartrates_daily_url = (
|
|
"/wellness-service/wellness/dailyHeartRate"
|
|
)
|
|
self.garmin_connect_daily_respiration_url = (
|
|
"/wellness-service/wellness/daily/respiration"
|
|
)
|
|
self.garmin_connect_daily_spo2_url = "/wellness-service/wellness/daily/spo2"
|
|
self.garmin_connect_daily_intensity_minutes = (
|
|
"/wellness-service/wellness/daily/im"
|
|
)
|
|
self.garmin_daily_events_url = "/wellness-service/wellness/dailyEvents"
|
|
self.garmin_connect_activities = (
|
|
"/activitylist-service/activities/search/activities"
|
|
)
|
|
self.garmin_connect_activities_count = "/activitylist-service/activities/count"
|
|
self.garmin_connect_activities_baseurl = "/activitylist-service/activities/"
|
|
self.garmin_connect_activity = "/activity-service/activity"
|
|
self.garmin_connect_activity_types = "/activity-service/activity/activityTypes"
|
|
self.garmin_connect_activity_fordate = "/mobile-gateway/heartRate/forDate"
|
|
self.garmin_connect_fitnessstats = "/fitnessstats-service/activity"
|
|
self.garmin_connect_fitnessage = "/fitnessage-service/fitnessage"
|
|
|
|
self.garmin_connect_fit_download = "/download-service/files/activity"
|
|
self.garmin_connect_health_snapshot_download = (
|
|
"/download-service/files/wellness"
|
|
)
|
|
self.garmin_connect_tcx_download = "/download-service/export/tcx/activity"
|
|
self.garmin_connect_gpx_download = "/download-service/export/gpx/activity"
|
|
self.garmin_connect_kml_download = "/download-service/export/kml/activity"
|
|
self.garmin_connect_csv_download = "/download-service/export/csv/activity"
|
|
|
|
self.garmin_connect_upload = "/upload-service/upload"
|
|
|
|
self.garmin_connect_gear = "/gear-service/gear/filterGear"
|
|
self.garmin_connect_gear_baseurl = "/gear-service/gear"
|
|
|
|
self.garmin_request_reload_url = "/wellness-service/wellness/epoch/request"
|
|
|
|
self.garmin_workouts = "/workout-service"
|
|
|
|
self.garmin_workouts_schedule_url = f"{self.garmin_workouts}/schedule"
|
|
|
|
self.garmin_calendar = "/calendar-service"
|
|
self.garmin_scheduled_workouts_url = f"{self.garmin_calendar}"
|
|
|
|
self.garmin_nutrition = "/nutrition-service"
|
|
|
|
self.garmin_connect_nutrition_daily_food_logs = (
|
|
f"{self.garmin_nutrition}/food/logs"
|
|
)
|
|
self.garmin_connect_nutrition_daily_meals = f"{self.garmin_nutrition}/meals"
|
|
self.garmin_connect_nutrition_daily_settings = (
|
|
f"{self.garmin_nutrition}/settings"
|
|
)
|
|
|
|
self.garmin_golf = "/gcs-golfcommunity/api/v2"
|
|
self.garmin_golf_scorecard_summary = f"{self.garmin_golf}/scorecard/summary"
|
|
self.garmin_golf_scorecard_detail = f"{self.garmin_golf}/scorecard/detail"
|
|
self.garmin_golf_shot = f"{self.garmin_golf}/shot/scorecard"
|
|
self.garmin_golf_club_stats = f"{self.garmin_golf}/club/player"
|
|
self.garmin_golf_user_stats = f"{self.garmin_golf}/player/stats"
|
|
|
|
self.garmin_connect_delete_activity_url = "/activity-service/activity"
|
|
|
|
self.garmin_graphql_endpoint = "graphql-gateway/graphql"
|
|
|
|
self.garmin_connect_training_plan_url = "/trainingplan-service/trainingplan"
|
|
|
|
self.garmin_connect_daily_lifestyle_logging_url = (
|
|
"/lifestylelogging-service/dailyLog"
|
|
)
|
|
|
|
self.client = client.Client(
|
|
domain="garmin.cn" if is_cn else "garmin.com",
|
|
pool_connections=20,
|
|
pool_maxsize=20,
|
|
verify_login=verify_login,
|
|
)
|
|
|
|
self.display_name: str | None = None
|
|
self.full_name: str | None = None
|
|
self.unit_system: str | None = None
|
|
|
|
@functools.cached_property
|
|
def typed(self) -> "TypedGarmin":
|
|
"""Return a typed namespace that wraps selected endpoints with Pydantic models.
|
|
|
|
Example::
|
|
|
|
g = Garmin(email, password)
|
|
g.login()
|
|
stats = g.typed.get_stats("2026-04-21") # DailyStats
|
|
|
|
Requires the optional ``typed`` extra::
|
|
|
|
pip install 'garminconnect[typed]'
|
|
|
|
See :mod:`garminconnect.typed` for the list of wrapped endpoints and
|
|
response models. **Experimental** — shapes may change between minor
|
|
releases.
|
|
"""
|
|
# Lazy import so pydantic stays an optional dep. The typed module
|
|
# raises a clear ImportError on its own if pydantic is missing.
|
|
from .typed import TypedGarmin
|
|
|
|
return TypedGarmin(self)
|
|
|
|
@_handle_api_errors("API call")
|
|
def connectapi(self, path: str, **kwargs: Any) -> Any:
|
|
"""Native connectapi call with error translation and optional retries."""
|
|
return self.client.connectapi(path, **kwargs)
|
|
|
|
@_handle_api_errors("Web proxy call")
|
|
def connectwebproxy(self, path: str, **kwargs: Any) -> Any:
|
|
"""Web proxy request with error translation and optional retries."""
|
|
return self.client.request("GET", "connect", path, **kwargs).json()
|
|
|
|
@_handle_api_errors("Download")
|
|
def download(self, path: str, **kwargs: Any) -> Any:
|
|
"""Native download call with error translation and optional retries."""
|
|
return self.client.download(path, **kwargs)
|
|
|
|
def login(self, /, tokenstore: str | None = None) -> tuple[str | None, str | None]:
|
|
"""Log in natively.
|
|
|
|
Returns:
|
|
Tuple[str | None, str | None]: (needs_mfa, None) when MFA is required;
|
|
(None, None) on clean successful login.
|
|
|
|
"""
|
|
tokenstore = tokenstore or os.getenv("GARMINTOKENS")
|
|
|
|
try:
|
|
mfa_status = None
|
|
_legacy_token = None
|
|
|
|
# Try to load tokens from tokenstore if provided
|
|
tokens_loaded = False
|
|
tokenstore_path = None
|
|
if tokenstore:
|
|
try:
|
|
if _looks_like_json(tokenstore):
|
|
# Token data is provided directly as string
|
|
self.client.loads(tokenstore)
|
|
else:
|
|
# Tokenstore is a path - expand ~ for cross-platform
|
|
# compatibility. Deliberately NOT .resolve()d: resolve()
|
|
# follows symlinks, which would let a pre-planted
|
|
# tokenstore symlink reach client.load()/dump() as an
|
|
# ordinary path with the symlink already gone, bypassing
|
|
# token_file_path()'s anti-symlink check entirely.
|
|
# token_file_path() expands and validates the raw path
|
|
# itself; logout() already relies on that instead of
|
|
# resolving here.
|
|
tokenstore_path = str(Path(tokenstore).expanduser())
|
|
normalized_path = tokenstore_path
|
|
logger.debug(
|
|
f"Loading tokens from normalized path: {normalized_path}"
|
|
)
|
|
self.client.load(normalized_path)
|
|
tokens_loaded = True
|
|
|
|
# Proactively refresh DI token if it's expired or about to expire.
|
|
# This avoids hitting the SSO login endpoint (which may be
|
|
# Cloudflare-blocked) when a simple DI refresh would suffice.
|
|
with self.client._token_lock:
|
|
if (
|
|
self.client.di_refresh_token
|
|
and self.client._token_expires_soon()
|
|
):
|
|
logger.debug("Token expiring soon, refreshing proactively")
|
|
self.client._refresh_session()
|
|
|
|
except Exception as e:
|
|
# Never log the raw tokenstore value: it may be the inline
|
|
# token JSON (and thus the refresh token). Log only the
|
|
# source type, length, and the parse error.
|
|
source = "inline-json" if _looks_like_json(tokenstore) else "path"
|
|
logger.debug(
|
|
"Failed to cleanly load tokens (source=%s, len=%d): %s",
|
|
source,
|
|
len(tokenstore),
|
|
e,
|
|
)
|
|
tokens_loaded = False
|
|
|
|
# If tokens weren't loaded (or failed to load), use credentials
|
|
if not tokens_loaded:
|
|
# Validate credentials before attempting login
|
|
if not self.username or not self.password:
|
|
raise GarminConnectAuthenticationError(
|
|
"Username and password are required"
|
|
)
|
|
|
|
if self.return_on_mfa:
|
|
mfa_status, _legacy_token = self.client.login(
|
|
self.username,
|
|
self.password,
|
|
return_on_mfa=self.return_on_mfa,
|
|
)
|
|
# In MFA early-return mode, profile/settings are not loaded yet
|
|
return mfa_status, _legacy_token
|
|
if tokenstore_path is not None:
|
|
self.client._tokenstore_path = tokenstore_path
|
|
mfa_status, _legacy_token = self.client.login(
|
|
self.username,
|
|
self.password,
|
|
prompt_mfa=self.prompt_mfa,
|
|
)
|
|
# Persist tokens so next run restores without re-login/MFA
|
|
if tokenstore_path is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.client.dump(tokenstore_path)
|
|
# Continue to load profile/settings below
|
|
|
|
# Ensure profile is loaded (tokenstore path may not populate it).
|
|
try:
|
|
self._load_profile_and_settings()
|
|
except GarminConnectAuthenticationError:
|
|
# If we resumed from cached tokens and the API rejects them,
|
|
# the cache is stale/poisoned (e.g. a token whose audience the
|
|
# API tier no longer accepts — see #369). Discard it and run a
|
|
# full credential login so the strategy chain can find a token
|
|
# the API accepts. Without this, a poisoned cache silently
|
|
# short-circuits the chain on every run.
|
|
username, password = self.username, self.password
|
|
if not (
|
|
tokens_loaded and username and password and not self.return_on_mfa
|
|
):
|
|
raise
|
|
logger.warning(
|
|
"Cached tokens were rejected by the API; discarding and "
|
|
"performing a fresh login."
|
|
)
|
|
self.client._clear_auth_state()
|
|
if tokenstore_path is not None:
|
|
self.client._tokenstore_path = tokenstore_path
|
|
mfa_status, _legacy_token = self.client.login(
|
|
username,
|
|
password,
|
|
prompt_mfa=self.prompt_mfa,
|
|
)
|
|
if tokenstore_path is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.client.dump(tokenstore_path)
|
|
self._load_profile_and_settings()
|
|
|
|
# Successful authentication no longer needs the plaintext password.
|
|
# Keeping it would enlarge the credential exposure window (heap dumps,
|
|
# serialization, debug output) for the lifetime of the object.
|
|
self.password = None
|
|
|
|
return mfa_status, _legacy_token
|
|
|
|
except (
|
|
HTTPError,
|
|
requests.exceptions.HTTPError,
|
|
GarminConnectConnectionError,
|
|
) as e:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
error_str = str(e).lower()
|
|
# Re-raised below with a clean message; avoid logging a full
|
|
# traceback for expected failures (rate limits, bot challenges).
|
|
logger.debug("Login failed: %s (status=%s)", e, status)
|
|
|
|
if status == 429 or "429" in error_str:
|
|
raise GarminConnectTooManyRequestsError(
|
|
"Too many login attempts. Please wait a few minutes "
|
|
"before trying again."
|
|
) from e
|
|
|
|
if status == 401 or "401" in error_str or "unauthorized" in error_str:
|
|
raise GarminConnectAuthenticationError(
|
|
"Authentication failed (401 Unauthorized). "
|
|
"Possible causes:\n"
|
|
" • Incorrect email or password\n"
|
|
" • Account locked — check https://sso.garmin.com\n"
|
|
" • Garmin SSO service is temporarily unavailable\n"
|
|
f"Original error: {e}"
|
|
) from e
|
|
|
|
auth_indicators = ["unauthorized", "authentication failed"]
|
|
if any(indicator in error_str for indicator in auth_indicators):
|
|
raise GarminConnectAuthenticationError(
|
|
f"Authentication failed: {e}"
|
|
) from e
|
|
|
|
raise GarminConnectConnectionError(f"Login failed: {e}") from e
|
|
except FileNotFoundError:
|
|
raise
|
|
except (GarminConnectTooManyRequestsError, GarminConnectAuthenticationError):
|
|
raise
|
|
except Exception as e:
|
|
error_str = str(e).lower()
|
|
auth_indicators = ["401", "unauthorized", "authentication", "login failed"]
|
|
if any(indicator in error_str for indicator in auth_indicators):
|
|
raise GarminConnectAuthenticationError(
|
|
f"Authentication failed: {e}"
|
|
) from e
|
|
logger.debug("Login failed: %s", e)
|
|
raise GarminConnectConnectionError(f"Login failed: {e}") from e
|
|
|
|
def _load_profile_and_settings(self) -> None:
|
|
"""Fetch social profile and user settings, populating display name,
|
|
full name and unit system. Raises ``GarminConnectAuthenticationError``
|
|
if either cannot be retrieved (e.g. the token is rejected).
|
|
"""
|
|
prof = None
|
|
for attempt in range(3):
|
|
try:
|
|
prof = self.client.connectapi("/userprofile-service/socialProfile")
|
|
if isinstance(prof, dict):
|
|
break
|
|
except Exception as e:
|
|
if attempt == 2:
|
|
raise GarminConnectAuthenticationError(
|
|
"Failed to retrieve social profile"
|
|
) from e
|
|
logger.debug("Retrying social profile fetch: %s", e)
|
|
time.sleep(1)
|
|
else:
|
|
raise GarminConnectAuthenticationError("Invalid profile data found")
|
|
|
|
self.display_name = prof.get("displayName", self.username)
|
|
self.full_name = prof.get("fullName", "")
|
|
|
|
settings = None
|
|
for attempt in range(3):
|
|
try:
|
|
settings = self.client.connectapi(self.garmin_connect_user_settings_url)
|
|
if settings and isinstance(settings, dict) and "userData" in settings:
|
|
break
|
|
except Exception as e:
|
|
if attempt == 2:
|
|
raise GarminConnectAuthenticationError(
|
|
"Failed to retrieve user settings"
|
|
) from e
|
|
logger.debug("Retrying user settings fetch: %s", e)
|
|
time.sleep(1)
|
|
else:
|
|
raise GarminConnectAuthenticationError("Invalid user settings found")
|
|
|
|
self.unit_system = settings["userData"].get("measurementSystem")
|
|
|
|
def resume_login(
|
|
self, client_state: dict[str, Any], mfa_code: str
|
|
) -> tuple[Any, Any]:
|
|
"""Resume login interactively."""
|
|
mfa_status, _legacy_token = self.client.resume_login(client_state, mfa_code)
|
|
|
|
# The client already verifies the token against the API tier; if we reach
|
|
# this point the token is good. Load profile/settings normally and let
|
|
# any failure propagate so callers don't think the login succeeded.
|
|
self._load_profile_and_settings()
|
|
|
|
return mfa_status, _legacy_token
|
|
|
|
def _require_display_name(self) -> str:
|
|
"""Return display_name, URL-encoded, or raise if not set.
|
|
|
|
New/empty Garmin profiles may not have a displayName, which
|
|
would cause 'None' to be interpolated into API URLs and
|
|
result in 403 Forbidden errors.
|
|
|
|
Encoding the value before it enters a URL path prevents a
|
|
compromised or malicious server response from injecting path
|
|
separators or query/fragment characters via displayName.
|
|
"""
|
|
if not self.display_name:
|
|
raise GarminConnectConnectionError(
|
|
"Display name is not set. This usually means your "
|
|
"Garmin profile is incomplete (new account with no "
|
|
"display name configured). Please set a display name "
|
|
"at https://connect.garmin.com and try again."
|
|
)
|
|
return quote(self.display_name, safe="")
|
|
|
|
def get_full_name(self) -> str | None:
|
|
"""Return full name of the authenticated user."""
|
|
return self.full_name
|
|
|
|
def get_unit_system(self) -> str | None:
|
|
"""Return the user's unit system (e.g. metric)."""
|
|
return self.unit_system
|
|
|
|
def get_stats(self, cdate: str) -> dict[str, Any]:
|
|
"""Return user activity summary for 'cdate' format 'YYYY-MM-DD'
|
|
(compat for garminconnect).
|
|
"""
|
|
# Validate input
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
return self.get_user_summary(cdate)
|
|
|
|
def get_user_summary(self, cdate: str) -> dict[str, Any]:
|
|
"""Return user activity summary for 'cdate' format 'YYYY-MM-DD'.
|
|
|
|
Args:
|
|
cdate: The date to fetch the summary for.
|
|
|
|
Returns:
|
|
Dictionary containing the user activity summary.
|
|
|
|
"""
|
|
# Validate input
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
|
|
url = f"{self.garmin_connect_daily_summary_url}/{self._require_display_name()}"
|
|
params = {"calendarDate": cdate}
|
|
logger.debug("Requesting user summary")
|
|
|
|
response = self.connectapi(url, params=params)
|
|
|
|
if not response:
|
|
raise GarminConnectConnectionError("No data received from server")
|
|
|
|
if response.get("privacyProtected") is True:
|
|
raise GarminConnectAuthenticationError("Authentication error")
|
|
|
|
return response
|
|
|
|
def get_steps_data(self, cdate: str) -> list[dict[str, Any]]:
|
|
"""Fetch available steps data 'cDate' format 'YYYY-MM-DD'."""
|
|
# Validate input
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
|
|
url = f"{self.garmin_connect_user_summary_chart}/{self._require_display_name()}"
|
|
params = {"date": cdate}
|
|
logger.debug("Requesting steps data")
|
|
|
|
response = self.connectapi(url, params=params)
|
|
|
|
if response is None:
|
|
logger.warning("No steps data received")
|
|
return []
|
|
|
|
return response
|
|
|
|
def get_floors(self, cdate: str) -> dict[str, Any]:
|
|
"""Fetch available floors data 'cDate' format 'YYYY-MM-DD'."""
|
|
# Validate input
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
|
|
url = f"{self.garmin_connect_floors_chart_daily_url}/{cdate}"
|
|
logger.debug("Requesting floors data")
|
|
|
|
response = self.connectapi(url)
|
|
|
|
if response is None:
|
|
raise GarminConnectConnectionError("No floors data received")
|
|
|
|
return response
|
|
|
|
def get_daily_steps(self, start: str, end: str) -> list[dict[str, Any]]:
|
|
"""Fetch available steps data 'start' and 'end' format 'YYYY-MM-DD'.
|
|
|
|
Note: The Garmin Connect API 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.
|
|
"""
|
|
# Validate inputs
|
|
start = _validate_date_format(start, "start")
|
|
end = _validate_date_format(end, "end")
|
|
|
|
# Validate date range
|
|
start_date = datetime.strptime(start, DATE_FORMAT_STR).date()
|
|
end_date = datetime.strptime(end, DATE_FORMAT_STR).date()
|
|
|
|
if start_date > end_date:
|
|
raise ValueError("start date cannot be after end date")
|
|
|
|
# Calculate date range (inclusive)
|
|
days_diff = (end_date - start_date).days + 1
|
|
|
|
# If range is 28 days or less, make single request
|
|
if days_diff <= 28:
|
|
url = f"{self.garmin_connect_daily_stats_steps_url}/{start}/{end}"
|
|
logger.debug("Requesting daily steps data")
|
|
return self.connectapi(url)
|
|
|
|
# For ranges > 28 days, split into chunks
|
|
logger.debug(
|
|
f"Date range ({days_diff} days) exceeds 28-day limit, chunking requests"
|
|
)
|
|
all_results = []
|
|
current_start = start_date
|
|
|
|
while current_start <= end_date:
|
|
# Calculate chunk end (max 28 days from current_start)
|
|
chunk_end = min(current_start + timedelta(days=27), end_date)
|
|
chunk_start_str = current_start.isoformat()
|
|
chunk_end_str = chunk_end.isoformat()
|
|
|
|
url = (
|
|
f"{self.garmin_connect_daily_stats_steps_url}/"
|
|
f"{chunk_start_str}/{chunk_end_str}"
|
|
)
|
|
logger.debug(
|
|
f"Requesting daily steps data for chunk: "
|
|
f"{chunk_start_str} to {chunk_end_str}"
|
|
)
|
|
|
|
chunk_results = self.connectapi(url)
|
|
if chunk_results:
|
|
all_results.extend(chunk_results)
|
|
|
|
# Move to next chunk
|
|
current_start = chunk_end + timedelta(days=1)
|
|
|
|
return all_results
|
|
|
|
def get_weekly_steps(self, end: str, weeks: int = 52) -> list[dict[str, Any]]:
|
|
"""Fetch weekly steps aggregates.
|
|
|
|
Args:
|
|
end: End date string in format 'YYYY-MM-DD'
|
|
weeks: Number of weeks to fetch (default 52 = 1 year)
|
|
|
|
Returns:
|
|
List of weekly step aggregates containing:
|
|
- totalSteps: Total steps for the week
|
|
- averageSteps: Average daily steps
|
|
- totalDistance: Total distance in meters
|
|
- averageDistance: Average daily distance
|
|
- wellnessDataDaysCount: Days with data
|
|
|
|
"""
|
|
end = _validate_date_format(end, "end")
|
|
weeks = _validate_positive_integer(weeks, "weeks")
|
|
|
|
url = f"{self.garmin_connect_weekly_stats_steps_url}/{end}/{weeks}"
|
|
logger.debug("Requesting weekly steps data for %d weeks ending %s", weeks, end)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_weekly_stress(self, end: str, weeks: int = 52) -> list[dict[str, Any]]:
|
|
"""Fetch weekly stress aggregates.
|
|
|
|
Args:
|
|
end: End date string in format 'YYYY-MM-DD'
|
|
weeks: Number of weeks to fetch (default 52 = 1 year)
|
|
|
|
Returns:
|
|
List of weekly stress aggregates containing:
|
|
- value: Overall stress value for the week
|
|
- calendarDate: Week start date
|
|
|
|
"""
|
|
end = _validate_date_format(end, "end")
|
|
weeks = _validate_positive_integer(weeks, "weeks")
|
|
|
|
url = f"{self.garmin_connect_weekly_stats_stress_url}/{end}/{weeks}"
|
|
logger.debug("Requesting weekly stress data for %d weeks ending %s", weeks, end)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_weekly_intensity_minutes(
|
|
self, start: str, end: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch weekly intensity minutes aggregates.
|
|
|
|
Args:
|
|
start: Start date string in format 'YYYY-MM-DD'
|
|
end: End date string in format 'YYYY-MM-DD'
|
|
|
|
Returns:
|
|
List of weekly intensity minute aggregates containing:
|
|
- weeklyGoal: Weekly intensity minutes goal
|
|
- moderateValue: Moderate intensity minutes
|
|
- vigorousValue: Vigorous intensity minutes
|
|
- calendarDate: Week start date
|
|
|
|
"""
|
|
start = _validate_date_format(start, "start")
|
|
end = _validate_date_format(end, "end")
|
|
|
|
url = f"{self.garmin_connect_weekly_stats_intensity_minutes_url}/{start}/{end}"
|
|
logger.debug("Requesting weekly intensity minutes from %s to %s", start, end)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_heart_rates(self, cdate: str) -> dict[str, Any]:
|
|
"""Fetch available heart rates data 'cDate' format 'YYYY-MM-DD'.
|
|
|
|
Args:
|
|
cdate: Date string in format 'YYYY-MM-DD'
|
|
|
|
Returns:
|
|
Dictionary containing heart rate data for the specified date
|
|
|
|
Raises:
|
|
ValueError: If cdate format is invalid
|
|
GarminConnectConnectionError: If no data received
|
|
GarminConnectAuthenticationError: If authentication fails
|
|
|
|
"""
|
|
# Validate input
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
|
|
url = (
|
|
f"{self.garmin_connect_heartrates_daily_url}/{self._require_display_name()}"
|
|
)
|
|
params = {"date": cdate}
|
|
logger.debug("Requesting heart rates")
|
|
|
|
response = self.connectapi(url, params=params)
|
|
|
|
if response is None:
|
|
raise GarminConnectConnectionError("No heart rate data received")
|
|
|
|
return response
|
|
|
|
def get_stats_and_body(self, cdate: str) -> dict[str, Any]:
|
|
"""Return activity data and body composition (compat for garminconnect)."""
|
|
# Validate input
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
stats = self.get_stats(cdate)
|
|
body = self.get_body_composition(cdate)
|
|
body_avg = body.get("totalAverage") or {}
|
|
if not isinstance(body_avg, dict):
|
|
body_avg = {}
|
|
return {**stats, **body_avg}
|
|
|
|
def get_body_composition(
|
|
self, startdate: str, enddate: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Return available body composition data for 'startdate' format
|
|
'YYYY-MM-DD' through enddate 'YYYY-MM-DD'.
|
|
"""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = (
|
|
startdate if enddate is None else _validate_date_format(enddate, "enddate")
|
|
)
|
|
if (
|
|
datetime.strptime(startdate, DATE_FORMAT_STR).date()
|
|
> datetime.strptime(enddate, DATE_FORMAT_STR).date()
|
|
):
|
|
raise ValueError("startdate cannot be after enddate")
|
|
url = f"{self.garmin_connect_weight_url}/weight/dateRange"
|
|
params = {"startDate": startdate, "endDate": enddate}
|
|
logger.debug("Requesting body composition")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def add_body_composition(
|
|
self,
|
|
timestamp: str | None,
|
|
weight: float,
|
|
percent_fat: float | None = None,
|
|
percent_hydration: float | None = None,
|
|
visceral_fat_mass: float | None = None,
|
|
bone_mass: float | None = None,
|
|
muscle_mass: float | None = None,
|
|
basal_met: float | None = None,
|
|
active_met: float | None = None,
|
|
physique_rating: float | None = None,
|
|
metabolic_age: float | None = None,
|
|
visceral_fat_rating: float | None = None,
|
|
bmi: float | None = None,
|
|
) -> dict[str, Any]:
|
|
weight = _validate_positive_number(weight, "weight")
|
|
dt = datetime.fromisoformat(timestamp) if timestamp else datetime.now()
|
|
fitEncoder = FitEncoderWeight()
|
|
fitEncoder.write_file_info()
|
|
fitEncoder.write_file_creator()
|
|
fitEncoder.write_device_info(dt)
|
|
fitEncoder.write_weight_scale(
|
|
dt,
|
|
weight=weight,
|
|
percent_fat=percent_fat,
|
|
percent_hydration=percent_hydration,
|
|
visceral_fat_mass=visceral_fat_mass,
|
|
bone_mass=bone_mass,
|
|
muscle_mass=muscle_mass,
|
|
basal_met=basal_met,
|
|
active_met=active_met,
|
|
physique_rating=physique_rating,
|
|
metabolic_age=metabolic_age,
|
|
visceral_fat_rating=visceral_fat_rating,
|
|
bmi=bmi,
|
|
)
|
|
fitEncoder.finish()
|
|
|
|
url = self.garmin_connect_upload
|
|
files = {
|
|
"file": ("body_composition.fit", fitEncoder.getvalue()),
|
|
}
|
|
return self.client.post("connectapi", url, files=files, api=True)
|
|
|
|
def add_weigh_in(
|
|
self, weight: int | float, unitKey: str = "kg", timestamp: str = ""
|
|
) -> dict[str, Any] | None:
|
|
"""Add a weigh-in (default to kg)."""
|
|
# Validate inputs
|
|
weight = _validate_positive_number(weight, "weight")
|
|
|
|
if unitKey not in VALID_WEIGHT_UNITS:
|
|
raise ValueError(f"unitKey must be one of {VALID_WEIGHT_UNITS}")
|
|
|
|
url = f"{self.garmin_connect_weight_url}/user-weight"
|
|
|
|
try:
|
|
dt = datetime.fromisoformat(timestamp) if timestamp else datetime.now()
|
|
except ValueError as e:
|
|
raise ValueError(f"invalid timestamp format: {e}") from e
|
|
|
|
# Apply timezone offset to get UTC/GMT time
|
|
dtGMT = dt.astimezone(UTC)
|
|
payload = {
|
|
"dateTimestamp": _fmt_ts(dt),
|
|
"gmtTimestamp": _fmt_ts(dtGMT),
|
|
"unitKey": unitKey,
|
|
"sourceType": "MANUAL",
|
|
"value": weight,
|
|
}
|
|
logger.debug("Adding weigh-in")
|
|
return _validate_json_exists(self.client.post("connectapi", url, json=payload))
|
|
|
|
def add_weigh_in_with_timestamps(
|
|
self,
|
|
weight: int | float,
|
|
unitKey: str = "kg",
|
|
dateTimestamp: str = "",
|
|
gmtTimestamp: str = "",
|
|
) -> dict[str, Any] | None:
|
|
"""Add a weigh-in with explicit timestamps (default to kg)."""
|
|
url = f"{self.garmin_connect_weight_url}/user-weight"
|
|
|
|
if unitKey not in VALID_WEIGHT_UNITS:
|
|
raise ValueError(f"unitKey must be one of {VALID_WEIGHT_UNITS}")
|
|
# Make local timestamp timezone-aware
|
|
dt = (
|
|
datetime.fromisoformat(dateTimestamp).astimezone()
|
|
if dateTimestamp
|
|
else datetime.now().astimezone()
|
|
)
|
|
if gmtTimestamp:
|
|
g = datetime.fromisoformat(gmtTimestamp)
|
|
# Assume provided GMT is UTC if naive; otherwise convert to UTC
|
|
if g.tzinfo is None:
|
|
g = g.replace(tzinfo=UTC)
|
|
dtGMT = g.astimezone(UTC)
|
|
else:
|
|
dtGMT = dt.astimezone(UTC)
|
|
|
|
# Validate weight for consistency with add_weigh_in
|
|
weight = _validate_positive_number(weight, "weight")
|
|
# Build the payload
|
|
payload = {
|
|
"dateTimestamp": _fmt_ts(dt), # Local time (ms)
|
|
"gmtTimestamp": _fmt_ts(dtGMT), # GMT/UTC time (ms)
|
|
"unitKey": unitKey,
|
|
"sourceType": "MANUAL",
|
|
"value": weight,
|
|
}
|
|
|
|
# Debug log for payload
|
|
logger.debug("Adding weigh-in with explicit timestamps: %s", payload)
|
|
|
|
# Make the POST request
|
|
return _validate_json_exists(self.client.post("connectapi", url, json=payload))
|
|
|
|
def get_weigh_ins(self, startdate: str, enddate: str) -> dict[str, Any]:
|
|
"""Get weigh-ins between startdate and enddate using format 'YYYY-MM-DD'."""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
url = f"{self.garmin_connect_weight_url}/weight/range/{startdate}/{enddate}"
|
|
params = {"includeAll": True}
|
|
logger.debug("Requesting weigh-ins")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_daily_weigh_ins(self, cdate: str) -> dict[str, Any]:
|
|
"""Get weigh-ins for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_weight_url}/weight/dayview/{cdate}"
|
|
params = {"includeAll": True}
|
|
logger.debug("Requesting weigh-ins")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def delete_weigh_in(self, weight_pk: str, cdate: str) -> Any:
|
|
"""Delete specific weigh-in."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
weight_pk = str(_validate_positive_integer(int(weight_pk), "weight_pk"))
|
|
url = f"{self.garmin_connect_weight_url}/weight/{cdate}/byversion/{weight_pk}"
|
|
logger.debug("Deleting weigh-in")
|
|
|
|
return self.client.request(
|
|
"DELETE",
|
|
"connectapi",
|
|
url,
|
|
api=True,
|
|
)
|
|
|
|
def delete_weigh_ins(self, cdate: str, delete_all: bool = False) -> int | None:
|
|
"""Delete weigh-in for 'cdate' format 'YYYY-MM-DD'.
|
|
Includes option to delete all weigh-ins for that date.
|
|
"""
|
|
daily_weigh_ins = self.get_daily_weigh_ins(cdate)
|
|
weigh_ins = daily_weigh_ins.get("dateWeightList", [])
|
|
if not weigh_ins or len(weigh_ins) == 0:
|
|
logger.warning(f"No weigh-ins found on {cdate}")
|
|
return None
|
|
if len(weigh_ins) > 1:
|
|
logger.warning(f"Multiple weigh-ins found for {cdate}")
|
|
if not delete_all:
|
|
logger.warning(
|
|
f"Set delete_all to True to delete all {len(weigh_ins)} weigh-ins"
|
|
)
|
|
return None
|
|
|
|
for w in weigh_ins:
|
|
self.delete_weigh_in(w["samplePk"], cdate)
|
|
|
|
return len(weigh_ins)
|
|
|
|
def get_body_battery(
|
|
self, startdate: str, enddate: str | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Return body battery values by day for 'startdate' format
|
|
'YYYY-MM-DD' through enddate 'YYYY-MM-DD'.
|
|
"""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
if enddate is None:
|
|
enddate = startdate
|
|
else:
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
url = self.garmin_connect_daily_body_battery_url
|
|
params = {"startDate": startdate, "endDate": enddate}
|
|
logger.debug("Requesting body battery data")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_body_battery_events(self, cdate: str) -> list[dict[str, Any]]:
|
|
"""Return body battery events for date 'cdate' format 'YYYY-MM-DD'.
|
|
The return value is a list of dictionaries, where each dictionary contains event data for a specific event.
|
|
Events can include sleep, recorded activities, auto-detected activities, and naps.
|
|
"""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_body_battery_events_url}/{cdate}"
|
|
logger.debug("Requesting body battery event data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def set_blood_pressure(
|
|
self,
|
|
systolic: int,
|
|
diastolic: int,
|
|
pulse: int,
|
|
timestamp: str = "",
|
|
notes: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Add blood pressure measurement."""
|
|
url = f"{self.garmin_connect_set_blood_pressure_endpoint}"
|
|
dt = datetime.fromisoformat(timestamp) if timestamp else datetime.now()
|
|
# Apply timezone offset to get UTC/GMT time
|
|
dtGMT = dt.astimezone(UTC)
|
|
payload = {
|
|
"measurementTimestampLocal": _fmt_ts(dt),
|
|
"measurementTimestampGMT": _fmt_ts(dtGMT),
|
|
"systolic": systolic,
|
|
"diastolic": diastolic,
|
|
"pulse": pulse,
|
|
"sourceType": "MANUAL",
|
|
"notes": notes,
|
|
}
|
|
for name, val, lo, hi in (
|
|
("systolic", systolic, 70, 260),
|
|
("diastolic", diastolic, 40, 150),
|
|
("pulse", pulse, 20, 250),
|
|
):
|
|
if not isinstance(val, int) or not (lo <= val <= hi):
|
|
raise ValueError(f"{name} must be an int in [{lo}, {hi}]")
|
|
logger.debug("Adding blood pressure")
|
|
|
|
return self.client.post("connectapi", url, json=payload).json()
|
|
|
|
def get_blood_pressure(
|
|
self, startdate: str, enddate: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Returns blood pressure by day for 'startdate' format
|
|
'YYYY-MM-DD' through enddate 'YYYY-MM-DD'.
|
|
"""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
if enddate is None:
|
|
enddate = startdate
|
|
else:
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
url = f"{self.garmin_connect_blood_pressure_endpoint}/{startdate}/{enddate}"
|
|
params = {"includeAll": True}
|
|
logger.debug("Requesting blood pressure data")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def delete_blood_pressure(self, version: str, cdate: str) -> dict[str, Any]:
|
|
"""Delete specific blood pressure measurement."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
version = str(_validate_positive_integer(int(version), "version"))
|
|
url = f"{self.garmin_connect_set_blood_pressure_endpoint}/{cdate}/{version}"
|
|
logger.debug("Deleting blood pressure measurement")
|
|
|
|
return self.client.request(
|
|
"DELETE",
|
|
"connectapi",
|
|
url,
|
|
api=True,
|
|
).json()
|
|
|
|
def get_max_metrics(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available max metric data for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_metrics_url}/{cdate}/{cdate}"
|
|
logger.debug("Requesting max metrics")
|
|
|
|
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_functional_threshold_power_range(
|
|
self,
|
|
start: str,
|
|
end: str,
|
|
*,
|
|
sport: str = "RUNNING",
|
|
aggregation: str = "daily",
|
|
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
"""Return historic functional threshold power for a Garmin sport.
|
|
|
|
This uses Garmin Connect's undocumented biometric statistics range
|
|
endpoint. It is useful for FTP history because the existing
|
|
``get_cycling_ftp`` endpoint returns only the latest cycling value.
|
|
|
|
Args:
|
|
start: First date in the range, format 'YYYY-MM-DD'.
|
|
end: Last date in the range, format 'YYYY-MM-DD'.
|
|
sport: Garmin sport key (e.g. ``RUNNING``, ``CYCLING``).
|
|
aggregation: One of ``daily``, ``weekly``, ``monthly``, ``yearly``.
|
|
|
|
"""
|
|
start, end = _validate_date_range(start, end)
|
|
|
|
valid_aggregations = {"daily", "weekly", "monthly", "yearly"}
|
|
if aggregation not in valid_aggregations:
|
|
raise ValueError(f"aggregation must be one of {valid_aggregations}")
|
|
|
|
normalized_sport = _validate_sport_key(sport)
|
|
url = (
|
|
f"{self.garmin_connect_biometric_stats_url}"
|
|
f"/functionalThresholdPower/range/{start}/{end}"
|
|
)
|
|
params = {
|
|
"sport": normalized_sport,
|
|
"aggregation": aggregation,
|
|
"aggregationStrategy": "LATEST",
|
|
}
|
|
logger.debug(
|
|
"Requesting functional threshold power from %s to %s for sport %s",
|
|
start,
|
|
end,
|
|
normalized_sport,
|
|
)
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_lactate_threshold(
|
|
self,
|
|
*,
|
|
latest: bool = True,
|
|
start_date: str | date | None = None,
|
|
end_date: str | date | None = None,
|
|
aggregation: str = "daily",
|
|
) -> dict[str, Any]:
|
|
"""Returns Running Lactate Threshold information, including heart rate, power, and speed.
|
|
|
|
:param bool (Required) - latest: Whether to query for the latest Lactate Threshold info or a range. False if querying a range
|
|
:param date (Optional) - start_date: The first date in the range to query, format 'YYYY-MM-DD'. Required if `latest` is False. Ignored if `latest` is True
|
|
:param date (Optional) - end_date: The last date in the range to query, format 'YYYY-MM-DD'. Defaults to current data. Ignored if `latest` is True
|
|
:param str (Optional) - aggregation: How to aggregate the data. Must be one of `daily`, `weekly`, `monthly`, `yearly`.
|
|
"""
|
|
if latest:
|
|
speed_and_heart_rate_url = (
|
|
f"{self.garmin_connect_biometric_url}/latestLactateThreshold"
|
|
)
|
|
power_url = f"{self.garmin_connect_biometric_url}/powerToWeight/latest/{date.today()}"
|
|
|
|
power = self.connectapi(power_url, params={"sport": "Running"})
|
|
if isinstance(power, list) and power:
|
|
power_dict = power[0]
|
|
elif isinstance(power, dict):
|
|
power_dict = power
|
|
else:
|
|
power_dict = {}
|
|
|
|
speed_and_heart_rate = self.connectapi(speed_and_heart_rate_url)
|
|
|
|
speed_and_heart_rate_dict = {
|
|
"userProfilePK": None,
|
|
"version": None,
|
|
"calendarDate": None,
|
|
"sequence": None,
|
|
"speed": None,
|
|
"heartRate": None,
|
|
"heartRateCycling": None,
|
|
}
|
|
|
|
# Garmin /latestLactateThreshold endpoint returns a list of two
|
|
# (or more, if cyclingHeartRate ever gets values) nearly identical dicts.
|
|
# We're combining them here
|
|
for entry in speed_and_heart_rate:
|
|
speed = entry.get("speed")
|
|
if speed is not None:
|
|
speed_and_heart_rate_dict["userProfilePK"] = entry["userProfilePK"]
|
|
speed_and_heart_rate_dict["version"] = entry["version"]
|
|
speed_and_heart_rate_dict["calendarDate"] = entry["calendarDate"]
|
|
speed_and_heart_rate_dict["sequence"] = entry["sequence"]
|
|
speed_and_heart_rate_dict["speed"] = speed
|
|
|
|
# Prefer correct key; fall back to Garmin's historical typo ("hearRate")
|
|
hr = entry.get("heartRate") or entry.get("hearRate")
|
|
if hr is not None:
|
|
speed_and_heart_rate_dict["heartRate"] = hr
|
|
|
|
# Doesn't exist for me but adding it just in case. We'll check for each entry
|
|
hrc = entry.get("heartRateCycling")
|
|
if hrc is not None:
|
|
speed_and_heart_rate_dict["heartRateCycling"] = hrc
|
|
return {
|
|
"speed_and_heart_rate": speed_and_heart_rate_dict,
|
|
"power": power_dict,
|
|
}
|
|
|
|
if start_date is None:
|
|
raise ValueError("you must either specify 'latest=True' or a start_date")
|
|
|
|
if end_date is None:
|
|
end_date = date.today().isoformat()
|
|
|
|
# Normalize and validate
|
|
if isinstance(start_date, date):
|
|
start_date = start_date.isoformat()
|
|
else:
|
|
start_date = _validate_date_format(start_date, "start_date")
|
|
if isinstance(end_date, date):
|
|
end_date = end_date.isoformat()
|
|
else:
|
|
end_date = _validate_date_format(end_date, "end_date")
|
|
|
|
_valid_aggregations = {"daily", "weekly", "monthly", "yearly"}
|
|
if aggregation not in _valid_aggregations:
|
|
raise ValueError(f"aggregation must be one of {_valid_aggregations}")
|
|
|
|
power = self.get_functional_threshold_power_range(
|
|
start_date,
|
|
end_date,
|
|
sport="RUNNING",
|
|
aggregation=aggregation,
|
|
)
|
|
|
|
params = {
|
|
"sport": "RUNNING",
|
|
"aggregation": aggregation,
|
|
"aggregationStrategy": "LATEST",
|
|
}
|
|
speed_url = (
|
|
f"{self.garmin_connect_biometric_stats_url}"
|
|
f"/lactateThresholdSpeed/range/{start_date}/{end_date}"
|
|
)
|
|
|
|
heart_rate_url = (
|
|
f"{self.garmin_connect_biometric_stats_url}"
|
|
f"/lactateThresholdHeartRate/range/{start_date}/{end_date}"
|
|
)
|
|
|
|
speed = self.connectapi(speed_url, params=params)
|
|
heart_rate = self.connectapi(heart_rate_url, params=params)
|
|
|
|
return {"speed": speed, "heart_rate": heart_rate, "power": power}
|
|
|
|
def add_hydration_data(
|
|
self,
|
|
value_in_ml: float,
|
|
timestamp: str | None = None,
|
|
cdate: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Add hydration data in ml. Defaults to current date and current timestamp if left empty
|
|
:param float required - value_in_ml: The number of ml of water you wish to add (positive) or subtract (negative)
|
|
:param timestamp optional - timestamp: The timestamp of the hydration update, format 'YYYY-MM-DDThh:mm:ss.ms' Defaults to current timestamp
|
|
:param date optional - cdate: The date of the weigh in, format 'YYYY-MM-DD'. Defaults to current date.
|
|
"""
|
|
# Validate inputs
|
|
if not isinstance(value_in_ml, numbers.Real):
|
|
raise ValueError("value_in_ml must be a number")
|
|
value_in_ml = float(value_in_ml)
|
|
|
|
# Allow negative values for subtraction but validate reasonable range
|
|
if abs(value_in_ml) > MAX_HYDRATION_ML:
|
|
raise ValueError(
|
|
f"value_in_ml seems unreasonably high (>{MAX_HYDRATION_ML}ml)"
|
|
)
|
|
|
|
url = self.garmin_connect_set_hydration_url
|
|
|
|
if timestamp is None and cdate is None:
|
|
# If both are null, use today and now
|
|
raw_date = date.today()
|
|
cdate = str(raw_date)
|
|
|
|
raw_ts = datetime.now()
|
|
timestamp = _fmt_ts(raw_ts)
|
|
|
|
elif cdate is not None and timestamp is None:
|
|
# If cdate is provided, validate and use midnight local time
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
raw_ts = datetime.strptime(cdate, DATE_FORMAT_STR) # midnight local
|
|
timestamp = _fmt_ts(raw_ts)
|
|
|
|
elif cdate is None and timestamp is not None:
|
|
# If timestamp is provided, normalize and set cdate to its date part
|
|
if not isinstance(timestamp, str):
|
|
raise ValueError("timestamp must be a string")
|
|
try:
|
|
try:
|
|
raw_ts = datetime.fromisoformat(timestamp)
|
|
except ValueError:
|
|
raw_ts = datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S")
|
|
cdate = raw_ts.date().isoformat()
|
|
timestamp = _fmt_ts(raw_ts)
|
|
except ValueError as e:
|
|
raise ValueError("invalid timestamp format (expected ISO 8601)") from e
|
|
else:
|
|
# Both provided - validate consistency and normalize
|
|
if not isinstance(cdate, str):
|
|
raise ValueError("cdate must be a string")
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
if not isinstance(timestamp, str):
|
|
raise ValueError("timestamp must be a string")
|
|
try:
|
|
try:
|
|
raw_ts = datetime.fromisoformat(timestamp)
|
|
except ValueError:
|
|
raw_ts = datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S")
|
|
ts_date = raw_ts.date().isoformat()
|
|
if ts_date != cdate:
|
|
raise ValueError(
|
|
f"timestamp date ({ts_date}) doesn't match cdate ({cdate})"
|
|
)
|
|
timestamp = _fmt_ts(raw_ts)
|
|
except ValueError:
|
|
raise
|
|
|
|
payload = {
|
|
"calendarDate": cdate,
|
|
"timestampLocal": timestamp,
|
|
"valueInML": value_in_ml,
|
|
}
|
|
|
|
logger.debug("Adding hydration data")
|
|
return self.client.put("connectapi", url, json=payload).json()
|
|
|
|
def get_hydration_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available hydration data 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_hydration_url}/{cdate}"
|
|
logger.debug("Requesting hydration data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_respiration_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available respiration data 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_respiration_url}/{cdate}"
|
|
logger.debug("Requesting respiration data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_spo2_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available SpO2 data 'cdate' format 'YYYY-MM-DD'.
|
|
|
|
The API occasionally returns ``lastSevenDaysAvgSpO2`` as a string; it is
|
|
converted to ``float`` when that happens so the field is consistent with
|
|
the other numeric SpO2 values.
|
|
"""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_spo2_url}/{cdate}"
|
|
logger.debug("Requesting SpO2 data")
|
|
|
|
data = self.connectapi(url)
|
|
if isinstance(data, dict):
|
|
value = data.get("lastSevenDaysAvgSpO2")
|
|
if isinstance(value, str):
|
|
data["lastSevenDaysAvgSpO2"] = float(value)
|
|
return data
|
|
|
|
def get_intensity_minutes_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available Intensity Minutes data 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_intensity_minutes}/{cdate}"
|
|
logger.debug("Requesting Intensity Minutes data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_all_day_stress(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available all day stress data 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_stress_url}/{cdate}"
|
|
logger.debug("Requesting all day stress data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_all_day_events(self, cdate: str) -> dict[str, Any]:
|
|
"""Return available daily events data 'cdate' format 'YYYY-MM-DD'.
|
|
Includes autodetected activities, even if not recorded on the watch.
|
|
"""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = self.garmin_daily_events_url
|
|
logger.debug("Requesting all day events data")
|
|
|
|
return self.connectapi(url, params={"calendarDate": cdate})
|
|
|
|
def get_personal_record(self) -> dict[str, Any]:
|
|
"""Return personal records for current user."""
|
|
url = (
|
|
f"{self.garmin_connect_personal_record_url}/{self._require_display_name()}"
|
|
)
|
|
logger.debug("Requesting personal records for user")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_earned_badges(self) -> list[dict[str, Any]]:
|
|
"""Return all earned badges for the current user."""
|
|
url = self.garmin_connect_earned_badges_url
|
|
logger.debug("Requesting earned badges for user")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_available_badges(self) -> list[dict[str, Any]]:
|
|
"""Return available (not yet earned) badges for the current user."""
|
|
url = self.garmin_connect_available_badges_url
|
|
logger.debug("Requesting available badges for user")
|
|
|
|
return self.connectapi(url, params={"showExclusiveBadge": "true"})
|
|
|
|
def get_in_progress_badges(self) -> list[dict[str, Any]]:
|
|
"""Return all badges currently in progress for the current user."""
|
|
logger.debug("Requesting in progress badges for user")
|
|
|
|
earned_badges = self.get_earned_badges()
|
|
available_badges = self.get_available_badges()
|
|
|
|
# Filter out badges that are not in progress
|
|
def is_badge_in_progress(badge: dict) -> bool:
|
|
"""Return True if the badge is in progress."""
|
|
progress = badge.get("badgeProgressValue")
|
|
if not progress:
|
|
return False
|
|
if progress == 0:
|
|
return False
|
|
target = badge.get("badgeTargetValue")
|
|
if progress == target:
|
|
if badge.get("badgeLimitCount") is None:
|
|
return False
|
|
return badge.get("badgeEarnedNumber", 0) < badge["badgeLimitCount"]
|
|
return True
|
|
|
|
earned_in_progress_badges = list(filter(is_badge_in_progress, earned_badges))
|
|
available_in_progress_badges = list(
|
|
filter(is_badge_in_progress, available_badges)
|
|
)
|
|
|
|
combined = {b["badgeId"]: b for b in earned_in_progress_badges}
|
|
combined.update({b["badgeId"]: b for b in available_in_progress_badges})
|
|
return list(combined.values())
|
|
|
|
def get_adhoc_challenges(self, start: int, limit: int) -> dict[str, Any]:
|
|
"""Return adhoc challenges for the current user."""
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = self.garmin_connect_adhoc_challenges_url
|
|
params = {"start": str(start), "limit": str(limit)}
|
|
logger.debug("Requesting adhoc challenges for user")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_badge_challenges(self, start: int, limit: int) -> dict[str, Any]:
|
|
"""Return badge challenges for the current user."""
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = self.garmin_connect_badge_challenges_url
|
|
params = {"start": str(start), "limit": str(limit)}
|
|
logger.debug("Requesting badge challenges for user")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_available_badge_challenges(self, start: int, limit: int) -> dict[str, Any]:
|
|
"""Return available badge challenges."""
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = self.garmin_connect_available_badge_challenges_url
|
|
params = {"start": str(start), "limit": str(limit)}
|
|
logger.debug("Requesting available badge challenges")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_non_completed_badge_challenges(
|
|
self, start: int, limit: int
|
|
) -> dict[str, Any]:
|
|
"""Return badge non-completed challenges for current user."""
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = self.garmin_connect_non_completed_badge_challenges_url
|
|
params = {"start": str(start), "limit": str(limit)}
|
|
logger.debug("Requesting badge challenges for user")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_inprogress_virtual_challenges(
|
|
self, start: int, limit: int
|
|
) -> dict[str, Any]:
|
|
"""Return in-progress virtual challenges for current user."""
|
|
start = _validate_positive_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = self.garmin_connect_inprogress_virtual_challenges_url
|
|
params = {"start": str(start), "limit": str(limit)}
|
|
logger.debug("Requesting in-progress virtual challenges for user")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_sleep_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return sleep data for 'cdate' format 'YYYY-MM-DD'.
|
|
|
|
The response is passed through exactly as Garmin returns it. Some users
|
|
(notably China/UTC+8 accounts on connect.garmin.cn) have reported that
|
|
``sleepStartTimestampLocal`` / ``sleepEndTimestampLocal`` can be offset
|
|
by the local timezone twice. When in doubt, use the ``*GMT`` fields and
|
|
convert to local time yourself.
|
|
"""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_sleep_url}/{self._require_display_name()}"
|
|
params = {"date": cdate, "nonSleepBufferMinutes": 60}
|
|
logger.debug("Requesting sleep data")
|
|
|
|
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")
|
|
url = f"{self.garmin_connect_daily_stress_url}/{cdate}"
|
|
logger.debug("Requesting stress data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_lifestyle_logging_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return lifestyle logging data for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_daily_lifestyle_logging_url}/{cdate}"
|
|
logger.debug("Requesting lifestyle logging data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_rhr_day(self, cdate: str) -> dict[str, Any]:
|
|
"""Return resting heart rate data for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_rhr_url}/{self._require_display_name()}"
|
|
params = {
|
|
"fromDate": cdate,
|
|
"untilDate": cdate,
|
|
"metricId": 60,
|
|
}
|
|
logger.debug("Requesting resting heartrate data")
|
|
|
|
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")
|
|
url = f"{self.garmin_connect_hrv_url}/{cdate}"
|
|
logger.debug("Requesting Heart Rate Variability (hrv) data")
|
|
|
|
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")
|
|
url = f"{self.garmin_connect_training_readiness_url}/{cdate}"
|
|
logger.debug("Requesting training readiness data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_morning_training_readiness(self, cdate: str) -> dict[str, Any] | None:
|
|
"""Return morning training readiness data for current user.
|
|
|
|
This returns the Training Readiness score calculated immediately after
|
|
waking up, which is shown in Garmin's Morning Report feature. It filters
|
|
for entries with inputContext == 'AFTER_WAKEUP_RESET'.
|
|
|
|
Args:
|
|
cdate: Date string in format 'YYYY-MM-DD'
|
|
|
|
Returns:
|
|
Dictionary containing morning training readiness data, or None if
|
|
no morning data is available for the specified date.
|
|
|
|
Note:
|
|
Not all devices/firmware versions populate the inputContext field.
|
|
If inputContext is null for all entries, this method returns the
|
|
first entry as a fallback (typically the morning reading).
|
|
|
|
"""
|
|
data = self.get_training_readiness(cdate)
|
|
|
|
if not data:
|
|
return None
|
|
|
|
# The endpoint normally returns a list of snapshots, but stay defensive:
|
|
# some responses (or callers stubbing this) hand back a single dict.
|
|
if isinstance(data, dict):
|
|
return data
|
|
|
|
morning_entry = next(
|
|
(
|
|
entry
|
|
for entry in data
|
|
if entry.get("inputContext") == "AFTER_WAKEUP_RESET"
|
|
),
|
|
None,
|
|
)
|
|
|
|
if morning_entry is None:
|
|
logger.debug(
|
|
"No AFTER_WAKEUP_RESET context found, using first entry as fallback"
|
|
)
|
|
return data[0]
|
|
|
|
return morning_entry
|
|
|
|
def get_endurance_score(
|
|
self, startdate: str, enddate: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Return endurance score by day for 'startdate' format 'YYYY-MM-DD'
|
|
through enddate 'YYYY-MM-DD'.
|
|
Using a single day returns the precise values for that day.
|
|
Using a range returns the aggregated weekly values for that week.
|
|
"""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
if enddate is None:
|
|
url = self.garmin_connect_endurance_score_url
|
|
params = {"calendarDate": startdate}
|
|
logger.debug("Requesting endurance score data for a single day")
|
|
|
|
return self.connectapi(url, params=params)
|
|
url = f"{self.garmin_connect_endurance_score_url}/stats"
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
params = {
|
|
"startDate": startdate,
|
|
"endDate": enddate,
|
|
"aggregation": "weekly",
|
|
}
|
|
logger.debug("Requesting endurance score data for a range of days")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_running_tolerance(
|
|
self, startdate: str, enddate: str, aggregation: str = "weekly"
|
|
) -> list[dict[str, Any]]:
|
|
"""Return running tolerance data for date range.
|
|
|
|
Args:
|
|
startdate: Start date in 'YYYY-MM-DD' format.
|
|
enddate: End date in 'YYYY-MM-DD' format.
|
|
aggregation: 'daily' or 'weekly' (default: 'weekly').
|
|
|
|
Returns:
|
|
List of running tolerance data points.
|
|
|
|
"""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
if aggregation not in ("daily", "weekly"):
|
|
raise ValueError(
|
|
f"invalid aggregation '{aggregation}', must be 'daily' or 'weekly'"
|
|
)
|
|
url = self.garmin_connect_running_tolerance_url
|
|
params = {
|
|
"startDate": startdate,
|
|
"endDate": enddate,
|
|
"aggregation": aggregation,
|
|
}
|
|
logger.debug(
|
|
"Requesting running tolerance data (%s) from %s to %s",
|
|
aggregation,
|
|
startdate,
|
|
enddate,
|
|
)
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_race_predictions(
|
|
self,
|
|
startdate: str | None = None,
|
|
enddate: str | None = None,
|
|
_type: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return race predictions for the 5k, 10k, half marathon and marathon.
|
|
Accepts either 0 parameters or all three:
|
|
If all parameters are empty, returns the race predictions for the current date
|
|
Or returns the race predictions for each day or month in the range provided.
|
|
|
|
Keyword Arguments:
|
|
'startdate' the date of the earliest race predictions
|
|
Cannot be more than one year before 'enddate'
|
|
'enddate' the date of the last race predictions
|
|
'_type' either 'daily' (the predictions for each day in the range) or
|
|
'monthly' (the aggregated monthly prediction for each month in the range)
|
|
|
|
"""
|
|
valid = {"daily", "monthly", None}
|
|
if _type not in valid:
|
|
raise ValueError(f"results: _type must be one of {valid!r}.")
|
|
|
|
if _type is None and startdate is None and enddate is None:
|
|
url = (
|
|
self.garmin_connect_race_predictor_url
|
|
+ f"/latest/{self._require_display_name()}"
|
|
)
|
|
return self.connectapi(url)
|
|
|
|
if _type is not None and startdate is not None and enddate is not None:
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
if (
|
|
datetime.strptime(enddate, DATE_FORMAT_STR).date()
|
|
- datetime.strptime(startdate, DATE_FORMAT_STR).date()
|
|
).days > 366:
|
|
raise ValueError(
|
|
"startdate cannot be more than one year before enddate"
|
|
)
|
|
url = (
|
|
self.garmin_connect_race_predictor_url
|
|
+ f"/{_type}/{self._require_display_name()}"
|
|
)
|
|
params = {"fromCalendarDate": startdate, "toCalendarDate": enddate}
|
|
return self.connectapi(url, params=params)
|
|
|
|
raise ValueError("you must either provide all parameters or no parameters")
|
|
|
|
def get_training_status(self, cdate: str) -> dict[str, Any]:
|
|
"""Return training status data for current user."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_training_status_url}/{cdate}"
|
|
logger.debug("Requesting training status data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_fitnessage_data(self, cdate: str) -> dict[str, Any]:
|
|
"""Return Fitness Age data for current user."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_fitnessage}/{cdate}"
|
|
logger.debug("Requesting Fitness Age data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_hill_score(
|
|
self, startdate: str, enddate: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Return hill score by day from 'startdate' format 'YYYY-MM-DD'
|
|
to enddate 'YYYY-MM-DD'.
|
|
"""
|
|
if enddate is None:
|
|
url = self.garmin_connect_hill_score_url
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
params = {"calendarDate": startdate}
|
|
logger.debug("Requesting hill score data for a single day")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
url = f"{self.garmin_connect_hill_score_url}/stats"
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
params = {
|
|
"startDate": startdate,
|
|
"endDate": enddate,
|
|
"aggregation": "daily",
|
|
}
|
|
logger.debug("Requesting hill score data for a range of days")
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_devices(self) -> list[dict[str, Any]]:
|
|
"""Return available devices for the current user account."""
|
|
url = self.garmin_connect_devices_url
|
|
logger.debug("Requesting devices")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_device_settings(self, device_id: str) -> dict[str, Any]:
|
|
"""Return device settings for device with 'device_id'."""
|
|
device_id = str(_validate_positive_integer(int(device_id), "device_id"))
|
|
url = f"{self.garmin_connect_device_url}/device-info/settings/{device_id}"
|
|
logger.debug("Requesting device settings")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_primary_training_device(self) -> dict[str, Any]:
|
|
"""Return detailed information around primary training devices, included the specified device and the
|
|
priority of all devices.
|
|
"""
|
|
url = self.garmin_connect_primary_device_url
|
|
logger.debug("Requesting primary training device information")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_device_solar_data(
|
|
self, device_id: str, startdate: str, enddate: str | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Return solar data for compatible device with 'device_id'."""
|
|
if enddate is None:
|
|
enddate = startdate
|
|
single_day = True
|
|
else:
|
|
single_day = False
|
|
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
device_id = str(_validate_positive_integer(int(device_id), "device_id"))
|
|
params = {"singleDayView": single_day}
|
|
|
|
url = f"{self.garmin_connect_solar_url}/{device_id}/{startdate}/{enddate}"
|
|
|
|
resp = self.connectapi(url, params=params)
|
|
if not resp or "deviceSolarInput" not in resp:
|
|
raise GarminConnectConnectionError("No device solar input data received")
|
|
return resp["deviceSolarInput"]
|
|
|
|
def get_device_alarms(self) -> list[Any]:
|
|
"""Get list of active alarms from all devices."""
|
|
logger.debug("Requesting device alarms")
|
|
|
|
alarms = []
|
|
devices = self.get_devices()
|
|
for device in devices:
|
|
device_settings = self.get_device_settings(device["deviceId"])
|
|
device_alarms = device_settings.get("alarms")
|
|
if device_alarms is not None:
|
|
alarms += device_alarms
|
|
return alarms
|
|
|
|
def get_device_last_used(self) -> dict[str, Any]:
|
|
"""Return device last used."""
|
|
url = f"{self.garmin_connect_device_url}/mylastused"
|
|
logger.debug("Requesting device last used")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def count_activities(self) -> int:
|
|
"""Return total number of activities for the current user account."""
|
|
url = f"{self.garmin_connect_activities_count}"
|
|
logger.debug("Requesting activities count")
|
|
|
|
activities_count = self.connectapi(url)
|
|
if not activities_count or "totalCount" not in activities_count:
|
|
raise GarminConnectConnectionError("No activities count data received")
|
|
return activities_count["totalCount"]
|
|
|
|
def get_activities(
|
|
self,
|
|
start: int = 0,
|
|
limit: int = 20,
|
|
activitytype: str | None = None,
|
|
) -> dict[str, Any] | list[Any]:
|
|
"""Return available activities.
|
|
:param start: Starting activity offset, where 0 means the most recent activity
|
|
:param limit: Number of activities to return
|
|
:param activitytype: (Optional) Filter activities by type
|
|
:return: List of activities from Garmin.
|
|
"""
|
|
# Validate inputs
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
|
|
if limit > MAX_ACTIVITY_LIMIT:
|
|
raise ValueError(f"limit cannot exceed {MAX_ACTIVITY_LIMIT}")
|
|
|
|
url = self.garmin_connect_activities
|
|
params = {"start": str(start), "limit": str(limit)}
|
|
if activitytype:
|
|
params["activityType"] = activitytype
|
|
|
|
logger.debug("Requesting activities from %d with limit %d", start, limit)
|
|
|
|
activities = self.connectapi(url, params=params)
|
|
|
|
if activities is None:
|
|
logger.warning("No activities data received")
|
|
return []
|
|
|
|
return activities
|
|
|
|
def get_activities_fordate(self, fordate: str) -> dict[str, Any]:
|
|
"""Return available activities for date."""
|
|
fordate = _validate_date_format(fordate, "fordate")
|
|
url = f"{self.garmin_connect_activity_fordate}/{fordate}"
|
|
logger.debug("Requesting activities for date %s", fordate)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def set_activity_name(self, activity_id: str, title: str) -> Any:
|
|
"""Set name for activity with id."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}"
|
|
payload = {"activityId": activity_id, "activityName": title}
|
|
|
|
return self.client.put("connectapi", url, json=payload, api=True)
|
|
|
|
def set_activity_type(
|
|
self,
|
|
activity_id: str,
|
|
type_id: int,
|
|
type_key: str,
|
|
parent_type_id: int,
|
|
) -> Any:
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}"
|
|
payload = {
|
|
"activityId": activity_id,
|
|
"activityTypeDTO": {
|
|
"typeId": type_id,
|
|
"typeKey": type_key,
|
|
"parentTypeId": parent_type_id,
|
|
},
|
|
}
|
|
logger.debug("Changing activity type: %s", payload)
|
|
return self.client.put("connectapi", url, json=payload, api=True)
|
|
|
|
def set_activity_description(self, activity_id: str, description: str) -> Any:
|
|
"""Set description for activity with id."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}"
|
|
payload = {"activityId": activity_id, "description": description}
|
|
|
|
return self.client.put("connectapi", url, json=payload, api=True)
|
|
|
|
def create_manual_activity_from_json(self, payload: dict[str, Any]) -> Any:
|
|
url = f"{self.garmin_connect_activity}"
|
|
logger.debug("Uploading manual activity: %s", str(payload))
|
|
return self.client.post("connectapi", url, json=payload, api=True)
|
|
|
|
def create_manual_activity(
|
|
self,
|
|
start_datetime: str,
|
|
time_zone: str,
|
|
type_key: str,
|
|
distance_km: float,
|
|
duration_min: int,
|
|
activity_name: str,
|
|
) -> Any:
|
|
"""Create a private activity manually with a few basic parameters.
|
|
type_key - Garmin field representing type of activity. See https://connect.garmin.com/modern/main/js/properties/activity_types/activity_types.properties
|
|
Value to use is the key without 'activity_type_' prefix, e.g. 'resort_skiing'
|
|
start_datetime - timestamp in this pattern "2023-12-02T10:00:00.000"
|
|
time_zone - local timezone of the activity, e.g. 'Europe/Paris'
|
|
distance_km - distance of the activity in kilometers
|
|
duration_min - duration of the activity in minutes
|
|
activity_name - the title.
|
|
"""
|
|
payload = {
|
|
"activityTypeDTO": {"typeKey": type_key},
|
|
"accessControlRuleDTO": {"typeId": 2, "typeKey": "private"},
|
|
"timeZoneUnitDTO": {"unitKey": time_zone},
|
|
"activityName": activity_name,
|
|
"metadataDTO": {
|
|
"autoCalcCalories": True,
|
|
},
|
|
"summaryDTO": {
|
|
"startTimeLocal": start_datetime,
|
|
"distance": distance_km * 1000,
|
|
"duration": duration_min * 60,
|
|
},
|
|
}
|
|
return self.create_manual_activity_from_json(payload)
|
|
|
|
def get_last_activity(self) -> dict[str, Any] | None:
|
|
"""Return last activity."""
|
|
activities = self.get_activities(0, 1)
|
|
if activities and isinstance(activities, list) and len(activities) > 0:
|
|
return activities[-1]
|
|
if activities and isinstance(activities, dict) and "activityList" in activities:
|
|
activity_list = activities["activityList"]
|
|
if activity_list and len(activity_list) > 0:
|
|
return activity_list[-1]
|
|
|
|
return None
|
|
|
|
def upload_activity(self, activity_path: str) -> Any:
|
|
"""Upload activity in fit format from file."""
|
|
# This code is borrowed from python-garminconnect-enhanced ;-)
|
|
|
|
# Validate input
|
|
if not activity_path:
|
|
raise ValueError("activity_path cannot be empty")
|
|
|
|
if not isinstance(activity_path, str):
|
|
raise ValueError("activity_path must be a string")
|
|
|
|
# Check if file exists
|
|
p = Path(activity_path)
|
|
if not p.exists():
|
|
raise FileNotFoundError(f"File not found: {activity_path}")
|
|
|
|
# Check if it's actually a file
|
|
if not p.is_file():
|
|
raise ValueError(f"path is not a file: {activity_path}")
|
|
|
|
file_base_name = p.name
|
|
|
|
if not file_base_name:
|
|
raise ValueError("invalid file path - no filename found")
|
|
|
|
# More robust extension checking
|
|
file_parts = file_base_name.split(".")
|
|
if len(file_parts) < 2:
|
|
raise GarminConnectInvalidFileFormatError(
|
|
f"File has no extension: {activity_path}"
|
|
)
|
|
|
|
file_extension = file_parts[-1]
|
|
allowed_file_extension = (
|
|
file_extension.upper() in Garmin.ActivityUploadFormat.__members__
|
|
)
|
|
|
|
if allowed_file_extension:
|
|
try:
|
|
# Use context manager for file handling
|
|
with p.open("rb") as file_handle:
|
|
files = {"file": (file_base_name, file_handle)}
|
|
url = self.garmin_connect_upload
|
|
return self.client.post("connectapi", url, files=files, api=True)
|
|
except OSError as e:
|
|
raise GarminConnectConnectionError(
|
|
f"Failed to read file {activity_path}: {e}"
|
|
) from e
|
|
else:
|
|
allowed_formats = ", ".join(Garmin.ActivityUploadFormat.__members__.keys())
|
|
raise GarminConnectInvalidFileFormatError(
|
|
f"Invalid file format '{file_extension}'. Allowed formats: {allowed_formats}"
|
|
)
|
|
|
|
def import_activity(self, activity_path: str) -> dict[str, Any]:
|
|
"""Upload activity as an import (not re-exported to third parties like Strava).
|
|
|
|
Uses the Garmin import endpoint with headers matching Garmin Connect
|
|
Mobile, so imported activities are treated as imports rather than
|
|
device-synced activities.
|
|
|
|
Args:
|
|
activity_path: Path to the activity file (FIT, TCX, or GPX).
|
|
|
|
Returns:
|
|
Dictionary containing the DetailedImportResult with successes,
|
|
failures, and activity IDs.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the activity file does not exist.
|
|
GarminConnectInvalidFileFormatError: If the file format is invalid.
|
|
GarminConnectConnectionError: If the upload fails.
|
|
|
|
"""
|
|
if not activity_path:
|
|
raise ValueError("activity_path cannot be empty")
|
|
|
|
if not isinstance(activity_path, str):
|
|
raise ValueError("activity_path must be a string")
|
|
|
|
p = Path(activity_path)
|
|
if not p.exists():
|
|
raise FileNotFoundError(f"File not found: {activity_path}")
|
|
|
|
if not p.is_file():
|
|
raise ValueError(f"path is not a file: {activity_path}")
|
|
|
|
file_base_name = p.name
|
|
if not file_base_name:
|
|
raise ValueError("invalid file path - no filename found")
|
|
|
|
file_parts = file_base_name.split(".")
|
|
if len(file_parts) < 2:
|
|
raise GarminConnectInvalidFileFormatError(
|
|
f"File has no extension: {activity_path}"
|
|
)
|
|
|
|
file_extension = file_parts[-1].lower()
|
|
if file_extension.upper() not in Garmin.ActivityUploadFormat.__members__:
|
|
allowed_formats = ", ".join(Garmin.ActivityUploadFormat.__members__.keys())
|
|
raise GarminConnectInvalidFileFormatError(
|
|
f"Invalid file format '{file_extension}'. "
|
|
f"Allowed formats: {allowed_formats}"
|
|
)
|
|
|
|
url = f"{self.garmin_connect_upload}/{file_extension}"
|
|
headers = {
|
|
"NK": "NT",
|
|
"origin": "https://sso.garmin.com",
|
|
"User-Agent": "GCM-iOS-5.7.2.1",
|
|
}
|
|
|
|
try:
|
|
with p.open("rb") as file_handle:
|
|
files = {
|
|
"file": (
|
|
file_base_name,
|
|
file_handle,
|
|
"application/octet-stream",
|
|
)
|
|
}
|
|
logger.debug("Importing activity file %s via %s", file_base_name, url)
|
|
response = self.client.post(
|
|
"connectapi", url, files=files, headers=headers, api=True
|
|
)
|
|
if hasattr(response, "json"):
|
|
result: dict[str, Any] = response.json()
|
|
return result
|
|
return {"status": "uploaded", "fileName": file_base_name}
|
|
except (HTTPError, GarminConnectConnectionError) as e:
|
|
if isinstance(e, GarminConnectConnectionError):
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
else:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
if status == 409:
|
|
logger.info("Activity already exists (duplicate): %s", file_base_name)
|
|
raise GarminConnectConnectionError(
|
|
f"Activity already exists (duplicate): {file_base_name}"
|
|
) from e
|
|
logger.exception(
|
|
"Import failed for '%s' (status=%s)", activity_path, status
|
|
)
|
|
raise GarminConnectConnectionError(f"Import error: {e}") from e
|
|
except OSError as e:
|
|
raise GarminConnectConnectionError(
|
|
f"Failed to read file {activity_path}: {e}"
|
|
) from e
|
|
|
|
def delete_activity(self, activity_id: str) -> Any:
|
|
"""Delete activity with specified id."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_delete_activity_url}/{activity_id}"
|
|
logger.debug("Deleting activity with id %s", activity_id)
|
|
|
|
return self.client.request(
|
|
"DELETE",
|
|
"connectapi",
|
|
url,
|
|
api=True,
|
|
)
|
|
|
|
def get_activities_by_date(
|
|
self,
|
|
startdate: str,
|
|
enddate: str | None = None,
|
|
activitytype: str | None = None,
|
|
sortorder: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch available activities between specific dates
|
|
:param startdate: String in the format YYYY-MM-DD
|
|
:param enddate: (Optional) String in the format YYYY-MM-DD
|
|
:param activitytype: (Optional) Type of activity you are searching
|
|
Possible values are [cycling, running, swimming,
|
|
multi_sport, fitness_equipment, hiking, walking, other]
|
|
:param sortorder: (Optional) sorting direction. By default, Garmin uses descending order by startLocal field.
|
|
Use "asc" to get activities from oldest to newest.
|
|
:return: list of JSON activities.
|
|
"""
|
|
activities = []
|
|
start = 0
|
|
limit = 20
|
|
# mimicking the behavior of the web interface that fetches
|
|
# 20 activities at a time
|
|
# and automatically loads more on scroll
|
|
url = self.garmin_connect_activities
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
if enddate is not None:
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
params = {
|
|
"startDate": startdate,
|
|
"start": str(start),
|
|
"limit": str(limit),
|
|
}
|
|
if enddate:
|
|
params["endDate"] = enddate
|
|
if activitytype:
|
|
params["activityType"] = activitytype
|
|
if sortorder:
|
|
params["sortOrder"] = sortorder
|
|
|
|
logger.debug("Requesting activities by date from %s to %s", startdate, enddate)
|
|
for _ in range(MAX_PAGINATED_REQUESTS):
|
|
params["start"] = str(start)
|
|
logger.debug("Requesting activities %d to %d", start, start + limit)
|
|
act = self.connectapi(url, params=params)
|
|
if act:
|
|
activities.extend(act)
|
|
start = start + limit
|
|
else:
|
|
break
|
|
else:
|
|
# A server that never returns an empty page would otherwise loop
|
|
# the client forever (DoS); fail loudly instead of truncating.
|
|
raise GarminConnectConnectionError(
|
|
f"Pagination exceeded {MAX_PAGINATED_REQUESTS} requests; aborting"
|
|
)
|
|
|
|
return activities
|
|
|
|
def get_progress_summary_between_dates(
|
|
self,
|
|
startdate: str,
|
|
enddate: str,
|
|
metric: str = "distance",
|
|
groupbyactivities: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Fetch progress summary data between specific dates
|
|
:param startdate: String in the format YYYY-MM-DD
|
|
:param enddate: String in the format YYYY-MM-DD
|
|
:param metric: metric to be calculated in the summary:
|
|
"elevationGain", "duration", "distance", "movingDuration"
|
|
:param groupbyactivities: group the summary by activity type
|
|
:return: list of JSON activities with their aggregated progress summary.
|
|
"""
|
|
url = self.garmin_connect_fitnessstats
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
params = {
|
|
"startDate": startdate,
|
|
"endDate": enddate,
|
|
"aggregation": "lifetime",
|
|
"groupByParentActivityType": str(groupbyactivities),
|
|
"metric": metric,
|
|
}
|
|
|
|
logger.debug(
|
|
"Requesting fitnessstats by date from %s to %s", startdate, enddate
|
|
)
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_activity_types(self) -> dict[str, Any]:
|
|
url = self.garmin_connect_activity_types
|
|
logger.debug("Requesting activity types")
|
|
return self.connectapi(url)
|
|
|
|
def get_goals(
|
|
self, status: str = "active", start: int = 0, limit: int = 30
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch all goals based on status
|
|
:param status: Status of goals (valid options are "active", "future", or "past")
|
|
:type status: str
|
|
:param start: Initial goal index
|
|
:type start: int
|
|
:param limit: Pagination limit when retrieving goals
|
|
:type limit: int
|
|
:return: list of goals in JSON format.
|
|
"""
|
|
goals = []
|
|
url = self.garmin_connect_goals_url
|
|
valid_statuses = {"active", "future", "past"}
|
|
if status not in valid_statuses:
|
|
raise ValueError(f"status must be one of {valid_statuses}")
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
params = {
|
|
"status": status,
|
|
"start": str(start),
|
|
"limit": str(limit),
|
|
"sortOrder": "asc",
|
|
}
|
|
|
|
logger.debug("Requesting %s goals", status)
|
|
for _ in range(MAX_PAGINATED_REQUESTS):
|
|
params["start"] = str(start)
|
|
logger.debug(
|
|
"Requesting %s goals %d to %d", status, start, start + limit - 1
|
|
)
|
|
goals_json = self.connectapi(url, params=params)
|
|
if goals_json:
|
|
goals.extend(goals_json)
|
|
start = start + limit
|
|
else:
|
|
break
|
|
else:
|
|
# A server that never returns an empty page would otherwise loop
|
|
# the client forever (DoS); fail loudly instead of truncating.
|
|
raise GarminConnectConnectionError(
|
|
f"Pagination exceeded {MAX_PAGINATED_REQUESTS} requests; aborting"
|
|
)
|
|
|
|
return goals
|
|
|
|
def get_gear(self, userProfileNumber: str) -> dict[str, Any]:
|
|
"""Return a list of gear for the specified user profile number."""
|
|
userProfileNumber = str(
|
|
_validate_positive_integer(int(userProfileNumber), "userProfileNumber")
|
|
)
|
|
url = self.garmin_connect_gear
|
|
logger.debug("Requesting gear for user %s", userProfileNumber)
|
|
|
|
return self.connectapi(url, params={"userProfilePk": userProfileNumber})
|
|
|
|
def get_gear_stats(self, gearUUID: str) -> dict[str, Any]:
|
|
"""Return statistics (e.g. distance) for specific gear UUID."""
|
|
gearUUID = _validate_uuid(gearUUID, "gearUUID")
|
|
url = f"{self.garmin_connect_gear_baseurl}/stats/{gearUUID}"
|
|
logger.debug("Requesting gear stats for gearUUID %s", gearUUID)
|
|
|
|
try:
|
|
return self.connectapi(url)
|
|
except GarminConnectConnectionError as e:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
if status == 404:
|
|
logger.warning(
|
|
"Gear stats not found for UUID %s (likely retired/removed gear)",
|
|
gearUUID,
|
|
)
|
|
return {}
|
|
raise
|
|
|
|
def get_gear_defaults(self, userProfileNumber: str) -> dict[str, Any]:
|
|
userProfileNumber = str(
|
|
_validate_positive_integer(int(userProfileNumber), "userProfileNumber")
|
|
)
|
|
url = (
|
|
f"{self.garmin_connect_gear_baseurl}/user/{userProfileNumber}/activityTypes"
|
|
)
|
|
logger.debug("Requesting gear defaults for user %s", userProfileNumber)
|
|
return self.connectapi(url)
|
|
|
|
def set_gear_default(
|
|
self, activityType: str, gearUUID: str, defaultGear: bool = True
|
|
) -> Any:
|
|
activityType = _validate_sport_key(activityType, "activityType")
|
|
gearUUID = _validate_uuid(gearUUID, "gearUUID")
|
|
defaultGearString = "/default/true" if defaultGear else ""
|
|
method_override = "PUT" if defaultGear else "DELETE"
|
|
url = (
|
|
f"{self.garmin_connect_gear_baseurl}/{gearUUID}/"
|
|
f"activityType/{activityType}{defaultGearString}"
|
|
)
|
|
|
|
try:
|
|
return self.client.request(method_override, "connectapi", url, api=True)
|
|
except GarminConnectConnectionError as e:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
if status == 404:
|
|
raise GarminConnectConnectionError(
|
|
f"Cannot set gear default for UUID {gearUUID}: gear not found (likely retired/removed)"
|
|
) from e
|
|
raise
|
|
|
|
class ActivityDownloadFormat(Enum):
|
|
"""Activity variables."""
|
|
|
|
ORIGINAL = auto()
|
|
TCX = auto()
|
|
GPX = auto()
|
|
KML = auto()
|
|
CSV = auto()
|
|
|
|
class ActivityUploadFormat(Enum):
|
|
FIT = auto()
|
|
GPX = auto()
|
|
TCX = auto()
|
|
|
|
def download_activity(
|
|
self,
|
|
activity_id: str,
|
|
dl_fmt: ActivityDownloadFormat = ActivityDownloadFormat.TCX,
|
|
) -> bytes:
|
|
"""Downloads activity in requested format and returns the raw bytes. For
|
|
"Original" will return the zip file content, up to user to extract it.
|
|
"CSV" will return a csv of the splits.
|
|
"""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
urls = {
|
|
Garmin.ActivityDownloadFormat.ORIGINAL: f"{self.garmin_connect_fit_download}/{activity_id}",
|
|
Garmin.ActivityDownloadFormat.TCX: f"{self.garmin_connect_tcx_download}/{activity_id}",
|
|
Garmin.ActivityDownloadFormat.GPX: f"{self.garmin_connect_gpx_download}/{activity_id}",
|
|
Garmin.ActivityDownloadFormat.KML: f"{self.garmin_connect_kml_download}/{activity_id}",
|
|
Garmin.ActivityDownloadFormat.CSV: f"{self.garmin_connect_csv_download}/{activity_id}",
|
|
}
|
|
if dl_fmt not in urls:
|
|
raise ValueError(f"unexpected value {dl_fmt} for dl_fmt")
|
|
url = urls[dl_fmt]
|
|
|
|
logger.debug("Downloading activity from %s", url)
|
|
|
|
return self.download(url)
|
|
|
|
def download_health_snapshot(self, requested_date: str) -> bytes:
|
|
"""Download the Health Snapshot ZIP file for a calendar date."""
|
|
requested_date = _validate_date_format(requested_date, "requested_date")
|
|
url = f"{self.garmin_connect_health_snapshot_download}/{requested_date}"
|
|
|
|
logger.debug("Downloading Health Snapshot from %s", url)
|
|
|
|
return self.download(url)
|
|
|
|
def get_activity_splits(self, activity_id: str) -> dict[str, Any]:
|
|
"""Return activity splits."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/splits"
|
|
logger.debug("Requesting splits for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_activity_typed_splits(self, activity_id: str) -> dict[str, Any]:
|
|
"""Return typed activity splits. Contains similar info to `get_activity_splits`, but for certain activity types
|
|
(e.g., Bouldering), this contains more detail.
|
|
"""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/typedsplits"
|
|
logger.debug("Requesting typed splits for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_activity_split_summaries(self, activity_id: str) -> dict[str, Any]:
|
|
"""Return activity split summaries."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/split_summaries"
|
|
logger.debug("Requesting split summaries for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_activity_weather(self, activity_id: str) -> dict[str, Any]:
|
|
"""Return activity weather."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/weather"
|
|
logger.debug("Requesting weather for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_activity_hr_in_timezones(self, activity_id: str) -> dict[str, Any]:
|
|
"""Return activity heartrate in timezones."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/hrTimeInZones"
|
|
logger.debug("Requesting HR time-in-zones for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_activity_power_in_timezones(self, activity_id: str) -> dict[str, Any]:
|
|
"""Return activity power in timezones."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/powerTimeInZones"
|
|
logger.debug("Requesting Power time-in-zones for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_cycling_ftp(
|
|
self,
|
|
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
"""Return cycling Functional Threshold Power (FTP) information."""
|
|
url = f"{self.garmin_connect_biometric_url}/latestFunctionalThresholdPower/CYCLING"
|
|
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."""
|
|
normalized_sport = _validate_sport_key(sport)
|
|
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"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}"
|
|
logger.debug("Requesting activity summary data for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_activity_details(
|
|
self, activity_id: str, maxchart: int = 2000, maxpoly: int = 4000
|
|
) -> dict[str, Any]:
|
|
"""Return activity details."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
maxchart = _validate_positive_integer(maxchart, "maxchart")
|
|
maxpoly = _validate_non_negative_integer(maxpoly, "maxpoly")
|
|
params = {"maxChartSize": str(maxchart), "maxPolylineSize": str(maxpoly)}
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/details"
|
|
logger.debug("Requesting details for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_activity_exercise_sets(self, activity_id: int | str) -> dict[str, Any]:
|
|
"""Return activity exercise sets."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/exerciseSets"
|
|
logger.debug("Requesting exercise sets for activity id %s", activity_id)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def set_activity_exercise_sets(
|
|
self, activity_id: int | str, payload: dict[str, Any]
|
|
) -> Any:
|
|
"""Replace exercise sets for activity with id.
|
|
|
|
`payload` is the full body sent to the server, in the same shape as the
|
|
response from `get_activity_exercise_sets`. Replace-all semantics — the
|
|
existing `exerciseSets` array is overwritten. Garmin validates the
|
|
`exercises[].category` (parent) and `exercises[].name` (sub-category)
|
|
against its FIT enum and returns 400 "Invalid Sub-Category Passed" for
|
|
unknown values; `name=None` is always accepted under a known parent.
|
|
"""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
url = f"{self.garmin_connect_activity}/{activity_id}/exerciseSets"
|
|
logger.debug("Replacing exercise sets for activity id %s", activity_id)
|
|
|
|
return self.client.put("connectapi", url, json=payload, api=True)
|
|
|
|
def get_activity_gear(self, activity_id: int | str) -> dict[str, Any]:
|
|
"""Return gears used for activity id."""
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
params = {
|
|
"activityId": activity_id,
|
|
}
|
|
url = self.garmin_connect_gear
|
|
logger.debug("Requesting gear for activity_id %s", activity_id)
|
|
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_gear_activities(
|
|
self, gearUUID: str, limit: int = 1000
|
|
) -> list[dict[str, Any]]:
|
|
"""Return activities where gear uuid was used.
|
|
:param gearUUID: UUID of the gear to get activities for
|
|
:param limit: Maximum number of activities to return (default: 1000)
|
|
:return: List of activities where the specified gear was used.
|
|
"""
|
|
gearUUID = _validate_uuid(gearUUID, "gearUUID")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
# Optional: enforce a reasonable ceiling to avoid heavy responses
|
|
limit = min(limit, MAX_ACTIVITY_LIMIT)
|
|
url = f"{self.garmin_connect_activities_baseurl}{gearUUID}/gear"
|
|
logger.debug("Requesting activities for gearUUID %s", gearUUID)
|
|
|
|
try:
|
|
return self.connectapi(url, params={"start": 0, "limit": limit})
|
|
except GarminConnectConnectionError as e:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
if status == 404:
|
|
logger.warning(
|
|
"Gear activities not found for UUID %s (likely retired/removed gear)",
|
|
gearUUID,
|
|
)
|
|
return []
|
|
raise
|
|
|
|
def add_gear_to_activity(
|
|
self, gearUUID: str, activity_id: int | str
|
|
) -> dict[str, Any]:
|
|
"""Associates gear with an activity. Requires a gearUUID and an activity_id.
|
|
|
|
Args:
|
|
gearUUID: UID for gear to add to activity. Findable though the get_gear function
|
|
activity_id: Integer ID for the activity to add the gear to
|
|
|
|
Returns:
|
|
Dictionary containing information for the added gear
|
|
|
|
"""
|
|
gearUUID = _validate_uuid(gearUUID, "gearUUID")
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
|
|
url = (
|
|
f"{self.garmin_connect_gear_baseurl}/link/{gearUUID}/activity/{activity_id}"
|
|
)
|
|
logger.debug("Linking gear %s to activity %s", gearUUID, activity_id)
|
|
|
|
try:
|
|
return self.client.put("connectapi", url).json()
|
|
except GarminConnectConnectionError as e:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
if status == 404:
|
|
raise GarminConnectConnectionError(
|
|
f"Cannot add gear {gearUUID} to activity {activity_id}: gear not found (likely retired/removed)"
|
|
) from e
|
|
raise
|
|
|
|
def remove_gear_from_activity(
|
|
self, gearUUID: str, activity_id: int | str
|
|
) -> dict[str, Any]:
|
|
"""Removes gear from an activity. Requires a gearUUID and an activity_id.
|
|
|
|
Args:
|
|
gearUUID: UID for gear to remove from activity. Findable though the get_gear method.
|
|
activity_id: Integer ID for the activity to remove the gear from
|
|
|
|
Returns:
|
|
Dictionary containing information about the removed gear
|
|
|
|
"""
|
|
gearUUID = _validate_uuid(gearUUID, "gearUUID")
|
|
activity_id = str(_validate_positive_integer(int(activity_id), "activity_id"))
|
|
|
|
url = f"{self.garmin_connect_gear_baseurl}/unlink/{gearUUID}/activity/{activity_id}"
|
|
logger.debug("Unlinking gear %s from activity %s", gearUUID, activity_id)
|
|
|
|
try:
|
|
return self.client.put("connectapi", url).json()
|
|
except GarminConnectConnectionError as e:
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
if status == 404:
|
|
raise GarminConnectConnectionError(
|
|
f"Cannot remove gear {gearUUID} from activity {activity_id}: gear not found (likely retired/removed)"
|
|
) from e
|
|
raise
|
|
|
|
def get_user_profile(self) -> dict[str, Any]:
|
|
"""Get all users settings."""
|
|
url = self.garmin_connect_user_settings_url
|
|
logger.debug("Requesting user profile.")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_userprofile_settings(self) -> dict[str, Any]:
|
|
"""Get user settings."""
|
|
url = self.garmin_connect_userprofile_settings_url
|
|
logger.debug("Getting userprofile settings")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def request_reload(self, cdate: str) -> dict[str, Any]:
|
|
"""Request reload of data for a specific date. This is necessary because
|
|
Garmin offloads older data.
|
|
"""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_request_reload_url}/{cdate}"
|
|
logger.debug("Requesting reload of data for %s.", cdate)
|
|
|
|
return self.client.post("connectapi", url, api=True)
|
|
|
|
def get_workouts(self, start: int = 0, limit: int = 100) -> list[dict[str, Any]]:
|
|
"""Return workouts starting at offset `start` with at most `limit` results."""
|
|
url = f"{self.garmin_workouts}/workouts"
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
logger.debug("Requesting workouts from %d with limit %d", start, limit)
|
|
params = {"start": start, "limit": limit}
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_workout_by_id(self, workout_id: int | str) -> dict[str, Any]:
|
|
"""Return workout by id."""
|
|
workout_id = _validate_positive_integer(int(workout_id), "workout_id")
|
|
url = f"{self.garmin_workouts}/workout/{workout_id}"
|
|
return self.connectapi(url)
|
|
|
|
def delete_workout(self, workout_id: int | str) -> Any:
|
|
"""Delete a workout template from the workout library."""
|
|
workout_id = _validate_positive_integer(int(workout_id), "workout_id")
|
|
url = f"{self.garmin_workouts}/workout/{workout_id}"
|
|
logger.debug("Deleting workout %s", workout_id)
|
|
return self.client.delete("connectapi", url, api=True)
|
|
|
|
def download_workout(self, workout_id: int | str) -> bytes:
|
|
"""Download workout by id."""
|
|
workout_id = _validate_positive_integer(int(workout_id), "workout_id")
|
|
url = f"{self.garmin_workouts}/workout/FIT/{workout_id}"
|
|
logger.debug("Downloading workout from %s", url)
|
|
|
|
return self.download(url)
|
|
|
|
def upload_workout(
|
|
self, workout_json: dict[str, Any] | list[Any] | str
|
|
) -> dict[str, Any]:
|
|
"""Upload workout using json data."""
|
|
url = f"{self.garmin_workouts}/workout"
|
|
logger.debug("Uploading workout using %s", url)
|
|
|
|
if isinstance(workout_json, str):
|
|
import json as _json
|
|
|
|
try:
|
|
payload = _json.loads(workout_json)
|
|
except Exception as e:
|
|
raise ValueError(f"invalid workout_json string: {e}") from e
|
|
else:
|
|
payload = workout_json
|
|
if not isinstance(payload, dict | list):
|
|
raise ValueError("workout_json must be a JSON object or array")
|
|
return self.client.post("connectapi", url, json=payload, api=True)
|
|
|
|
def update_workout(
|
|
self, workout_id: int | str, workout_json: dict[str, Any] | str
|
|
) -> dict[str, Any]:
|
|
"""Update (replace) an existing workout in place using json data.
|
|
|
|
Garmin's workout endpoint replaces the whole workout via PUT, so
|
|
``workout_json`` must be the complete structure (as returned by
|
|
``get_workout_by_id`` or built the same way as for ``upload_workout``).
|
|
The workout keeps its id, so any calendar schedules pointing at it stay
|
|
valid. ``workoutId`` in the body is forced to match ``workout_id``.
|
|
"""
|
|
workout_id = _validate_positive_integer(int(workout_id), "workout_id")
|
|
url = f"{self.garmin_workouts}/workout/{workout_id}"
|
|
logger.debug("Updating workout using %s", url)
|
|
|
|
if isinstance(workout_json, str):
|
|
import json as _json
|
|
|
|
try:
|
|
payload = _json.loads(workout_json)
|
|
except Exception as e:
|
|
raise ValueError(f"invalid workout_json string: {e}") from e
|
|
else:
|
|
payload = workout_json
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("workout_json must be a JSON object")
|
|
body = payload | {"workoutId": workout_id}
|
|
return self.client.put("connectapi", url, json=body, api=True)
|
|
|
|
def upload_running_workout(self, workout: Any) -> dict[str, Any]:
|
|
"""Upload a typed running workout.
|
|
|
|
Args:
|
|
workout: RunningWorkout instance from garminconnect.workout
|
|
|
|
Returns:
|
|
Dictionary containing the uploaded workout data
|
|
|
|
Example:
|
|
from garminconnect.workout import RunningWorkout, WorkoutSegment, create_warmup_step
|
|
|
|
workout = RunningWorkout(
|
|
workoutName="Easy Run",
|
|
estimatedDurationInSecs=1800,
|
|
workoutSegments=[
|
|
WorkoutSegment(
|
|
segmentOrder=1,
|
|
sportType={"sportTypeId": 1, "sportTypeKey": "running"},
|
|
workoutSteps=[create_warmup_step(300.0)]
|
|
)
|
|
]
|
|
)
|
|
api.upload_running_workout(workout)
|
|
|
|
"""
|
|
try:
|
|
from .workout import RunningWorkout
|
|
|
|
if not isinstance(workout, RunningWorkout):
|
|
raise TypeError("workout must be a RunningWorkout instance")
|
|
return self.upload_workout(workout.to_dict())
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Pydantic is required for typed workouts. "
|
|
"Install it with: pip install pydantic or pip install garminconnect[workout]"
|
|
) from None
|
|
|
|
def upload_cycling_workout(self, workout: Any) -> dict[str, Any]:
|
|
"""Upload a typed cycling workout.
|
|
|
|
Args:
|
|
workout: CyclingWorkout instance from garminconnect.workout
|
|
|
|
Returns:
|
|
Dictionary containing the uploaded workout data
|
|
|
|
Example:
|
|
from garminconnect.workout import CyclingWorkout, WorkoutSegment, create_warmup_step
|
|
|
|
workout = CyclingWorkout(
|
|
workoutName="Interval Ride",
|
|
estimatedDurationInSecs=3600,
|
|
workoutSegments=[
|
|
WorkoutSegment(
|
|
segmentOrder=1,
|
|
sportType={"sportTypeId": 2, "sportTypeKey": "cycling"},
|
|
workoutSteps=[create_warmup_step(600.0)]
|
|
)
|
|
]
|
|
)
|
|
api.upload_cycling_workout(workout)
|
|
|
|
"""
|
|
try:
|
|
from .workout import CyclingWorkout
|
|
|
|
if not isinstance(workout, CyclingWorkout):
|
|
raise TypeError("workout must be a CyclingWorkout instance")
|
|
return self.upload_workout(workout.to_dict())
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Pydantic is required for typed workouts. "
|
|
"Install it with: pip install pydantic or pip install garminconnect[workout]"
|
|
) from None
|
|
|
|
def upload_swimming_workout(self, workout: Any) -> dict[str, Any]:
|
|
"""Upload a typed swimming workout.
|
|
|
|
Args:
|
|
workout: SwimmingWorkout instance from garminconnect.workout
|
|
|
|
Returns:
|
|
Dictionary containing the uploaded workout data
|
|
|
|
"""
|
|
try:
|
|
from .workout import SwimmingWorkout
|
|
|
|
if not isinstance(workout, SwimmingWorkout):
|
|
raise TypeError("workout must be a SwimmingWorkout instance")
|
|
return self.upload_workout(workout.to_dict())
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Pydantic is required for typed workouts. "
|
|
"Install it with: pip install pydantic or pip install garminconnect[workout]"
|
|
) from None
|
|
|
|
def upload_walking_workout(self, workout: Any) -> dict[str, Any]:
|
|
"""Upload a typed walking workout.
|
|
|
|
Args:
|
|
workout: WalkingWorkout instance from garminconnect.workout
|
|
|
|
Returns:
|
|
Dictionary containing the uploaded workout data
|
|
|
|
"""
|
|
try:
|
|
from .workout import WalkingWorkout
|
|
|
|
if not isinstance(workout, WalkingWorkout):
|
|
raise TypeError("workout must be a WalkingWorkout instance")
|
|
return self.upload_workout(workout.to_dict())
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Pydantic is required for typed workouts. "
|
|
"Install it with: pip install pydantic or pip install garminconnect[workout]"
|
|
) from None
|
|
|
|
def upload_hiking_workout(self, workout: Any) -> dict[str, Any]:
|
|
"""Upload a typed hiking workout.
|
|
|
|
Args:
|
|
workout: HikingWorkout instance from garminconnect.workout
|
|
|
|
Returns:
|
|
Dictionary containing the uploaded workout data
|
|
|
|
"""
|
|
try:
|
|
from .workout import HikingWorkout
|
|
|
|
if not isinstance(workout, HikingWorkout):
|
|
raise TypeError("workout must be a HikingWorkout instance")
|
|
return self.upload_workout(workout.to_dict())
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Pydantic is required for typed workouts. "
|
|
"Install it with: pip install pydantic or pip install garminconnect[workout]"
|
|
) from None
|
|
|
|
def upload_strength_workout(self, workout: Any) -> dict[str, Any]:
|
|
"""Upload a typed strength training workout.
|
|
|
|
Args:
|
|
workout: StrengthWorkout instance from garminconnect.workout
|
|
|
|
Returns:
|
|
Dictionary containing the uploaded workout data
|
|
|
|
Example:
|
|
from garminconnect.workout import StrengthWorkout, WorkoutSegment
|
|
from garminconnect.workout import create_strength_set
|
|
|
|
workout = StrengthWorkout(
|
|
workoutName="Upper Body",
|
|
estimatedDurationInSecs=0,
|
|
workoutSegments=[
|
|
WorkoutSegment(
|
|
segmentOrder=1,
|
|
sportType={"sportTypeId": 5, "sportTypeKey": "strength_training"},
|
|
workoutSteps=[
|
|
create_strength_set("BENCH_PRESS", step_order=1,
|
|
sets=4, reps=10, rest_seconds=120),
|
|
],
|
|
)
|
|
],
|
|
)
|
|
api.upload_strength_workout(workout)
|
|
|
|
"""
|
|
try:
|
|
from .workout import StrengthWorkout
|
|
|
|
if not isinstance(workout, StrengthWorkout):
|
|
raise TypeError("workout must be a StrengthWorkout instance")
|
|
return self.upload_workout(workout.to_dict())
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Pydantic is required for typed workouts. "
|
|
"Install it with: pip install pydantic or pip install garminconnect[workout]"
|
|
) from None
|
|
|
|
def push_workout_to_device(
|
|
self, workout_id: int | str | None = None, device_id: int | str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Push a workout to a device.
|
|
|
|
Args:
|
|
workout_id: The workout ID returned after uploading. If not provided, will push last workout in the library.
|
|
device_id: Optional device ID to push the workout to. If not provided, will choose the last used device.
|
|
|
|
Returns:
|
|
Dictionary containing the result of the push operation.
|
|
|
|
"""
|
|
if device_id is None:
|
|
device_id = self.get_device_last_used()["userDeviceId"]
|
|
|
|
device_id = _validate_positive_integer(int(device_id), "device_id")
|
|
|
|
if workout_id is None:
|
|
workouts = self.get_workouts(start=0, limit=1)
|
|
if not workouts:
|
|
raise ValueError("No workouts found to push.")
|
|
workout_id = workouts[0]["workoutId"]
|
|
|
|
workout_id = _validate_positive_integer(int(workout_id), "workout_id")
|
|
workout_name = self.get_workout_by_id(workout_id)["workoutName"]
|
|
|
|
url = self.garmin_connect_devicemessage_url
|
|
|
|
payload = [
|
|
{
|
|
"deviceId": device_id,
|
|
"messageUrl": f"workout-service/workout/FIT/{workout_id}",
|
|
"messageType": "workouts",
|
|
"groupName": None,
|
|
"messageName": workout_name,
|
|
"priority": 1,
|
|
"fileType": "FIT",
|
|
"metaDataId": workout_id,
|
|
}
|
|
]
|
|
|
|
logger.debug("Pushing workout %s to device %s", workout_id, device_id)
|
|
return self.client.post("connectapi", url, json=payload, api=True)
|
|
|
|
def get_scheduled_workouts(
|
|
self, year: int | str, month: int | str
|
|
) -> dict[str, Any]:
|
|
"""Return scheduled workout by year and month."""
|
|
year = _validate_positive_integer(int(year), "year")
|
|
if year < 2000:
|
|
raise ValueError(f"year must be 2000 or later, got: {year}")
|
|
|
|
month = _validate_positive_integer(int(month), "month")
|
|
if month < 1 or month > 12:
|
|
raise ValueError(f"month must be between 1 and 12, got: {month}")
|
|
|
|
# Garmin's API uses 0-indexed months, so we need to subtract 1 from the month value
|
|
url = f"{self.garmin_scheduled_workouts_url}/year/{year}/month/{month - 1}"
|
|
logger.debug(
|
|
"Requesting scheduled workout for year %d and month %d", year, month
|
|
)
|
|
return self.connectapi(url)
|
|
|
|
def get_scheduled_workout_by_id(
|
|
self, scheduled_workout_id: int | str
|
|
) -> dict[str, Any]:
|
|
"""Return scheduled workout by ID."""
|
|
scheduled_workout_id = _validate_positive_integer(
|
|
int(scheduled_workout_id), "scheduled_workout_id"
|
|
)
|
|
url = f"{self.garmin_workouts_schedule_url}/{scheduled_workout_id}"
|
|
logger.debug("Requesting scheduled workout by id %d", scheduled_workout_id)
|
|
return self.connectapi(url)
|
|
|
|
def schedule_workout(self, workout_id: int | str, date_str: str) -> dict[str, Any]:
|
|
"""Schedule a workout on a specific date in the Garmin calendar.
|
|
|
|
Args:
|
|
workout_id: The workout ID returned after uploading.
|
|
date_str: Target date in YYYY-MM-DD format.
|
|
|
|
"""
|
|
workout_id = _validate_positive_integer(int(workout_id), "workout_id")
|
|
date_str = _validate_date_format(date_str, "date_str")
|
|
url = f"{self.garmin_workouts_schedule_url}/{workout_id}"
|
|
logger.debug("Scheduling workout %s for %s", workout_id, date_str)
|
|
payload = {"date": date_str}
|
|
return self.client.post("connectapi", url, json=payload, api=True)
|
|
|
|
def unschedule_workout(self, scheduled_workout_id: int | str) -> Any:
|
|
"""Remove a scheduled workout from the calendar without deleting the template."""
|
|
scheduled_workout_id = _validate_positive_integer(
|
|
int(scheduled_workout_id), "scheduled_workout_id"
|
|
)
|
|
url = f"{self.garmin_workouts_schedule_url}/{scheduled_workout_id}"
|
|
logger.debug("Unscheduling workout %s", scheduled_workout_id)
|
|
return self.client.delete("connectapi", url, api=True)
|
|
|
|
def get_menstrual_data_for_date(self, fordate: str) -> dict[str, Any]:
|
|
"""Return menstrual data for date."""
|
|
fordate = _validate_date_format(fordate, "fordate")
|
|
url = f"{self.garmin_connect_menstrual_dayview_url}/{fordate}"
|
|
logger.debug("Requesting menstrual data for date %s", fordate)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_menstrual_calendar_data(
|
|
self, startdate: str, enddate: str
|
|
) -> dict[str, Any]:
|
|
"""Return summaries of cycles that have days between startdate and enddate."""
|
|
startdate = _validate_date_format(startdate, "startdate")
|
|
enddate = _validate_date_format(enddate, "enddate")
|
|
url = f"{self.garmin_connect_menstrual_calendar_url}/{startdate}/{enddate}"
|
|
logger.debug(
|
|
"Requesting menstrual data for dates %s through %s", startdate, enddate
|
|
)
|
|
|
|
return self.connectapi(url)
|
|
|
|
def get_pregnancy_summary(self) -> dict[str, Any]:
|
|
"""Return pregnancy summary for the current user."""
|
|
url = f"{self.garmin_connect_pregnancy_snapshot_url}"
|
|
logger.debug("Requesting pregnancy snapshot data")
|
|
|
|
return self.connectapi(url)
|
|
|
|
def query_garmin_graphql(self, query: dict[str, Any]) -> dict[str, Any]:
|
|
"""Execute a POST to Garmin's GraphQL endpoint.
|
|
|
|
Args:
|
|
query: A GraphQL request body, e.g. {"query": "...", "variables": {...}}
|
|
See example.py for example queries.
|
|
|
|
Returns:
|
|
Parsed JSON response as a dict.
|
|
|
|
"""
|
|
op = (
|
|
(query.get("operationName") or "unnamed")
|
|
if isinstance(query, dict)
|
|
else "unnamed"
|
|
)
|
|
vars_keys = (
|
|
sorted((query.get("variables") or {}).keys())
|
|
if isinstance(query, dict)
|
|
else []
|
|
)
|
|
logger.debug("Querying Garmin GraphQL op=%s vars=%s", op, vars_keys)
|
|
return self.client.post(
|
|
"connectapi", self.garmin_graphql_endpoint, json=query
|
|
).json()
|
|
|
|
def logout(self, tokenstore: str | None = None) -> None:
|
|
"""Clear in-memory auth state and any cached tokens on disk.
|
|
|
|
Call this after an authentication failure to guarantee the next
|
|
``login()`` runs the full strategy chain instead of resuming
|
|
stale/poisoned cached tokens. ``login()`` already self-heals from
|
|
rejected cached tokens, so this is only needed for manual control.
|
|
|
|
This only clears local authentication state. It does not revoke an
|
|
already-issued token at Garmin. The token-store directory and any
|
|
unrelated files in it are preserved.
|
|
|
|
:param tokenstore: Path to the token directory or JSON file whose
|
|
``garmin_tokens.json`` file should be removed. Falls back to the
|
|
``GARMINTOKENS`` environment variable. Inline JSON token strings
|
|
are ignored — nothing to delete.
|
|
"""
|
|
self.client._clear_auth_state()
|
|
self.username = None
|
|
self.password = None
|
|
self.display_name = None
|
|
self.full_name = None
|
|
self.unit_system = None
|
|
|
|
tokenstore = tokenstore or os.getenv("GARMINTOKENS")
|
|
if not tokenstore or _looks_like_json(tokenstore):
|
|
return
|
|
try:
|
|
path = client.token_file_path(tokenstore)
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except ValueError as e:
|
|
logger.debug("Skipping tokenstore cleanup for unsafe path: %s", e)
|
|
|
|
def get_training_plans(self) -> dict[str, Any]:
|
|
"""Return all available training plans."""
|
|
url = f"{self.garmin_connect_training_plan_url}/plans"
|
|
logger.debug("Requesting training plans.")
|
|
return self.connectapi(url)
|
|
|
|
def get_training_plan_by_id(self, plan_id: int | str) -> dict[str, Any]:
|
|
"""Return details for a specific training plan."""
|
|
plan_id = _validate_positive_integer(int(plan_id), "plan_id")
|
|
|
|
url = f"{self.garmin_connect_training_plan_url}/phased/{plan_id}"
|
|
logger.debug("Requesting training plan details for %s", plan_id)
|
|
return self.connectapi(url)
|
|
|
|
def get_adaptive_training_plan_by_id(self, plan_id: int | str) -> dict[str, Any]:
|
|
"""Return details for a specific adaptive training plan."""
|
|
plan_id = _validate_positive_integer(int(plan_id), "plan_id")
|
|
url = f"{self.garmin_connect_training_plan_url}/fbt-adaptive/{plan_id}"
|
|
|
|
logger.debug("Requesting adaptive training plan details for %s", plan_id)
|
|
return self.connectapi(url)
|
|
|
|
def get_nutrition_daily_food_log(self, cdate: str) -> dict[str, Any]:
|
|
"""Return food log summary for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_nutrition_daily_food_logs}/{cdate}"
|
|
logger.debug("Requesting nutrition food log data for date %s", cdate)
|
|
return self.connectapi(url)
|
|
|
|
def get_nutrition_daily_meals(self, cdate: str) -> dict[str, Any]:
|
|
"""Return meals summary for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_nutrition_daily_meals}/{cdate}"
|
|
logger.debug("Requesting nutrition meals data for date %s", cdate)
|
|
return self.connectapi(url)
|
|
|
|
def get_nutrition_daily_settings(self, cdate: str) -> dict[str, Any]:
|
|
"""Return nutrition settings for 'cdate' format 'YYYY-MM-DD'."""
|
|
cdate = _validate_date_format(cdate, "cdate")
|
|
url = f"{self.garmin_connect_nutrition_daily_settings}/{cdate}"
|
|
logger.debug("Requesting nutrition settings data for date %s", cdate)
|
|
return self.connectapi(url)
|
|
|
|
def get_golf_summary(
|
|
self, start: int = 0, limit: int = 100
|
|
) -> list[dict[str, Any]]:
|
|
"""Return golf scorecard summary.
|
|
|
|
Args:
|
|
start: Starting offset for pagination.
|
|
limit: Maximum number of results to return.
|
|
|
|
Returns:
|
|
List of golf scorecard summaries.
|
|
|
|
"""
|
|
start = _validate_non_negative_integer(start, "start")
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = f"{self.garmin_golf_scorecard_summary}"
|
|
params = {"per-page": str(limit), "start": str(start)}
|
|
logger.debug("Requesting golf summary with limit %d", limit)
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_golf_scorecard(self, scorecard_id: int | str) -> dict[str, Any]:
|
|
"""Return golf scorecard detail by scorecard ID.
|
|
|
|
Args:
|
|
scorecard_id: The scorecard ID to retrieve.
|
|
|
|
Returns:
|
|
Dictionary containing the golf scorecard detail.
|
|
|
|
"""
|
|
scorecard_id = _validate_positive_integer(int(scorecard_id), "scorecard_id")
|
|
url = f"{self.garmin_golf_scorecard_detail}"
|
|
params = {
|
|
"scorecard-ids": str(scorecard_id),
|
|
"include-longest-shot-distance": "true",
|
|
}
|
|
logger.debug("Requesting golf scorecard %d", scorecard_id)
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_golf_shot_data(
|
|
self,
|
|
scorecard_id: int | str,
|
|
hole_numbers: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return golf shot data for a scorecard and specific holes.
|
|
|
|
Args:
|
|
scorecard_id: The scorecard ID to get shot data for.
|
|
hole_numbers: Holes 1-18 separated by ',' or '-' (e.g. "1,2,3").
|
|
Garmin's API only accepts '-' as a separator, so any commas
|
|
are normalized to '-' before the request is sent.
|
|
Lists containing holes 10-18 are silently upgraded to return
|
|
all 18 holes, because Garmin's endpoint drops double-digit
|
|
hole numbers from filtered queries.
|
|
Omit to get every hole on the scorecard.
|
|
|
|
Returns:
|
|
Dictionary containing shot data per hole.
|
|
|
|
"""
|
|
scorecard_id = _validate_positive_integer(int(scorecard_id), "scorecard_id")
|
|
url = f"{self.garmin_golf_shot}/{scorecard_id}/hole"
|
|
params = None
|
|
if hole_numbers is not None:
|
|
hole_numbers = _validate_hole_numbers(hole_numbers)
|
|
# Garmin's endpoint only returns single-digit holes reliably when a
|
|
# hole-numbers filter is supplied. Double-digit holes (10-18) are
|
|
# dropped or cause an empty response. The only reliable way to
|
|
# retrieve holes 10-18 is to omit the parameter and get all 18 holes.
|
|
if any(int(n) > 9 for n in re.findall(r"\d+", hole_numbers)):
|
|
logger.warning(
|
|
"Garmin drops double-digit hole numbers from hole-numbers "
|
|
"queries; returning all 18 holes for scorecard %s instead of %s",
|
|
scorecard_id,
|
|
hole_numbers,
|
|
)
|
|
hole_numbers = None
|
|
else:
|
|
params = f"hole-numbers={hole_numbers.replace(',', '-')}"
|
|
logger.debug(
|
|
"Requesting golf shot data for scorecard %d, holes %s",
|
|
scorecard_id,
|
|
"all" if hole_numbers is None else hole_numbers,
|
|
)
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_golf_club_stats(
|
|
self,
|
|
limit: int = 1000,
|
|
) -> dict[str, Any]:
|
|
"""Return golf club names and statistics.
|
|
|
|
Args:
|
|
limit: Maximum number of results to return.
|
|
|
|
Returns:
|
|
Dictionary containing club list and distance data.
|
|
|
|
"""
|
|
limit = _validate_positive_integer(limit, "limit")
|
|
url = f"{self.garmin_golf_club_stats}"
|
|
params = {"per-page": str(limit), "include-stats": "true"}
|
|
logger.debug("Requesting golf club data for the user.")
|
|
return self.connectapi(url, params=params)
|
|
|
|
def get_golf_user_stats(self) -> dict[str, Any]:
|
|
"""Return overview of the users golf statistics.
|
|
|
|
Returns:
|
|
Dictionary containing user stats such as handicap and strokes gained.
|
|
|
|
"""
|
|
url = f"{self.garmin_golf_user_stats}"
|
|
logger.debug("Requesting golf user statistics")
|
|
return self.connectapi(url)
|
|
|
|
|
|
from .exceptions import ( # noqa: E402
|
|
GarminConnectAuthenticationError as GarminConnectAuthenticationError,
|
|
)
|
|
from .exceptions import ( # noqa: E402
|
|
GarminConnectConnectionError as GarminConnectConnectionError,
|
|
)
|
|
from .exceptions import ( # noqa: E402
|
|
GarminConnectInvalidFileFormatError as GarminConnectInvalidFileFormatError,
|
|
)
|
|
from .exceptions import ( # noqa: E402
|
|
GarminConnectNotFoundError as GarminConnectNotFoundError,
|
|
)
|
|
from .exceptions import ( # noqa: E402
|
|
GarminConnectTooManyRequestsError as GarminConnectTooManyRequestsError,
|
|
)
|