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.
- garmin_cli/__init__.py +7 -0
- garmin_cli/__main__.py +4 -0
- garmin_cli/_env.py +22 -0
- garmin_cli/auth.py +198 -0
- garmin_cli/backend.py +355 -0
- garmin_cli/cli.py +185 -0
- garmin_cli/commands/__init__.py +1 -0
- garmin_cli/commands/_options.py +89 -0
- garmin_cli/commands/activities.py +406 -0
- garmin_cli/commands/devices.py +27 -0
- garmin_cli/commands/health.py +347 -0
- garmin_cli/commands/login.py +124 -0
- garmin_cli/commands/performance.py +156 -0
- garmin_cli/commands/workouts.py +220 -0
- garmin_cli/config.py +34 -0
- garmin_cli/date_utils.py +115 -0
- garmin_cli/endpoints/__init__.py +1 -0
- garmin_cli/endpoints/_base.py +258 -0
- garmin_cli/endpoints/activities.py +344 -0
- garmin_cli/endpoints/devices.py +19 -0
- garmin_cli/endpoints/health.py +127 -0
- garmin_cli/endpoints/metrics.py +48 -0
- garmin_cli/endpoints/performance.py +138 -0
- garmin_cli/endpoints/workouts.py +150 -0
- garmin_cli/exceptions.py +30 -0
- garmin_cli/input_reader.py +67 -0
- garmin_cli/mcp_auth.py +43 -0
- garmin_cli/mcp_cli.py +258 -0
- garmin_cli/mcp_server.py +56 -0
- garmin_cli/mcp_tools/__init__.py +10 -0
- garmin_cli/mcp_tools/_shared.py +257 -0
- garmin_cli/mcp_tools/activities.py +161 -0
- garmin_cli/mcp_tools/activities_write.py +260 -0
- garmin_cli/mcp_tools/health.py +113 -0
- garmin_cli/mcp_tools/misc.py +164 -0
- garmin_cli/mcp_tools/performance.py +91 -0
- garmin_cli/mcp_tools/workouts.py +305 -0
- garmin_cli/metrics/__init__.py +42 -0
- garmin_cli/metrics/field_table.py +152 -0
- garmin_cli/metrics/registry.py +420 -0
- garmin_cli/metrics/sport_profile.py +247 -0
- garmin_cli/output.py +158 -0
- garmin_cli/serializers/__init__.py +238 -0
- garmin_cli/serializers/_common.py +133 -0
- garmin_cli/serializers/activities.py +611 -0
- garmin_cli/serializers/devices.py +32 -0
- garmin_cli/serializers/health.py +422 -0
- garmin_cli/serializers/performance.py +306 -0
- garmin_cli/serializers/workouts.py +155 -0
- garmin_cli/services/__init__.py +8 -0
- garmin_cli/services/activities.py +139 -0
- garmin_cli/services/performance.py +37 -0
- garmin_cli/token_store.py +93 -0
- garmin_cli/units.py +90 -0
- garmin_cli/workout_builder.py +267 -0
- garmin_cli/workout_schema.py +185 -0
- garmin_py-2.7.0.dist-info/METADATA +470 -0
- garmin_py-2.7.0.dist-info/RECORD +62 -0
- garmin_py-2.7.0.dist-info/WHEEL +5 -0
- garmin_py-2.7.0.dist-info/entry_points.txt +2 -0
- garmin_py-2.7.0.dist-info/licenses/LICENSE +21 -0
- 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
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()
|