garmin-py 2.7.0__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.
Files changed (62) hide show
  1. garmin_cli/__init__.py +7 -0
  2. garmin_cli/__main__.py +4 -0
  3. garmin_cli/_env.py +22 -0
  4. garmin_cli/auth.py +198 -0
  5. garmin_cli/backend.py +355 -0
  6. garmin_cli/cli.py +185 -0
  7. garmin_cli/commands/__init__.py +1 -0
  8. garmin_cli/commands/_options.py +89 -0
  9. garmin_cli/commands/activities.py +406 -0
  10. garmin_cli/commands/devices.py +27 -0
  11. garmin_cli/commands/health.py +347 -0
  12. garmin_cli/commands/login.py +124 -0
  13. garmin_cli/commands/performance.py +156 -0
  14. garmin_cli/commands/workouts.py +220 -0
  15. garmin_cli/config.py +34 -0
  16. garmin_cli/date_utils.py +115 -0
  17. garmin_cli/endpoints/__init__.py +1 -0
  18. garmin_cli/endpoints/_base.py +258 -0
  19. garmin_cli/endpoints/activities.py +344 -0
  20. garmin_cli/endpoints/devices.py +19 -0
  21. garmin_cli/endpoints/health.py +127 -0
  22. garmin_cli/endpoints/metrics.py +48 -0
  23. garmin_cli/endpoints/performance.py +138 -0
  24. garmin_cli/endpoints/workouts.py +150 -0
  25. garmin_cli/exceptions.py +30 -0
  26. garmin_cli/input_reader.py +67 -0
  27. garmin_cli/mcp_auth.py +43 -0
  28. garmin_cli/mcp_cli.py +258 -0
  29. garmin_cli/mcp_server.py +56 -0
  30. garmin_cli/mcp_tools/__init__.py +10 -0
  31. garmin_cli/mcp_tools/_shared.py +257 -0
  32. garmin_cli/mcp_tools/activities.py +161 -0
  33. garmin_cli/mcp_tools/activities_write.py +260 -0
  34. garmin_cli/mcp_tools/health.py +113 -0
  35. garmin_cli/mcp_tools/misc.py +164 -0
  36. garmin_cli/mcp_tools/performance.py +91 -0
  37. garmin_cli/mcp_tools/workouts.py +305 -0
  38. garmin_cli/metrics/__init__.py +42 -0
  39. garmin_cli/metrics/field_table.py +152 -0
  40. garmin_cli/metrics/registry.py +420 -0
  41. garmin_cli/metrics/sport_profile.py +247 -0
  42. garmin_cli/output.py +158 -0
  43. garmin_cli/serializers/__init__.py +238 -0
  44. garmin_cli/serializers/_common.py +133 -0
  45. garmin_cli/serializers/activities.py +611 -0
  46. garmin_cli/serializers/devices.py +32 -0
  47. garmin_cli/serializers/health.py +422 -0
  48. garmin_cli/serializers/performance.py +306 -0
  49. garmin_cli/serializers/workouts.py +155 -0
  50. garmin_cli/services/__init__.py +8 -0
  51. garmin_cli/services/activities.py +139 -0
  52. garmin_cli/services/performance.py +37 -0
  53. garmin_cli/token_store.py +93 -0
  54. garmin_cli/units.py +90 -0
  55. garmin_cli/workout_builder.py +267 -0
  56. garmin_cli/workout_schema.py +185 -0
  57. garmin_py-2.7.0.dist-info/METADATA +470 -0
  58. garmin_py-2.7.0.dist-info/RECORD +62 -0
  59. garmin_py-2.7.0.dist-info/WHEEL +5 -0
  60. garmin_py-2.7.0.dist-info/entry_points.txt +2 -0
  61. garmin_py-2.7.0.dist-info/licenses/LICENSE +21 -0
  62. garmin_py-2.7.0.dist-info/top_level.txt +1 -0
garmin_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """garmin-cli — Garmin Connect data extractor."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("garmin-py")
6
+ except PackageNotFoundError: # running from a source tree without installed metadata
7
+ __version__ = "0.0.0+unknown"
garmin_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Allow running garmin-cli as a module: python -m garmin_cli."""
2
+ from garmin_cli.cli import main
3
+
4
+ main()
garmin_cli/_env.py ADDED
@@ -0,0 +1,22 @@
1
+ """Environment-variable parsing helpers shared across low-level modules."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+
7
+ def _env_float(name: str, default: float, *, allow_zero: bool = True) -> float:
8
+ """Read *name* from the environment as a float, falling back to *default*.
9
+
10
+ A parse error, a negative value, or a zero value when ``allow_zero`` is
11
+ False, is ignored and *default* is returned. This is the shared core of the
12
+ per-knob resolvers (HTTP timeout, auth probe TTL, daily-call delay).
13
+ """
14
+ raw = os.environ.get(name, "")
15
+ if raw:
16
+ try:
17
+ value = float(raw)
18
+ if value > 0 or (allow_zero and value == 0):
19
+ return value
20
+ except ValueError:
21
+ pass
22
+ return default
garmin_cli/auth.py ADDED
@@ -0,0 +1,198 @@
1
+ """Authentication via the maintained Garmin backend."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import threading
7
+ import time
8
+ from datetime import date
9
+ from typing import Any
10
+
11
+ from garmin_cli import backend as garth
12
+ from garmin_cli._env import _env_float
13
+ from garmin_cli.config import CliConfig
14
+ from garmin_cli.exceptions import GarminCliError, extract_status_code
15
+ from garmin_cli.token_store import ensure_secure_directory
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Probe-TTL cache
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _DEFAULT_PROBE_TTL: float = 600.0
24
+
25
+ _probe_cache_lock: threading.Lock = threading.Lock()
26
+ # Map of garth_home -> timestamp of last successful probe (monotonic clock).
27
+ _last_probe_ok: dict[str, float] = {}
28
+ # The garth_home for which the backend is currently loaded.
29
+ _cached_garth_home: str | None = None
30
+
31
+
32
+ def _get_probe_ttl() -> float:
33
+ """Return the probe TTL in seconds from the environment.
34
+
35
+ ``GARMIN_CLI_AUTH_PROBE_TTL`` may be set to a non-negative float.
36
+ ``0`` disables caching entirely (today's behaviour). Invalid or
37
+ negative values fall back to the 600 s default.
38
+ """
39
+ return _env_float("GARMIN_CLI_AUTH_PROBE_TTL", _DEFAULT_PROBE_TTL)
40
+
41
+
42
+ def _probe_cache_hit(garth_home: str, ttl: float) -> bool:
43
+ """Return True when the cache entry is valid (TTL > 0 and not expired)."""
44
+ if ttl == 0:
45
+ return False
46
+ with _probe_cache_lock:
47
+ if _cached_garth_home != garth_home:
48
+ return False
49
+ last = _last_probe_ok.get(garth_home)
50
+ if last is None:
51
+ return False
52
+ return (time.monotonic() - last) < ttl
53
+
54
+
55
+ def _record_probe_ok(garth_home: str) -> None:
56
+ """Record a successful probe for *garth_home*."""
57
+ with _probe_cache_lock:
58
+ global _cached_garth_home
59
+ _cached_garth_home = garth_home
60
+ _last_probe_ok[garth_home] = time.monotonic()
61
+
62
+
63
+ def _invalidate_probe_cache(garth_home: str | None = None) -> None:
64
+ """Invalidate cache entries.
65
+
66
+ If *garth_home* is given, only that key is removed. Otherwise the
67
+ entire cache is cleared (used on login, which replaces any session).
68
+ """
69
+ with _probe_cache_lock:
70
+ global _cached_garth_home
71
+ if garth_home is not None:
72
+ _last_probe_ok.pop(garth_home, None)
73
+ if _cached_garth_home == garth_home:
74
+ _cached_garth_home = None
75
+ else:
76
+ _last_probe_ok.clear()
77
+ _cached_garth_home = None
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Security helpers
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _secure_directory(path: str) -> None:
86
+ """Ensure path is not a symlink and has owner-only permissions.
87
+
88
+ Raises GarminCliError if the path is a symlink or if permission
89
+ repair fails.
90
+ """
91
+ try:
92
+ ensure_secure_directory(path)
93
+ except OSError as exc:
94
+ if os.path.islink(path):
95
+ raise GarminCliError(
96
+ error=f"garmin_home path '{path}' is a symlink — refusing for security",
97
+ error_code="AUTH_FAILED",
98
+ ) from exc
99
+ raise GarminCliError(
100
+ error=(
101
+ f"garmin_home directory has insecure permissions "
102
+ f"and cannot be repaired: {exc}"
103
+ ),
104
+ error_code="AUTH_FAILED",
105
+ ) from exc
106
+
107
+
108
+ def _probe_session(garth_client: Any | None = None) -> None:
109
+ """Verify that resumed tokens still authorize a simple Garmin request.
110
+
111
+ garth_client defaults to the module-level garth so callers can pass
112
+ their own patched instance in tests without also patching auth.garth.
113
+ """
114
+ if garth_client is None:
115
+ garth_client = garth
116
+ today = date.today()
117
+ garth_client.connectapi(
118
+ f"/calendar-service/year/{today.year}/month/{today.month - 1}/day/{today.day}/start/1"
119
+ )
120
+
121
+
122
+ def ensure_authenticated(config: CliConfig) -> None:
123
+ """Authenticate with Garmin Connect.
124
+
125
+ Tries to resume an existing session. If that fails and credentials
126
+ are available, performs a fresh login and saves the session.
127
+
128
+ On a probe-TTL cache hit the function returns immediately without any
129
+ disk reads or network calls; the directory security check is deferred
130
+ to the next cache miss so hot MCP servers skip the per-call stat/chmod.
131
+
132
+ Raises:
133
+ GarminCliError: With error_code AUTH_MISSING if no session and no credentials.
134
+ GarminCliError: With error_code AUTH_FAILED if login fails or security check fails.
135
+ """
136
+ garth_home = os.path.expanduser(config.garth_home)
137
+
138
+ ttl = _get_probe_ttl()
139
+ if _probe_cache_hit(garth_home, ttl):
140
+ logger.debug("Auth probe cache hit for %s — skipping resume+probe", garth_home)
141
+ return
142
+
143
+ _secure_directory(garth_home)
144
+
145
+ try:
146
+ garth.resume(garth_home)
147
+ try:
148
+ _probe_session()
149
+ _record_probe_ok(garth_home)
150
+ return
151
+ except Exception as exc:
152
+ _invalidate_probe_cache(garth_home)
153
+ if extract_status_code(exc) not in (401, 403):
154
+ raise GarminCliError(
155
+ error="Saved Garmin session could not be validated.",
156
+ error_code="AUTH_FAILED",
157
+ ) from exc
158
+ except FileNotFoundError:
159
+ pass
160
+ except OSError as exc:
161
+ raise GarminCliError(
162
+ error=f"Cannot access session directory: {exc}",
163
+ error_code="AUTH_FAILED",
164
+ ) from exc
165
+ except GarminCliError:
166
+ raise
167
+ except Exception as exc:
168
+ # Session expired/missing/corrupt — fall through to login. Broad by
169
+ # design: garth/garminconnect exception shapes vary across versions, so
170
+ # any resume failure should re-auth rather than crash. Logged for triage.
171
+ _invalidate_probe_cache(garth_home)
172
+ logger.debug("Garmin session resume failed, falling through to login: %s", exc)
173
+
174
+ if not config.email or not config.password:
175
+ raise GarminCliError(
176
+ error=(
177
+ "No usable saved session found and GARMIN_EMAIL / GARMIN_PASSWORD "
178
+ "are not set. Set these credentials to authenticate."
179
+ ),
180
+ error_code="AUTH_MISSING",
181
+ )
182
+
183
+ try:
184
+ _invalidate_probe_cache() # login replaces the session entirely
185
+ garth.login(config.email, config.password, garth_home=garth_home)
186
+ os.makedirs(garth_home, mode=0o700, exist_ok=True)
187
+ garth.save(garth_home)
188
+ except Exception as exc:
189
+ if extract_status_code(exc) == 429:
190
+ logger.debug("Garmin login hit rate limiting for %s", garth_home)
191
+ raise GarminCliError(
192
+ error="Garmin login is temporarily rate limited. Try again shortly.",
193
+ error_code="RATE_LIMITED",
194
+ ) from exc
195
+ raise GarminCliError(
196
+ error="Authentication failed. Check your credentials.",
197
+ error_code="AUTH_FAILED",
198
+ ) from exc
garmin_cli/backend.py ADDED
@@ -0,0 +1,355 @@
1
+ """Maintained Garmin backend compatibility boundary."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import threading
7
+ import types
8
+ from datetime import date
9
+ from typing import Any
10
+
11
+ from garminconnect import Garmin
12
+
13
+ from garmin_cli._env import _env_float
14
+ from garmin_cli.token_store import (
15
+ detect_legacy_tokens,
16
+ ensure_secure_directory,
17
+ has_tokenstore,
18
+ secure_token_file,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _DEFAULT_HTTP_TIMEOUT: float = 30.0
24
+
25
+ # RLock so that the same thread can re-acquire (e.g. login -> _set_backend).
26
+ _backend_lock: threading.RLock = threading.RLock()
27
+
28
+
29
+ def _resolve_http_timeout() -> float:
30
+ """Return the configured HTTP timeout in seconds.
31
+
32
+ Reads ``GARMIN_CLI_HTTP_TIMEOUT`` from the environment. Invalid or
33
+ non-positive values are silently ignored and the default (30 s) is used.
34
+ """
35
+ return _env_float("GARMIN_CLI_HTTP_TIMEOUT", _DEFAULT_HTTP_TIMEOUT, allow_zero=False)
36
+
37
+
38
+ def _apply_timeout(garmin: Garmin) -> None:
39
+ """Wrap *garmin.client._run_request* to enforce the configured timeout.
40
+
41
+ The upstream ``garminconnect.client.Client._run_request`` injects
42
+ ``timeout=15`` when the caller omits it. We replace that default with
43
+ our configurable value by wrapping the bound method at the instance level
44
+ so every API call inherits it without touching the installed package.
45
+ """
46
+ timeout = _resolve_http_timeout()
47
+ inner_client = garmin.client
48
+ original_run_request = inner_client._run_request.__func__ # type: ignore[attr-defined]
49
+
50
+ def _patched_run_request(self: Any, method: str, path: str, **kwargs: Any) -> Any:
51
+ if "timeout" not in kwargs:
52
+ kwargs["timeout"] = timeout
53
+ return original_run_request(self, method, path, **kwargs)
54
+
55
+ inner_client._run_request = types.MethodType(_patched_run_request, inner_client) # type: ignore[method-assign]
56
+
57
+
58
+ # Process-wide: under parallel fan-out several worker threads can hit the
59
+ # token-expiry window (or a 401) simultaneously. Upstream _refresh_session
60
+ # mutates token state and rewrites the tokenstore non-atomically, so
61
+ # concurrent refreshes can corrupt the store or burn a rotated (single-use)
62
+ # refresh token.
63
+ _refresh_lock: threading.Lock = threading.Lock()
64
+
65
+
66
+ def _serialize_refresh(garmin: Garmin) -> None:
67
+ """Wrap *garmin.client._refresh_session* so only one refresh runs at a time.
68
+
69
+ A thread queued behind an in-flight refresh skips its own refresh when it
70
+ observes a refresh already completed while it waited for the lock. A
71
+ generation counter (not token identity) detects this so the guard also
72
+ covers legacy jwt_web sessions, whose refresh never touches ``di_token``.
73
+ """
74
+ inner_client = garmin.client
75
+ original_refresh = inner_client._refresh_session.__func__ # type: ignore[attr-defined]
76
+ inner_client._garmin_cli_refresh_generation = 0
77
+
78
+ def _locked_refresh(self: Any) -> None:
79
+ seen = self._garmin_cli_refresh_generation
80
+ with _refresh_lock:
81
+ if self._garmin_cli_refresh_generation != seen:
82
+ return
83
+ original_refresh(self)
84
+ self._garmin_cli_refresh_generation = seen + 1
85
+
86
+ inner_client._refresh_session = types.MethodType(_locked_refresh, inner_client) # type: ignore[method-assign]
87
+
88
+
89
+ RAW_FALLBACKS: tuple[dict[str, str], ...] = (
90
+ {
91
+ "capability": "workout_update",
92
+ "why": "Upstream provides no typed workout update helper.",
93
+ "transport": "Garmin.client.put('connectapi', '/workout-service/workout/{id}', json=payload)",
94
+ "tests": "tests/test_endpoints/test_workouts_write.py, tests/test_backend.py",
95
+ "removal_condition": "Use an upstream typed update method once python-garminconnect exposes one.",
96
+ },
97
+ )
98
+
99
+ _backend: Garmin | None = None
100
+ _garth_home: str | None = None
101
+
102
+
103
+ def get_raw_fallback_registry() -> list[dict[str, str]]:
104
+ """Return the governed raw-fallback table for this backend."""
105
+ return [dict(entry) for entry in RAW_FALLBACKS]
106
+
107
+
108
+ def _require_backend() -> Garmin:
109
+ with _backend_lock:
110
+ if _backend is None:
111
+ raise RuntimeError("Garmin backend is not authenticated")
112
+ return _backend
113
+
114
+
115
+ def _set_backend(client: Garmin, garth_home: str | None = None) -> None:
116
+ global _backend, _garth_home
117
+ with _backend_lock:
118
+ _backend = client
119
+ _garth_home = garth_home
120
+
121
+
122
+ def _normalize_home(path: str | None = None) -> str | None:
123
+ if path is None:
124
+ with _backend_lock:
125
+ return _garth_home
126
+ return str(ensure_secure_directory(path))
127
+
128
+
129
+ def login(
130
+ email: str,
131
+ password: str,
132
+ *,
133
+ garth_home: str | None = None,
134
+ prompt_mfa: Any = None,
135
+ ) -> None:
136
+ """Authenticate with Garmin Connect without persisting the tokenstore yet."""
137
+ normalized_home = _normalize_home(garth_home)
138
+ client = Garmin(email, password, prompt_mfa=prompt_mfa)
139
+ _apply_timeout(client)
140
+ _serialize_refresh(client)
141
+ previous_env = os.environ.pop("GARMINTOKENS", None)
142
+ try:
143
+ client.login()
144
+ finally:
145
+ if previous_env is not None:
146
+ os.environ["GARMINTOKENS"] = previous_env
147
+ if normalized_home is not None:
148
+ client.client._tokenstore_path = normalized_home
149
+ _set_backend(client, normalized_home)
150
+ logger.debug("Garmin login succeeded for configured account")
151
+
152
+
153
+ def resume(garth_home: str) -> None:
154
+ """Restore an authenticated client from the configured Garmin home."""
155
+ ensure_secure_directory(garth_home)
156
+
157
+ if detect_legacy_tokens(garth_home) and not has_tokenstore(garth_home):
158
+ logger.debug(
159
+ "Legacy garth token files detected in %s; requiring a fresh login",
160
+ garth_home,
161
+ )
162
+ raise FileNotFoundError("Legacy garth session files are not resumable")
163
+
164
+ if not has_tokenstore(garth_home):
165
+ raise FileNotFoundError("No garmin_tokens.json found in the Garmin home directory")
166
+
167
+ client = Garmin()
168
+ _apply_timeout(client)
169
+ _serialize_refresh(client)
170
+ client.login(tokenstore=garth_home)
171
+ _set_backend(client, garth_home)
172
+ logger.debug("Garmin session resumed from %s", garth_home)
173
+
174
+
175
+ def save(garth_home: str) -> None:
176
+ """Persist the current backend tokenstore into the configured Garmin home."""
177
+ with _backend_lock:
178
+ client = _require_backend()
179
+ directory = ensure_secure_directory(garth_home, create=True)
180
+ client.client.dump(str(directory))
181
+ secure_token_file(str(directory))
182
+ _set_backend(client, str(directory))
183
+ logger.debug("Garmin session saved to %s", garth_home)
184
+
185
+
186
+ def connectapi(
187
+ path: str,
188
+ method: str = "GET",
189
+ *,
190
+ capability: str | None = None,
191
+ params: dict[str, Any] | None = None,
192
+ json: dict[str, Any] | list[Any] | None = None,
193
+ **kwargs: Any,
194
+ ) -> Any:
195
+ """Compatibility wrapper matching the old `garth.connectapi` surface."""
196
+ client = _require_backend()
197
+ method = method.upper()
198
+
199
+ if method == "GET":
200
+ return client.connectapi(path, params=params, **kwargs)
201
+
202
+ capability_name = capability or f"{method.lower()}:{path}"
203
+ logger.debug(
204
+ "Raw fallback used for capability=%s method=%s path=%s",
205
+ capability_name,
206
+ method,
207
+ path,
208
+ )
209
+ if method == "POST":
210
+ request_fn = client.client.post
211
+ elif method == "PUT":
212
+ request_fn = client.client.put
213
+ elif method == "DELETE":
214
+ request_fn = client.client.delete
215
+ else:
216
+ raise ValueError(f"Unsupported Garmin API method: {method}")
217
+
218
+ response = request_fn("connectapi", path, params=params, json=json, **kwargs)
219
+ if response is None:
220
+ return None
221
+ if hasattr(response, "status_code") and response.status_code == 204:
222
+ return None
223
+ if hasattr(response, "json"):
224
+ return response.json()
225
+ return response
226
+
227
+
228
+ def list_workouts(limit: int) -> list[dict[str, Any]]:
229
+ """Use the typed upstream workout listing helper."""
230
+ client = _require_backend()
231
+ return client.get_workouts(start=0, limit=limit)
232
+
233
+
234
+ def get_workout(workout_id: int | str) -> dict[str, Any]:
235
+ """Use the typed upstream single-workout helper."""
236
+ client = _require_backend()
237
+ return client.get_workout_by_id(workout_id)
238
+
239
+
240
+ def create_workout(payload: dict[str, Any]) -> dict[str, Any]:
241
+ """Use the typed upstream workout upload helper."""
242
+ client = _require_backend()
243
+ return client.upload_workout(payload)
244
+
245
+
246
+ def delete_workout(workout_id: int | str) -> Any:
247
+ """Use the typed upstream workout delete helper."""
248
+ client = _require_backend()
249
+ return client.delete_workout(workout_id)
250
+
251
+
252
+ def schedule_workout(workout_id: int | str, schedule_date: date | str) -> dict[str, Any]:
253
+ """Use the typed upstream workout schedule helper."""
254
+ client = _require_backend()
255
+ date_str = schedule_date if isinstance(schedule_date, str) else schedule_date.isoformat()
256
+ return client.schedule_workout(workout_id, date_str)
257
+
258
+
259
+ def get_activity_types() -> list[dict[str, Any]]:
260
+ """Use the typed upstream activity-types helper (sport typeKey/typeId table)."""
261
+ client = _require_backend()
262
+ return client.get_activity_types()
263
+
264
+
265
+ def set_activity_name(activity_id: int | str, name: str) -> Any:
266
+ """Use the typed upstream activity-rename helper (PUT title)."""
267
+ client = _require_backend()
268
+ return client.set_activity_name(str(activity_id), name)
269
+
270
+
271
+ def set_activity_type(
272
+ activity_id: int | str,
273
+ type_id: int,
274
+ type_key: str,
275
+ parent_type_id: int,
276
+ ) -> Any:
277
+ """Use the typed upstream activity-type helper (PUT activityTypeDTO)."""
278
+ client = _require_backend()
279
+ return client.set_activity_type(str(activity_id), type_id, type_key, parent_type_id)
280
+
281
+
282
+ def unschedule_workout(scheduled_workout_id: int | str) -> Any:
283
+ """Use the typed upstream unschedule helper (removes a calendar entry)."""
284
+ client = _require_backend()
285
+ return client.unschedule_workout(scheduled_workout_id)
286
+
287
+
288
+ def get_activity_typed_splits(activity_id: int | str) -> Any:
289
+ """Use the typed upstream typed-splits helper (per-pool-length swim data)."""
290
+ client = _require_backend()
291
+ return client.get_activity_typed_splits(activity_id)
292
+
293
+
294
+ def get_activity_details(activity_id: int | str) -> Any:
295
+ """Use the typed upstream activity-details helper (metric stream + descriptors)."""
296
+ client = _require_backend()
297
+ return client.get_activity_details(activity_id)
298
+
299
+
300
+ def get_activity_hr_in_timezones(activity_id: int | str) -> Any:
301
+ """Use the typed upstream HR-time-in-zone helper."""
302
+ client = _require_backend()
303
+ return client.get_activity_hr_in_timezones(activity_id)
304
+
305
+
306
+ def download_activity(activity_id: int | str, dl_fmt: Any) -> bytes:
307
+ """Use the typed upstream activity-download helper.
308
+
309
+ *dl_fmt* must be a ``Garmin.ActivityDownloadFormat`` enum member.
310
+ Returns raw bytes — caller is responsible for writing to disk.
311
+ """
312
+ client = _require_backend()
313
+ return client.download_activity(str(activity_id), dl_fmt=dl_fmt)
314
+
315
+
316
+ def upload_activity(activity_path: str) -> Any:
317
+ """Use the typed upstream activity-upload helper.
318
+
319
+ *activity_path* must exist on disk and have a FIT, GPX, or TCX extension.
320
+ Returns the upstream response payload (shape varies by file format).
321
+ """
322
+ client = _require_backend()
323
+ return client.upload_activity(activity_path)
324
+
325
+
326
+ def delete_activity(activity_id: int | str) -> Any:
327
+ """Use the typed upstream activity-delete helper."""
328
+ client = _require_backend()
329
+ return client.delete_activity(str(activity_id))
330
+
331
+
332
+ def get_heart_rates(cdate: str) -> dict[str, Any]:
333
+ """Use the typed upstream daily-heart-rate helper (displayName-scoped).
334
+
335
+ The bare ``/wellness-service/wellness/dailyHeartRate/{day}`` path now
336
+ 403s; upstream scopes the same endpoint under the account displayName.
337
+ """
338
+ client = _require_backend()
339
+ return client.get_heart_rates(cdate)
340
+
341
+
342
+ def get_race_predictions() -> dict[str, Any]:
343
+ """Use the typed upstream race-predictions helper (displayName-scoped, latest).
344
+
345
+ The bare ``/metrics-service/metrics/racepredictions`` path now 404s;
346
+ upstream scopes the same endpoint under the account displayName.
347
+ """
348
+ client = _require_backend()
349
+ return client.get_race_predictions()
350
+
351
+
352
+ def get_personal_records() -> Any:
353
+ """Use the typed upstream personal-records helper (displayName-scoped)."""
354
+ client = _require_backend()
355
+ return client.get_personal_record()