gcpapi-sync 0.1.20261022__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gcpapi_sync/__init__.py +62 -0
- gcpapi_sync/__main__.py +7 -0
- gcpapi_sync/_async_client.py +537 -0
- gcpapi_sync/_auth.py +350 -0
- gcpapi_sync/_client.py +516 -0
- gcpapi_sync/_compat.py +10 -0
- gcpapi_sync/_constants.py +41 -0
- gcpapi_sync/_core.py +234 -0
- gcpapi_sync/_dates.py +58 -0
- gcpapi_sync/_endpoints.py +608 -0
- gcpapi_sync/_files.py +80 -0
- gcpapi_sync/_retry.py +74 -0
- gcpapi_sync/_version.py +8 -0
- gcpapi_sync/cli.py +470 -0
- gcpapi_sync/exceptions.py +203 -0
- gcpapi_sync/models/__init__.py +246 -0
- gcpapi_sync/models/_base.py +128 -0
- gcpapi_sync/models/_generated.py +4628 -0
- gcpapi_sync/models/_series.py +232 -0
- gcpapi_sync/py.typed +0 -0
- gcpapi_sync-0.1.20261022.dist-info/METADATA +341 -0
- gcpapi_sync-0.1.20261022.dist-info/RECORD +25 -0
- gcpapi_sync-0.1.20261022.dist-info/WHEEL +4 -0
- gcpapi_sync-0.1.20261022.dist-info/entry_points.txt +3 -0
- gcpapi_sync-0.1.20261022.dist-info/licenses/LICENSE +21 -0
gcpapi_sync/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Typed sync and async client for the Garmin Connect Personal API.
|
|
2
|
+
|
|
3
|
+
Start with `GarminClient` (or `AsyncGarminClient`); response models are in
|
|
4
|
+
`gcpapi_sync.models`, exceptions in `gcpapi_sync.exceptions`.
|
|
5
|
+
|
|
6
|
+
.. include:: ../../README.md
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import models
|
|
10
|
+
from ._async_client import AsyncGarminClient
|
|
11
|
+
from ._auth import AccessToken, FileTokenCache, MemoryTokenCache, TokenCache, resolve_api_key
|
|
12
|
+
from ._client import GarminClient
|
|
13
|
+
from ._dates import DateLike
|
|
14
|
+
from ._endpoints import ActivityFilters, BoundingBox
|
|
15
|
+
from ._files import ActivityFile
|
|
16
|
+
from ._retry import RetryConfig
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
APIConnectionError,
|
|
20
|
+
APIError,
|
|
21
|
+
APIStatusError,
|
|
22
|
+
APITimeoutError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
BadRequestError,
|
|
25
|
+
ConfigurationError,
|
|
26
|
+
GarminError,
|
|
27
|
+
InternalServerError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PermissionDeniedError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ResponseValidationError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"APIConnectionError",
|
|
36
|
+
"APIError",
|
|
37
|
+
"APIStatusError",
|
|
38
|
+
"APITimeoutError",
|
|
39
|
+
"AccessToken",
|
|
40
|
+
"ActivityFile",
|
|
41
|
+
"ActivityFilters",
|
|
42
|
+
"AsyncGarminClient",
|
|
43
|
+
"AuthenticationError",
|
|
44
|
+
"BadRequestError",
|
|
45
|
+
"BoundingBox",
|
|
46
|
+
"ConfigurationError",
|
|
47
|
+
"DateLike",
|
|
48
|
+
"FileTokenCache",
|
|
49
|
+
"GarminClient",
|
|
50
|
+
"GarminError",
|
|
51
|
+
"InternalServerError",
|
|
52
|
+
"MemoryTokenCache",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"PermissionDeniedError",
|
|
55
|
+
"RateLimitError",
|
|
56
|
+
"ResponseValidationError",
|
|
57
|
+
"RetryConfig",
|
|
58
|
+
"TokenCache",
|
|
59
|
+
"__version__",
|
|
60
|
+
"models",
|
|
61
|
+
"resolve_api_key",
|
|
62
|
+
]
|
gcpapi_sync/__main__.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""Asynchronous client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Self, TypeVar, Unpack
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _endpoints as ep
|
|
12
|
+
from ._constants import (
|
|
13
|
+
DEFAULT_ACTIVITY_PAGE_SIZE,
|
|
14
|
+
DEFAULT_TIMEOUT,
|
|
15
|
+
MAX_ACTIVITY_PAGE_SIZE,
|
|
16
|
+
MAX_DAYS_BODY_BATTERY,
|
|
17
|
+
MAX_DAYS_DAILY_SUMMARIES,
|
|
18
|
+
MAX_DAYS_HRV,
|
|
19
|
+
MAX_DAYS_HYDRATION,
|
|
20
|
+
MAX_DAYS_SLEEP,
|
|
21
|
+
MAX_DAYS_STEPS,
|
|
22
|
+
MAX_DAYS_STRESS,
|
|
23
|
+
)
|
|
24
|
+
from ._core import BaseClient
|
|
25
|
+
from ._dates import DateLike, to_range, windows
|
|
26
|
+
from .exceptions import APIConnectionError, APITimeoutError, GarminError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import os
|
|
30
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
31
|
+
from datetime import date
|
|
32
|
+
from types import TracebackType
|
|
33
|
+
|
|
34
|
+
from pydantic import JsonValue
|
|
35
|
+
|
|
36
|
+
from ._auth import AccessToken, TokenCache
|
|
37
|
+
from ._endpoints import ActivityFilters, Request
|
|
38
|
+
from ._files import ActivityFile
|
|
39
|
+
from ._retry import RetryConfig
|
|
40
|
+
from .models import (
|
|
41
|
+
ActivitySummary,
|
|
42
|
+
ActivityWorkout,
|
|
43
|
+
BodyBatteryDailyReport,
|
|
44
|
+
DailyHeartRate,
|
|
45
|
+
DailyNutritionSummary,
|
|
46
|
+
DailySleep,
|
|
47
|
+
DailySpO2AndAcclimation,
|
|
48
|
+
DateWeight,
|
|
49
|
+
DetailedMenstrualCycleDay,
|
|
50
|
+
FitnessAge,
|
|
51
|
+
HeartRateZones,
|
|
52
|
+
HrvChart,
|
|
53
|
+
HrvSummary,
|
|
54
|
+
MenstrualCycleSummary,
|
|
55
|
+
MultiValueWellnessChartItem,
|
|
56
|
+
StepsDailyStat,
|
|
57
|
+
SummarizedActivity,
|
|
58
|
+
UserDailyHydration,
|
|
59
|
+
UserDailySummary,
|
|
60
|
+
UserDailySummaryV2,
|
|
61
|
+
UserGoalsRange,
|
|
62
|
+
UserSettings,
|
|
63
|
+
WeightRange,
|
|
64
|
+
WellnessActivitySummary,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
__all__ = ["AsyncGarminClient"]
|
|
68
|
+
|
|
69
|
+
T = TypeVar("T")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AsyncGarminClient(BaseClient):
|
|
73
|
+
"""Asynchronous client for the Garmin Connect Personal API.
|
|
74
|
+
|
|
75
|
+
Same methods as :class:`GarminClient`, as coroutines. Works with asyncio and trio.
|
|
76
|
+
Date ranges that need several requests are fetched concurrently::
|
|
77
|
+
|
|
78
|
+
async with AsyncGarminClient() as garmin:
|
|
79
|
+
steps = await garmin.get_daily_steps("2026-01-01", "2026-10-21")
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
api_key: Personal API key. If omitted, it is read from ``key_file``, the
|
|
83
|
+
``GARMIN_PERSONAL_API_KEY`` environment variable, or
|
|
84
|
+
``~/.config/garmin/connect-personal-api-key``, in that order.
|
|
85
|
+
access_token: An already issued access token. Without an API key the client
|
|
86
|
+
uses it until it expires.
|
|
87
|
+
key_file: File containing the API key.
|
|
88
|
+
token_cache: Where access tokens are kept. Defaults to memory; use
|
|
89
|
+
:class:`FileTokenCache` to reuse tokens across processes.
|
|
90
|
+
base_url: API host. Defaults to ``GARMIN_PERSONAL_API_BASE_URL`` or the
|
|
91
|
+
production host.
|
|
92
|
+
timeout: Request timeout in seconds, or an :class:`httpx.Timeout`.
|
|
93
|
+
retry: Retry policy. ``RetryConfig(max_retries=0)`` disables retries.
|
|
94
|
+
user_agent: Override the ``User-Agent`` header.
|
|
95
|
+
http_client: Bring your own :class:`httpx.AsyncClient`. The caller remains
|
|
96
|
+
responsible for closing it.
|
|
97
|
+
max_concurrency: Maximum parallel requests when a date range is split.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ConfigurationError: No API key or access token could be found.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
api_key: str | None = None,
|
|
106
|
+
*,
|
|
107
|
+
access_token: str | None = None,
|
|
108
|
+
key_file: str | os.PathLike[str] | None = None,
|
|
109
|
+
token_cache: TokenCache | None = None,
|
|
110
|
+
base_url: str | None = None,
|
|
111
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
112
|
+
retry: RetryConfig | None = None,
|
|
113
|
+
user_agent: str | None = None,
|
|
114
|
+
http_client: httpx.AsyncClient | None = None,
|
|
115
|
+
max_concurrency: int = 4,
|
|
116
|
+
) -> None:
|
|
117
|
+
if max_concurrency < 1:
|
|
118
|
+
raise ValueError("max_concurrency must be at least 1")
|
|
119
|
+
super().__init__(
|
|
120
|
+
api_key=api_key,
|
|
121
|
+
access_token=access_token,
|
|
122
|
+
key_file=key_file,
|
|
123
|
+
token_cache=token_cache,
|
|
124
|
+
base_url=base_url,
|
|
125
|
+
retry=retry,
|
|
126
|
+
user_agent=user_agent,
|
|
127
|
+
)
|
|
128
|
+
self.max_concurrency = max_concurrency
|
|
129
|
+
self._owns_http = http_client is None
|
|
130
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout, follow_redirects=False)
|
|
131
|
+
self._token_lock = anyio.Lock()
|
|
132
|
+
|
|
133
|
+
async def aclose(self) -> None:
|
|
134
|
+
"""Close the underlying HTTP connection pool (unless it was passed in)."""
|
|
135
|
+
if self._owns_http:
|
|
136
|
+
await self._http.aclose()
|
|
137
|
+
|
|
138
|
+
async def __aenter__(self) -> Self:
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
async def __aexit__(
|
|
142
|
+
self,
|
|
143
|
+
exc_type: type[BaseException] | None,
|
|
144
|
+
exc: BaseException | None,
|
|
145
|
+
traceback: TracebackType | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
await self.aclose()
|
|
148
|
+
|
|
149
|
+
# -- authentication ----------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
async def get_access_token(self, *, force_refresh: bool = False) -> AccessToken:
|
|
152
|
+
"""Return a valid access token, exchanging the API key if needed.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
force_refresh: Always exchange the API key for a new token.
|
|
156
|
+
"""
|
|
157
|
+
async with self._token_lock:
|
|
158
|
+
if not force_refresh and (token := self._usable_token()) is not None:
|
|
159
|
+
return token
|
|
160
|
+
return await self._exchange_key()
|
|
161
|
+
|
|
162
|
+
async def _token(self, stale: str | None = None) -> str:
|
|
163
|
+
async with self._token_lock:
|
|
164
|
+
token = self._usable_token(stale)
|
|
165
|
+
return (token or await self._exchange_key()).value
|
|
166
|
+
|
|
167
|
+
async def _exchange_key(self) -> AccessToken:
|
|
168
|
+
url, headers = self._token_request()
|
|
169
|
+
token = self._parse_token_response(await self._send("POST", url, headers, ()))
|
|
170
|
+
self._store_token(token)
|
|
171
|
+
return token
|
|
172
|
+
|
|
173
|
+
# -- transport ---------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async def _send(
|
|
176
|
+
self, method: str, url: str, headers: dict[str, str], params: ep.Params
|
|
177
|
+
) -> httpx.Response:
|
|
178
|
+
attempt = 0
|
|
179
|
+
while True:
|
|
180
|
+
started = time.monotonic()
|
|
181
|
+
try:
|
|
182
|
+
response = await self._http.request(method, url, headers=headers, params=params)
|
|
183
|
+
except httpx.TransportError as exc:
|
|
184
|
+
delay = self._retry_delay(attempt, None)
|
|
185
|
+
if delay is None:
|
|
186
|
+
error = (
|
|
187
|
+
APITimeoutError
|
|
188
|
+
if isinstance(exc, httpx.TimeoutException)
|
|
189
|
+
else APIConnectionError
|
|
190
|
+
)
|
|
191
|
+
raise error(
|
|
192
|
+
f"{method} {httpx.URL(url).path} failed: {exc}", request=exc.request
|
|
193
|
+
) from exc
|
|
194
|
+
self._log_retry(attempt, delay, repr(exc))
|
|
195
|
+
else:
|
|
196
|
+
self._log_response(response, time.monotonic() - started)
|
|
197
|
+
delay = self._retry_delay(attempt, response)
|
|
198
|
+
if delay is None:
|
|
199
|
+
return response
|
|
200
|
+
self._log_retry(attempt, delay, f"HTTP {response.status_code}")
|
|
201
|
+
await response.aclose()
|
|
202
|
+
await anyio.sleep(delay)
|
|
203
|
+
attempt += 1
|
|
204
|
+
|
|
205
|
+
async def _request(self, request: Request[T]) -> T:
|
|
206
|
+
url = self._url(request.path)
|
|
207
|
+
token = await self._token()
|
|
208
|
+
headers = self._headers(token, request.accept)
|
|
209
|
+
response = await self._send("GET", url, headers, request.params)
|
|
210
|
+
if response.status_code == 401 and self.can_refresh:
|
|
211
|
+
# The token was revoked or expired early: renew it once and retry.
|
|
212
|
+
headers = self._headers(await self._token(stale=token), request.accept)
|
|
213
|
+
response = await self._send("GET", url, headers, request.params)
|
|
214
|
+
if response.is_error:
|
|
215
|
+
raise self._status_error(response)
|
|
216
|
+
return request.parse(response)
|
|
217
|
+
|
|
218
|
+
async def _collect(
|
|
219
|
+
self,
|
|
220
|
+
build: Callable[[date, date], Request[list[T]]],
|
|
221
|
+
start: DateLike,
|
|
222
|
+
end: DateLike,
|
|
223
|
+
max_days: int,
|
|
224
|
+
) -> list[T]:
|
|
225
|
+
first, last = to_range(start, end)
|
|
226
|
+
spans = windows(first, last, max_days)
|
|
227
|
+
if len(spans) == 1:
|
|
228
|
+
return await self._request(build(*spans[0]))
|
|
229
|
+
results: list[list[T]] = [[] for _ in spans]
|
|
230
|
+
errors: list[Exception] = []
|
|
231
|
+
limiter = anyio.CapacityLimiter(self.max_concurrency)
|
|
232
|
+
|
|
233
|
+
async def fetch(index: int, window_start: date, window_end: date) -> None:
|
|
234
|
+
try:
|
|
235
|
+
async with limiter:
|
|
236
|
+
results[index] = await self._request(build(window_start, window_end))
|
|
237
|
+
except GarminError as exc:
|
|
238
|
+
errors.append(exc)
|
|
239
|
+
group.cancel_scope.cancel()
|
|
240
|
+
|
|
241
|
+
async with anyio.create_task_group() as group:
|
|
242
|
+
for index, (window_start, window_end) in enumerate(spans):
|
|
243
|
+
group.start_soon(fetch, index, window_start, window_end)
|
|
244
|
+
if errors:
|
|
245
|
+
# Raise the API error itself rather than an ExceptionGroup.
|
|
246
|
+
raise errors[0]
|
|
247
|
+
return [item for chunk in results for item in chunk]
|
|
248
|
+
|
|
249
|
+
async def get_json(self, path: str, params: Sequence[tuple[str, str]] = ()) -> JsonValue:
|
|
250
|
+
"""GET any path on the API host and return the decoded JSON.
|
|
251
|
+
|
|
252
|
+
An escape hatch for endpoints this package does not wrap yet. Authentication,
|
|
253
|
+
retries and error handling work as for the typed methods.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
path: Absolute path, such as ``"/sleep-service/sleep/dailySleep"``.
|
|
257
|
+
params: Query parameters as ``(name, value)`` pairs; repeat a name to
|
|
258
|
+
send it several times.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The decoded JSON, or ``None`` for an empty response.
|
|
262
|
+
"""
|
|
263
|
+
return await self._request(ep.get_json(path, params))
|
|
264
|
+
|
|
265
|
+
# -- activities --------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async def get_activity(self, activity_id: int) -> ActivitySummary:
|
|
268
|
+
"""Return an activity's summary, metadata, measurements and splits."""
|
|
269
|
+
return await self._request(ep.get_activity(activity_id))
|
|
270
|
+
|
|
271
|
+
async def get_activity_workouts(self, activity_id: int) -> list[ActivityWorkout]:
|
|
272
|
+
"""Return the structured workouts performed during an activity."""
|
|
273
|
+
return await self._request(ep.get_activity_workouts(activity_id))
|
|
274
|
+
|
|
275
|
+
async def search_activities(
|
|
276
|
+
self,
|
|
277
|
+
*,
|
|
278
|
+
start: int = 0,
|
|
279
|
+
limit: int = DEFAULT_ACTIVITY_PAGE_SIZE,
|
|
280
|
+
**filters: Unpack[ActivityFilters],
|
|
281
|
+
) -> list[SummarizedActivity]:
|
|
282
|
+
"""Return up to ``limit`` activities matching ``filters``, newest first by default.
|
|
283
|
+
|
|
284
|
+
Large limits are fetched in several pages (the server fails on big pages).
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
start: Zero-based offset of the first activity.
|
|
288
|
+
limit: Maximum number of activities to return.
|
|
289
|
+
**filters: See :class:`ActivityFilters`.
|
|
290
|
+
"""
|
|
291
|
+
if start < 0 or limit < 0:
|
|
292
|
+
raise ValueError("start and limit must be >= 0")
|
|
293
|
+
params = ep.activity_filter_params(filters)
|
|
294
|
+
activities: list[SummarizedActivity] = []
|
|
295
|
+
while len(activities) < limit:
|
|
296
|
+
size = min(limit - len(activities), DEFAULT_ACTIVITY_PAGE_SIZE)
|
|
297
|
+
page = await self._request(ep.search_activities(start + len(activities), size, params))
|
|
298
|
+
activities.extend(page)
|
|
299
|
+
if len(page) < size:
|
|
300
|
+
break
|
|
301
|
+
return activities
|
|
302
|
+
|
|
303
|
+
async def iter_activities(
|
|
304
|
+
self,
|
|
305
|
+
*,
|
|
306
|
+
start: int = 0,
|
|
307
|
+
page_size: int = DEFAULT_ACTIVITY_PAGE_SIZE,
|
|
308
|
+
**filters: Unpack[ActivityFilters],
|
|
309
|
+
) -> AsyncIterator[SummarizedActivity]:
|
|
310
|
+
"""Iterate over all activities matching ``filters``, fetching pages lazily.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
start: Zero-based offset of the first activity.
|
|
314
|
+
page_size: Activities per request (1 to 45).
|
|
315
|
+
**filters: See :class:`ActivityFilters`.
|
|
316
|
+
"""
|
|
317
|
+
if not 1 <= page_size <= MAX_ACTIVITY_PAGE_SIZE:
|
|
318
|
+
raise ValueError(f"page_size must be between 1 and {MAX_ACTIVITY_PAGE_SIZE}")
|
|
319
|
+
if start < 0:
|
|
320
|
+
raise ValueError("start must be >= 0")
|
|
321
|
+
params = ep.activity_filter_params(filters)
|
|
322
|
+
offset = start
|
|
323
|
+
while True:
|
|
324
|
+
page = await self._request(ep.search_activities(offset, page_size, params))
|
|
325
|
+
for activity in page:
|
|
326
|
+
yield activity
|
|
327
|
+
if len(page) < page_size:
|
|
328
|
+
return
|
|
329
|
+
offset += len(page)
|
|
330
|
+
|
|
331
|
+
async def download_activity(self, activity_id: int) -> ActivityFile:
|
|
332
|
+
"""Download an activity in its original format (usually FIT)."""
|
|
333
|
+
return await self._request(ep.download_activity(activity_id))
|
|
334
|
+
|
|
335
|
+
async def download_activity_zip(self, activity_id: int) -> bytes:
|
|
336
|
+
"""Download an activity as the zip archive Garmin serves."""
|
|
337
|
+
return await self._request(ep.download_activity_zip(activity_id))
|
|
338
|
+
|
|
339
|
+
# -- daily summaries ---------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
async def get_daily_summary(self, day: DateLike) -> UserDailySummary:
|
|
342
|
+
"""Return the day's wellness summary: steps, calories, stress, Body Battery, ..."""
|
|
343
|
+
return await self._request(ep.get_daily_summary(day))
|
|
344
|
+
|
|
345
|
+
async def get_daily_summaries(self, start: DateLike, end: DateLike) -> list[UserDailySummaryV2]:
|
|
346
|
+
"""Return detailed daily summaries for each day from ``start`` to ``end`` (inclusive)."""
|
|
347
|
+
return await self._collect(ep.get_daily_summaries, start, end, MAX_DAYS_DAILY_SUMMARIES)
|
|
348
|
+
|
|
349
|
+
async def get_wellness_activity_summaries(
|
|
350
|
+
self, start: DateLike, end: DateLike
|
|
351
|
+
) -> list[WellnessActivitySummary]:
|
|
352
|
+
"""Return wellness activities (such as breathing sessions) in a date range."""
|
|
353
|
+
first, last = to_range(start, end)
|
|
354
|
+
return await self._request(ep.get_wellness_activity_summaries(first, last))
|
|
355
|
+
|
|
356
|
+
# -- sleep -------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
async def get_daily_sleep(self, day: DateLike) -> DailySleep:
|
|
359
|
+
"""Return the sleep record for a calendar day (the night ending that morning)."""
|
|
360
|
+
return await self._request(ep.get_daily_sleep(day))
|
|
361
|
+
|
|
362
|
+
async def get_daily_sleep_summaries(self, start: DateLike, end: DateLike) -> list[DailySleep]:
|
|
363
|
+
"""Return sleep records for each day from ``start`` to ``end`` (inclusive)."""
|
|
364
|
+
return await self._collect(ep.get_daily_sleep_summaries, start, end, MAX_DAYS_SLEEP)
|
|
365
|
+
|
|
366
|
+
# -- heart -------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
async def get_daily_heart_rate(self, day: DateLike) -> DailyHeartRate:
|
|
369
|
+
"""Return the day's heart rate time series and resting/min/max values."""
|
|
370
|
+
return await self._request(ep.get_daily_heart_rate(day))
|
|
371
|
+
|
|
372
|
+
async def get_heart_rate_zones(self, *, generate_defaults: bool = True) -> list[HeartRateZones]:
|
|
373
|
+
"""Return the user's heart rate zone configuration per sport.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
generate_defaults: Return default zones when none are configured.
|
|
377
|
+
"""
|
|
378
|
+
return await self._request(ep.get_heart_rate_zones(generate_defaults=generate_defaults))
|
|
379
|
+
|
|
380
|
+
async def get_daily_hrv(self, day: DateLike) -> HrvChart | None:
|
|
381
|
+
"""Return the night's HRV summary and readings, or ``None`` without data."""
|
|
382
|
+
return await self._request(ep.get_daily_hrv(day))
|
|
383
|
+
|
|
384
|
+
async def get_daily_hrv_summaries(self, start: DateLike, end: DateLike) -> list[HrvSummary]:
|
|
385
|
+
"""Return daily HRV summaries from ``start`` to ``end`` (inclusive)."""
|
|
386
|
+
return await self._collect(ep.get_daily_hrv_summaries, start, end, MAX_DAYS_HRV)
|
|
387
|
+
|
|
388
|
+
# -- wellness ----------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
async def get_daily_stress(
|
|
391
|
+
self, start: DateLike, end: DateLike
|
|
392
|
+
) -> list[MultiValueWellnessChartItem]:
|
|
393
|
+
"""Return daily stress levels and durations from ``start`` to ``end`` (inclusive)."""
|
|
394
|
+
return await self._collect(ep.get_daily_stress, start, end, MAX_DAYS_STRESS)
|
|
395
|
+
|
|
396
|
+
async def get_daily_steps(self, start: DateLike, end: DateLike) -> list[StepsDailyStat]:
|
|
397
|
+
"""Return daily steps, distance and goal from ``start`` to ``end`` (inclusive)."""
|
|
398
|
+
return await self._collect(ep.get_daily_steps, start, end, MAX_DAYS_STEPS)
|
|
399
|
+
|
|
400
|
+
async def get_daily_hydration(self, start: DateLike, end: DateLike) -> list[UserDailyHydration]:
|
|
401
|
+
"""Return daily hydration intake and goals from ``start`` to ``end`` (inclusive)."""
|
|
402
|
+
return await self._collect(ep.get_daily_hydration, start, end, MAX_DAYS_HYDRATION)
|
|
403
|
+
|
|
404
|
+
async def get_daily_body_battery(
|
|
405
|
+
self, start: DateLike, end: DateLike, *, chunks: int | None = None
|
|
406
|
+
) -> list[BodyBatteryDailyReport]:
|
|
407
|
+
"""Return daily Body Battery reports from ``start`` to ``end`` (inclusive).
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
start: First day.
|
|
411
|
+
end: Last day.
|
|
412
|
+
chunks: Split each day into this many intervals and report the lowest and
|
|
413
|
+
highest reading of each.
|
|
414
|
+
"""
|
|
415
|
+
return await self._collect(
|
|
416
|
+
lambda a, b: ep.get_daily_body_battery(a, b, chunks=chunks),
|
|
417
|
+
start,
|
|
418
|
+
end,
|
|
419
|
+
MAX_DAYS_BODY_BATTERY,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
async def get_daily_spo2(self, day: DateLike) -> DailySpO2AndAcclimation:
|
|
423
|
+
"""Return the day's pulse-ox (SpO2) readings and altitude acclimation."""
|
|
424
|
+
return await self._request(ep.get_daily_spo2(day))
|
|
425
|
+
|
|
426
|
+
async def get_fitness_age(self, day: DateLike, *, full_history_scan: bool = True) -> FitnessAge:
|
|
427
|
+
"""Return the latest Fitness Age as of ``day``.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
day: Reference date.
|
|
431
|
+
full_history_scan: Search the full history; ``False`` only looks at the
|
|
432
|
+
28 days before ``day``.
|
|
433
|
+
"""
|
|
434
|
+
return await self._request(ep.get_fitness_age(day, full_history_scan=full_history_scan))
|
|
435
|
+
|
|
436
|
+
# -- body composition --------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
async def get_weight_range(
|
|
439
|
+
self, start: DateLike, end: DateLike, *, include_all: bool = False
|
|
440
|
+
) -> WeightRange:
|
|
441
|
+
"""Return weight summaries for a date range: per-day values and the range average.
|
|
442
|
+
|
|
443
|
+
Weights are in grams. Garmin computes the average over the whole range, so the
|
|
444
|
+
range is sent as one request; ranges beyond about a year may fail server-side.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
start: First day.
|
|
448
|
+
end: Last day.
|
|
449
|
+
include_all: Include every measurement of each day, not just the latest.
|
|
450
|
+
"""
|
|
451
|
+
first, last = to_range(start, end)
|
|
452
|
+
return await self._request(ep.get_weight_range(first, last, include_all=include_all))
|
|
453
|
+
|
|
454
|
+
async def get_weight_entries(self, start: DateLike, end: DateLike) -> list[DateWeight]:
|
|
455
|
+
"""Return all weight measurements in a date range.
|
|
456
|
+
|
|
457
|
+
Note:
|
|
458
|
+
As of October 2026 Garmin answers this documented endpoint with HTTP 400
|
|
459
|
+
for every input. Use ``get_weight_range(..., include_all=True)`` instead.
|
|
460
|
+
"""
|
|
461
|
+
first, last = to_range(start, end)
|
|
462
|
+
return await self._request(ep.get_weight_entries(first, last))
|
|
463
|
+
|
|
464
|
+
async def get_effective_weight_goals(self, start: DateLike, end: DateLike) -> UserGoalsRange:
|
|
465
|
+
"""Return the weight goals in effect during a date range."""
|
|
466
|
+
first, last = to_range(start, end)
|
|
467
|
+
return await self._request(ep.get_effective_weight_goals(first, last))
|
|
468
|
+
|
|
469
|
+
# -- nutrition ---------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
async def get_daily_food_logs(
|
|
472
|
+
self,
|
|
473
|
+
day: DateLike,
|
|
474
|
+
*,
|
|
475
|
+
include_goals: bool = True,
|
|
476
|
+
include_nutrition: bool = True,
|
|
477
|
+
include_media: bool = True,
|
|
478
|
+
meal_id: int | None = None,
|
|
479
|
+
) -> DailyNutritionSummary:
|
|
480
|
+
"""Return the day's nutrition log, goals and per-meal breakdown.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
day: Calendar day.
|
|
484
|
+
include_goals: Include nutrition goals.
|
|
485
|
+
include_nutrition: Include nutrient totals.
|
|
486
|
+
include_media: Include food images.
|
|
487
|
+
meal_id: Only this meal.
|
|
488
|
+
"""
|
|
489
|
+
return await self._request(
|
|
490
|
+
ep.get_daily_food_logs(
|
|
491
|
+
day,
|
|
492
|
+
include_goals=include_goals,
|
|
493
|
+
include_nutrition=include_nutrition,
|
|
494
|
+
include_media=include_media,
|
|
495
|
+
meal_id=meal_id,
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# -- women's health ----------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
async def get_menstrual_cycle_summary(self, day: DateLike) -> MenstrualCycleSummary | None:
|
|
502
|
+
"""Return the menstrual cycle summary for ``day``, or ``None`` if not tracked."""
|
|
503
|
+
return await self._request(ep.get_menstrual_cycle_summary(day))
|
|
504
|
+
|
|
505
|
+
async def get_menstrual_cycle_day_view(
|
|
506
|
+
self,
|
|
507
|
+
day: DateLike,
|
|
508
|
+
*,
|
|
509
|
+
today: DateLike | None = None,
|
|
510
|
+
reduced_response: bool = False,
|
|
511
|
+
skip_baby_movement: bool = False,
|
|
512
|
+
include_analysis: bool = False,
|
|
513
|
+
) -> DetailedMenstrualCycleDay | None:
|
|
514
|
+
"""Return the detailed menstrual cycle view for ``day``, or ``None`` if not tracked.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
day: Calendar day.
|
|
518
|
+
today: Reference "today" for predictions.
|
|
519
|
+
reduced_response: Request a lighter payload.
|
|
520
|
+
skip_baby_movement: Omit kick-count data.
|
|
521
|
+
include_analysis: Include cycle analysis.
|
|
522
|
+
"""
|
|
523
|
+
return await self._request(
|
|
524
|
+
ep.get_menstrual_cycle_day_view(
|
|
525
|
+
day,
|
|
526
|
+
today=today,
|
|
527
|
+
reduced_response=reduced_response,
|
|
528
|
+
skip_baby_movement=skip_baby_movement,
|
|
529
|
+
include_analysis=include_analysis,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# -- profile -----------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
async def get_user_profile_settings(self) -> UserSettings:
|
|
536
|
+
"""Return the user's profile settings (units, measurements, preferences)."""
|
|
537
|
+
return await self._request(ep.get_user_profile_settings())
|