From 2720927aa773d6a96662811bf9255817cf08dcf9 Mon Sep 17 00:00:00 2001 From: secstate Date: Fri, 11 Sep 2026 18:41:55 -0400 Subject: [PATCH] 0.3.11 with curl_cffi as optional tls extra (FreeBSD support) --- LICENSE | 21 + PKG-INFO | 558 +++++ README.md | 506 ++++ garminconnect/__init__.py | 3743 +++++++++++++++++++++++++++++ garminconnect/activity_details.py | 44 + garminconnect/client.py | 1734 +++++++++++++ garminconnect/exceptions.py | 33 + garminconnect/exercises.py | 2635 ++++++++++++++++++++ garminconnect/fit.py | 518 ++++ garminconnect/typed.py | 593 +++++ garminconnect/workout.py | 573 +++++ pyproject.toml | 357 +++ 12 files changed, 11315 insertions(+) create mode 100644 LICENSE create mode 100644 PKG-INFO create mode 100644 README.md create mode 100644 garminconnect/__init__.py create mode 100644 garminconnect/activity_details.py create mode 100644 garminconnect/client.py create mode 100644 garminconnect/exceptions.py create mode 100644 garminconnect/exercises.py create mode 100644 garminconnect/fit.py create mode 100644 garminconnect/typed.py create mode 100644 garminconnect/workout.py create mode 100644 pyproject.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..23fe6af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2026 Ron Klinkien + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..a06c96f --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,558 @@ +Metadata-Version: 2.1 +Name: garminconnect +Version: 0.3.11 +Summary: Python 3 API wrapper for Garmin Connect +Keywords: garmin connect,api,garmin +Author-Email: Ron Klinkien +License: MIT +Classifier: Development Status :: 5 - Production/Stable +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: MacOS :: MacOS X +Classifier: Operating System :: Microsoft :: Windows +Classifier: Operating System :: POSIX :: Linux +Classifier: Operating System :: OS Independent +Project-URL: Homepage, https://github.com/cyberjunky/python-garminconnect +Project-URL: Issues, https://github.com/cyberjunky/python-garminconnect/issues +Project-URL: Changelog, https://github.com/cyberjunky/python-garminconnect/releases +Requires-Python: >=3.12 +Requires-Dist: curl_cffi>=0.15.0 +Requires-Dist: requests>=2.33.0 +Requires-Dist: ua-generator>=1.0 +Provides-Extra: dev +Requires-Dist: ipython; extra == "dev" +Requires-Dist: ipdb; extra == "dev" +Requires-Dist: ipykernel; extra == "dev" +Requires-Dist: pandas; extra == "dev" +Requires-Dist: matplotlib; extra == "dev" +Provides-Extra: workout +Requires-Dist: pydantic>=2.4.0; extra == "workout" +Provides-Extra: typed +Requires-Dist: pydantic>=2.4.0; extra == "typed" +Provides-Extra: security +Requires-Dist: bandit[toml]; extra == "security" +Requires-Dist: pip-audit; extra == "security" +Provides-Extra: linting +Requires-Dist: black[jupyter]; extra == "linting" +Requires-Dist: ruff; extra == "linting" +Requires-Dist: mypy; extra == "linting" +Requires-Dist: isort; extra == "linting" +Requires-Dist: types-requests; extra == "linting" +Requires-Dist: pre-commit; extra == "linting" +Requires-Dist: codespell; extra == "linting" +Provides-Extra: testing +Requires-Dist: coverage; extra == "testing" +Requires-Dist: pytest; extra == "testing" +Requires-Dist: pytest-vcr>=1.0.2; extra == "testing" +Requires-Dist: vcrpy>=7.0.0; extra == "testing" +Provides-Extra: example +Requires-Dist: readchar; extra == "example" +Description-Content-Type: text/markdown + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) +![Project Maintenance][maintenance-shield] + +[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/) +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky) + +# Python: Garmin Connect + +The Garmin Connect API library comes with two examples: + +- **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls +- **`demo.py`** - Comprehensive demo providing access to **130+ API methods** organized into **13 categories** for easy navigation + +```bash +$ ./demo.py +๐Ÿ“ Exported data will be saved to the directory: 'your_data' +๐Ÿ“„ All API responses are written to: 'response.json' +Attempting to login using stored tokens from: ~/.garminconnect +Successfully logged in using stored tokens! + +๐Ÿ“Š Your Stats Today: 4,045 steps | 1445.0 kcal +๐ŸŒ Time to get those legs moving! + +================================================== +๐Ÿšด Full-blown Garmin Connect API Demo - Main Menu +================================================== +Select a category: + + [1] ๐Ÿ‘ค User & Profile + [2] ๐Ÿ“Š Daily Health & Activity + [3] ๐Ÿ”ฌ Advanced Health Metrics + [4] ๐Ÿ“ˆ Historical Data & Trends + [5] ๐Ÿƒ Activities & Workouts + [6] โš–๏ธ Body Composition & Weight + [7] ๐Ÿ† Goals & Achievements + [8] โŒš Device & Technical + [9] ๐ŸŽฝ Gear & Equipment + [0] ๐Ÿ’ง Hydration & Wellness + [a] ๐Ÿ”ง System & Export + [b] ๐Ÿ“… Training Plans + [c] โ›ณ Golf + [d] โœ๏ธ Activity Editing + + [q] Exit program + +Make your selection: +``` + +## API Coverage Statistics + +- **Total API Methods**: 144+ unique endpoints (snapshot) +- **Categories**: 13 organized sections +- **User & Profile**: 4 methods (basic user info, settings) +- **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges) +- **Advanced Health Metrics**: 16 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance) +- **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates) +- **Activities & Workouts**: 41 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, edit description / exercise sets) +- **Body Composition & Weight**: 8 methods (weight tracking, body composition) +- **Goals & Achievements**: 15 methods (challenges, badges, goals) +- **Device & Technical**: 7 methods (device info, settings) +- **Gear & Equipment**: 7 methods (gear management, tracking) +- **Hydration & Wellness**: 12 methods (hydration, nutrition, blood pressure, menstrual) +- **System & Export**: 5 methods (reporting, logout, GraphQL, health snapshot download) +- **Training Plans**: 3 methods (plans, plan by ID, adaptive plan by ID) +- **Golf**: 5 methods (scorecard summary, scorecard detail, shot data, club stats, user stats) + +### Interactive Features + +- **Enhanced User Experience**: Categorized navigation with emoji indicators +- **Smart Data Management**: Interactive weigh-in deletion with search capabilities +- **Comprehensive Coverage**: All major Garmin Connect features are accessible +- **Error Handling**: Robust error handling with user-friendly prompts +- **Data Export**: JSON export functionality for all data types + +[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/) +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky) + +A comprehensive Python3 API wrapper for Garmin Connect, providing access to health, fitness, and device data. + +## ๐Ÿ“– About + +This library enables developers to programmatically access Garmin Connect data including: + +- **Health Metrics**: Heart rate, sleep, stress, body composition, SpO2, HRV +- **Activity Data**: Workouts, typed workout uploads (running, cycling, swimming, walking, hiking, strength), workout scheduling, exercises, training status, performance metrics, import-style uploads (no Strava re-export) +- **Nutrition**: Daily food logs, meals, and nutrition settings +- **Golf**: Scorecard summaries, scorecard details, shot-by-shot data +- **Device Information**: Connected devices, settings, alarms, solar data +- **Goals & Achievements**: Personal records, badges, challenges, race predictions +- **Historical Data**: Trends, progress tracking, date range queries + +Compatible with all Garmin Connect accounts. See + +## ๐Ÿ“ฆ Installation + +Requires Python 3.12 or later. + +Install from PyPI: + +```bash +pip install --upgrade garminconnect curl_cffi +``` + +## Run demo software (recommended) + +Clone the repo, then: + +```bash +python3 -m venv .venv --copies +source .venv/bin/activate # On Windows: .venv\Scripts\activate +pip install -e ".[example]" + +python3 ./example.py # simple getting-started example +python3 ./demo.py # comprehensive demo (130+ API methods) +``` + +## ๐Ÿ› ๏ธ Development + +This project uses [PDM](https://pdm.fming.dev/) for dependency management and task automation. + +> **โš ๏ธ Important**: Create a virtual environment first on externally-managed Python installs (Debian/Ubuntu) to avoid system package conflicts. + +```bash +python3 -m venv .venv --copies +source .venv/bin/activate # On Windows: .venv\Scripts\activate +pip install pdm +python3 -m pdm install --group :all +pre-commit install --install-hooks # optional but recommended +``` + +> **Note**: Using `python -m pdm` instead of `pdm` avoids PATH issues on some +> Windows setups where `pip install pdm` places the `pdm` executable outside +> the directories on `PATH`. Once `pdm install` has run, subsequent `pdm run ...` +> commands work normally because the venv's `Scripts/` directory is on `PATH` +> while the venv is active. + +**Development commands:** + +```bash +pdm run format # Auto-format code (isort, black, ruff --fix) +pdm run lint # Check code quality (isort, ruff, black, mypy) +pdm run codespell # Check spelling +pdm run test # Run test suite +pdm run testcov # Run tests with coverage report +pdm run all # Run all checks (lint + codespell + pre-commit + test) +pdm run clean # Clean build artifacts and cache files +pdm run build # Build package for distribution +pdm run publish # Build and publish to PyPI +pdm run --list # Show all available commands +``` + +Run `pdm run format && pdm run lint && pdm run test` before submitting PRs. + +## ๐Ÿ” Authentication + +Authentication uses the same mobile SSO flow as the official Garmin Connect Android app. +No browser is needed. + +**How it works:** + +1. **First login**: Authenticates via `sso.garmin.com/mobile/api/login` using the Android + app's client ID. If MFA is required, a callback (`prompt_mfa`) prompts for the one-time code. +2. **Token exchange**: The service ticket is exchanged for DI OAuth Bearer tokens + (`access_token` + `refresh_token`) via `diauth.garmin.com`. Tokens are stored at + `~/.garminconnect/garmin_tokens.json`. +3. **Auto-refresh**: Before each API request the library checks whether the DI token is about + to expire and refreshes it automatically โ€” no user interaction required. + +**Session lifetime:** +- DI tokens auto-refresh indefinitely as long as the refresh token remains valid. +- A full re-login with credentials (and possibly MFA) is only needed if the refresh token + itself expires or is revoked. + +**Token storage:** +```bash +~/.garminconnect/garmin_tokens.json # saved automatically, mode 0600 +``` + +The containing directory is restricted to mode `0700`. Treat the token file +like a password: the refresh token can provide persistent account access. Avoid +putting a Garmin password in shell history or a long-lived environment variable; +prefer `getpass()` or another interactive secret prompt. + +**Resilient login (multi-strategy + token validation):** + +`login()` tries several authentication strategies in order (mobile, SSO widget, +web portal โ€” each with and without TLS impersonation) and only declares success +when the resulting token is actually accepted by the API. If a strategy obtains +a token the API later rejects (a region/account-specific condition โ€” see +[#369](https://github.com/cyberjunky/python-garminconnect/issues/369)), the +library transparently falls through to the next strategy. Set +`Garmin(..., verify_login=False)` to restore the legacy "first token wins" +behavior. + +**Cached-token gotcha & self-healing:** when a `tokenstore` is supplied, +`login()` loads those tokens *before* the strategy chain and short-circuits if +they load โ€” so stale/poisoned cached tokens used to fail every run. The library +now detects this: if cached tokens are rejected by the API, it discards them and +performs a fresh credential login automatically. To force a clean slate yourself +(e.g. between a failed resume and a retry), call: + +```python +g.logout() # clears in-memory auth + cached tokens (uses GARMINTOKENS) +g.logout(tokenstore) # or pass an explicit path +``` + +`logout()` removes only the local `garmin_tokens.json` file and preserves its +directory and unrelated files. It does **not** revoke a token that has already +been issued by Garmin. Revoke account access from Garmin's account/security +settings if a token may have been copied or exposed. + +### What running it locally does + +This is an unofficial client for Garmin's web services; it does not pair with +the Garmin Connect phone app or connect directly to a watch. When you call +`login()`, your credentials and MFA code are sent over HTTPS to Garmin's login +service. The library receives an access/refresh token pair and, when a token +store is supplied, caches it locally for later sessions. Subsequent API methods +send that token to Garmin and can read or change the same account data that the +selected method targets. + +The library does not automatically download an entire account. The demo writes +responses, activity downloads, and health reports only when you select those +actions. Demo exports are stored under `your_data/` with owner-only directory +and file permissions. Run the project in a dedicated virtual environment, read +the method you plan to call, and start with read-only methods. Upload, edit, +delete, schedule, hydration, and weigh-in methods can change Garmin account data. + +## ๐Ÿงช Testing + +The default suite is credential-free and excludes live-account integration tests: + +```bash +pdm run test # Run all tests +pdm run testcov # Run tests with coverage report +``` + +To explicitly run live integration tests, use a test account if possible. This +can create local VCR recordings and includes methods that may mutate the account: + +```bash +export GARMIN_EMAIL="you@example.com" +read -s GARMIN_PASSWORD && export GARMIN_PASSWORD +pdm run pytest -m integration --vcr-record=once +unset GARMIN_PASSWORD +``` + +VCR recordings are ignored by Git because Garmin responses contain sensitive +health, activity, location, and account data. Do not commit them. + +## ๐Ÿ“ฆ Publishing + +For package maintainers: + +**Setup PyPI credentials:** + +```bash +pip install twine +# Edit with your preferred editor, or create via here-doc: +# cat > ~/.pypirc <<'EOF' +# [pypi] +# username = __token__ +# password = +# EOF +``` + +```ini +[pypi] +username = __token__ +password = +``` + +Recommended: use environment variables and restrict file perms + +```bash +chmod 600 ~/.pypirc +export TWINE_USERNAME="__token__" +export TWINE_PASSWORD="" +``` + +**Publish new version:** + +```bash +pdm run publish # Build and publish to PyPI +``` + +**Alternative publishing steps:** + +```bash +pdm run build # Build package only +pdm publish # Publish pre-built package +``` + +## ๐Ÿค Contributing + +We welcome contributions! Here's how you can help: + +- **Report Issues**: Bug reports and feature requests via GitHub issues +- **Submit PRs**: Code improvements, new features, documentation updates +- **Testing**: Help test new features and report compatibility issues +- **Documentation**: Improve examples, add use cases, fix typos + +**Before contributing:** +1. Set up your dev environment (see [Development](#๏ธ-development) above) +2. Format and lint: `pdm run format && pdm run lint` +3. Run tests: `pdm run test` +4. Follow existing code style and patterns + +### Jupyter Notebook + +Explore the API interactively with our [reference notebook](https://github.com/cyberjunky/python-garminconnect/blob/master/docs/reference.ipynb). + +### Python Code Examples + +```python +import os +from getpass import getpass +from datetime import date +from garminconnect import Garmin + +# First run: logs in and saves tokens to ~/.garminconnect +# Subsequent runs: loads saved tokens and auto-refreshes +client = Garmin( + os.getenv("GARMIN_EMAIL"), + getpass("Garmin password: "), + prompt_mfa=lambda: input("MFA code: "), +) +client.login("~/.garminconnect") + +# Get today's stats +today = date.today().isoformat() +stats = client.get_stats(today) + +# Get heart rate data +hr_data = client.get_heart_rates(today) +print(f"Resting HR: {hr_data.get('restingHeartRate', 'n/a')}") +``` + +### Typed Workouts (Pydantic Models) + +The library includes optional typed workout models for creating type-safe workout definitions: + +```bash +pip install garminconnect[workout] +``` + +```python +from garminconnect.workout import ( + RunningWorkout, + WorkoutSegment, + create_warmup_step, + create_interval_step, + create_distance_interval_step, + create_cooldown_step, + create_repeat_group, +) + +# Create a structured running workout +workout = RunningWorkout( + workoutName="Easy Run", + estimatedDurationInSecs=1800, + workoutSegments=[ + WorkoutSegment( + segmentOrder=1, + sportType={"sportTypeId": 1, "sportTypeKey": "running"}, + workoutSteps=[create_warmup_step(300.0)], + ) + ], +) + +# Upload and optionally schedule it +result = client.upload_running_workout(workout) +client.schedule_workout(result["workoutId"], "2026-03-20") + +# Edit it in place - keeps its id, so any schedules pointing at it stay valid +workout_data = client.get_workout_by_id(result["workoutId"]) +workout_data["workoutName"] = "Easy Run (revised)" +client.update_workout(result["workoutId"], workout_data) + +# Delete a workout or remove it from the calendar +client.delete_workout(workout_id) +client.unschedule_workout(scheduled_workout_id) + +# Push a workout to a device - defaults to the last workout / last used device +client.push_workout_to_device(result["workoutId"], device_id) +``` + +**Available workout classes:** `RunningWorkout`, `CyclingWorkout`, `SwimmingWorkout`, `WalkingWorkout`, `HikingWorkout`, `StrengthWorkout`, `MultiSportWorkout`, `FitnessEquipmentWorkout` + +**Strength workouts** are rep-based. Build each exercise with `create_strength_set(category, step_order, sets, reps, rest_seconds, exercise_name="", weight_kg=None)` and identify exercises with a `category` / `exercise` pair from the bundled catalog in `garminconnect.exercises` (1,527 exercises across 47 categories, with `resolve(name)` and `find(term)` helpers): + +```python +from garminconnect import exercises +from garminconnect.workout import StrengthWorkout, WorkoutSegment, create_strength_set + +lat = exercises.resolve( + "Lat Pull-down" +) # {'category': 'PULL_UP', 'exercise': 'LAT_PULLDOWN'} + +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 + ), + create_strength_set( + lat["category"], + step_order=4, + sets=3, + reps=12, + rest_seconds=90, + exercise_name=lat["exercise"], + ), + ], + ) + ], +) +client.upload_strength_workout(workout) +``` + +**Helper functions:** `create_warmup_step`, `create_interval_step`, `create_distance_interval_step`, `create_recovery_step`, `create_cooldown_step`, `create_repeat_group`, `create_strength_exercise_step`, `create_strength_rest_step`, `create_strength_set` + +Use `create_distance_interval_step(600.0, step_order=1)` for interval steps that should end after a distance in meters instead of after a duration. + +### Golf + +The library supports reading Garmin Golf scorecards, shot-level data, club statistics, and golf user statistics. + +```python +# List recent golf scorecards +summary = client.get_golf_summary(limit=10) +scorecard_id = summary["scorecardSummaries"][0]["id"] + +# Get the full scorecard details +scorecard = client.get_golf_scorecard(scorecard_id) + +# Get shot-by-shot data +# - Omit hole_numbers to receive every hole (recommended for holes 10-18). +# - Single-digit holes work as a comma- or dash-separated list, e.g. "1,2,3". +# Commas are normalized to dashes because Garmin only accepts '-' as the +# separator. +# - Double-digit holes (10-18) cannot be fetched with a filter at all; the +# API either drops them or returns an empty response. If you request holes +# 10-18, the library falls back to returning all 18 holes. +shots = client.get_golf_shot_data(scorecard_id) +shots = client.get_golf_shot_data(scorecard_id, hole_numbers="1,2,3") + +# Club and player statistics +club_stats = client.get_golf_club_stats() +user_stats = client.get_golf_user_stats() +``` + +### Additional Resources +- **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide +- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 130+ API methods +- **API Documentation**: Comprehensive method documentation in source code +- **Test Cases**: Real-world usage examples in `tests/` directory + +## ๐Ÿ™ Acknowledgments + +Special thanks to all contributors who have helped improve this project: + +- **Community Contributors**: Bug reports, feature requests, and code improvements +- **Issue Reporters**: Helping identify and resolve compatibility issues +- **Feature Developers**: Adding new API endpoints and functionality +- **Documentation Authors**: Improving examples and user guides + +This project thrives thanks to community involvement and feedback. + +## ๐Ÿ’– Support This Project + +If you find this library useful for your projects, please consider supporting its continued development and maintenance: + +### ๐ŸŒŸ Ways to Support + +- **โญ Star this repository** - Help others discover the project +- **๐Ÿ’ฐ Financial Support** - Contribute to development and hosting costs +- **๐Ÿ› Report Issues** - Help improve stability and compatibility +- **๐Ÿ“– Spread the Word** - Share with other developers + +### ๐Ÿ’ณ Financial Support Options + +[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/) +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky) + +**Why Support?** +- Keeps the project actively maintained +- Enables faster bug fixes and new features +- Supports infrastructure costs (testing, AI, CI/CD) +- Shows appreciation for hundreds of hours of development + +Every contribution, no matter the size, makes a difference and is greatly appreciated! ๐Ÿ™ + +[releases-shield]: https://img.shields.io/github/release/cyberjunky/python-garminconnect.svg?style=for-the-badge +[releases]: https://github.com/cyberjunky/python-garminconnect/releases +[commits-shield]: https://img.shields.io/github/commit-activity/y/cyberjunky/python-garminconnect.svg?style=for-the-badge +[commits]: https://github.com/cyberjunky/python-garminconnect/commits/master +[license-shield]: https://img.shields.io/github/license/cyberjunky/python-garminconnect.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-cyberjunky-blue.svg?style=for-the-badge diff --git a/README.md b/README.md new file mode 100644 index 0000000..22db7e0 --- /dev/null +++ b/README.md @@ -0,0 +1,506 @@ +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) +![Project Maintenance][maintenance-shield] + +[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/) +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky) + +# Python: Garmin Connect + +The Garmin Connect API library comes with two examples: + +- **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls +- **`demo.py`** - Comprehensive demo providing access to **130+ API methods** organized into **13 categories** for easy navigation + +```bash +$ ./demo.py +๐Ÿ“ Exported data will be saved to the directory: 'your_data' +๐Ÿ“„ All API responses are written to: 'response.json' +Attempting to login using stored tokens from: ~/.garminconnect +Successfully logged in using stored tokens! + +๐Ÿ“Š Your Stats Today: 4,045 steps | 1445.0 kcal +๐ŸŒ Time to get those legs moving! + +================================================== +๐Ÿšด Full-blown Garmin Connect API Demo - Main Menu +================================================== +Select a category: + + [1] ๐Ÿ‘ค User & Profile + [2] ๐Ÿ“Š Daily Health & Activity + [3] ๐Ÿ”ฌ Advanced Health Metrics + [4] ๐Ÿ“ˆ Historical Data & Trends + [5] ๐Ÿƒ Activities & Workouts + [6] โš–๏ธ Body Composition & Weight + [7] ๐Ÿ† Goals & Achievements + [8] โŒš Device & Technical + [9] ๐ŸŽฝ Gear & Equipment + [0] ๐Ÿ’ง Hydration & Wellness + [a] ๐Ÿ”ง System & Export + [b] ๐Ÿ“… Training Plans + [c] โ›ณ Golf + [d] โœ๏ธ Activity Editing + + [q] Exit program + +Make your selection: +``` + +## API Coverage Statistics + +- **Total API Methods**: 144+ unique endpoints (snapshot) +- **Categories**: 13 organized sections +- **User & Profile**: 4 methods (basic user info, settings) +- **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges) +- **Advanced Health Metrics**: 16 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance) +- **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates) +- **Activities & Workouts**: 41 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, edit description / exercise sets) +- **Body Composition & Weight**: 8 methods (weight tracking, body composition) +- **Goals & Achievements**: 15 methods (challenges, badges, goals) +- **Device & Technical**: 7 methods (device info, settings) +- **Gear & Equipment**: 7 methods (gear management, tracking) +- **Hydration & Wellness**: 12 methods (hydration, nutrition, blood pressure, menstrual) +- **System & Export**: 5 methods (reporting, logout, GraphQL, health snapshot download) +- **Training Plans**: 3 methods (plans, plan by ID, adaptive plan by ID) +- **Golf**: 5 methods (scorecard summary, scorecard detail, shot data, club stats, user stats) + +### Interactive Features + +- **Enhanced User Experience**: Categorized navigation with emoji indicators +- **Smart Data Management**: Interactive weigh-in deletion with search capabilities +- **Comprehensive Coverage**: All major Garmin Connect features are accessible +- **Error Handling**: Robust error handling with user-friendly prompts +- **Data Export**: JSON export functionality for all data types + +[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/) +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky) + +A comprehensive Python3 API wrapper for Garmin Connect, providing access to health, fitness, and device data. + +## ๐Ÿ“– About + +This library enables developers to programmatically access Garmin Connect data including: + +- **Health Metrics**: Heart rate, sleep, stress, body composition, SpO2, HRV +- **Activity Data**: Workouts, typed workout uploads (running, cycling, swimming, walking, hiking, strength), workout scheduling, exercises, training status, performance metrics, import-style uploads (no Strava re-export) +- **Nutrition**: Daily food logs, meals, and nutrition settings +- **Golf**: Scorecard summaries, scorecard details, shot-by-shot data +- **Device Information**: Connected devices, settings, alarms, solar data +- **Goals & Achievements**: Personal records, badges, challenges, race predictions +- **Historical Data**: Trends, progress tracking, date range queries + +Compatible with all Garmin Connect accounts. See + +## ๐Ÿ“ฆ Installation + +Requires Python 3.12 or later. + +Install from PyPI: + +```bash +pip install --upgrade garminconnect curl_cffi +``` + +## Run demo software (recommended) + +Clone the repo, then: + +```bash +python3 -m venv .venv --copies +source .venv/bin/activate # On Windows: .venv\Scripts\activate +pip install -e ".[example]" + +python3 ./example.py # simple getting-started example +python3 ./demo.py # comprehensive demo (130+ API methods) +``` + +## ๐Ÿ› ๏ธ Development + +This project uses [PDM](https://pdm.fming.dev/) for dependency management and task automation. + +> **โš ๏ธ Important**: Create a virtual environment first on externally-managed Python installs (Debian/Ubuntu) to avoid system package conflicts. + +```bash +python3 -m venv .venv --copies +source .venv/bin/activate # On Windows: .venv\Scripts\activate +pip install pdm +python3 -m pdm install --group :all +pre-commit install --install-hooks # optional but recommended +``` + +> **Note**: Using `python -m pdm` instead of `pdm` avoids PATH issues on some +> Windows setups where `pip install pdm` places the `pdm` executable outside +> the directories on `PATH`. Once `pdm install` has run, subsequent `pdm run ...` +> commands work normally because the venv's `Scripts/` directory is on `PATH` +> while the venv is active. + +**Development commands:** + +```bash +pdm run format # Auto-format code (isort, black, ruff --fix) +pdm run lint # Check code quality (isort, ruff, black, mypy) +pdm run codespell # Check spelling +pdm run test # Run test suite +pdm run testcov # Run tests with coverage report +pdm run all # Run all checks (lint + codespell + pre-commit + test) +pdm run clean # Clean build artifacts and cache files +pdm run build # Build package for distribution +pdm run publish # Build and publish to PyPI +pdm run --list # Show all available commands +``` + +Run `pdm run format && pdm run lint && pdm run test` before submitting PRs. + +## ๐Ÿ” Authentication + +Authentication uses the same mobile SSO flow as the official Garmin Connect Android app. +No browser is needed. + +**How it works:** + +1. **First login**: Authenticates via `sso.garmin.com/mobile/api/login` using the Android + app's client ID. If MFA is required, a callback (`prompt_mfa`) prompts for the one-time code. +2. **Token exchange**: The service ticket is exchanged for DI OAuth Bearer tokens + (`access_token` + `refresh_token`) via `diauth.garmin.com`. Tokens are stored at + `~/.garminconnect/garmin_tokens.json`. +3. **Auto-refresh**: Before each API request the library checks whether the DI token is about + to expire and refreshes it automatically โ€” no user interaction required. + +**Session lifetime:** +- DI tokens auto-refresh indefinitely as long as the refresh token remains valid. +- A full re-login with credentials (and possibly MFA) is only needed if the refresh token + itself expires or is revoked. + +**Token storage:** +```bash +~/.garminconnect/garmin_tokens.json # saved automatically, mode 0600 +``` + +The containing directory is restricted to mode `0700`. Treat the token file +like a password: the refresh token can provide persistent account access. Avoid +putting a Garmin password in shell history or a long-lived environment variable; +prefer `getpass()` or another interactive secret prompt. + +**Resilient login (multi-strategy + token validation):** + +`login()` tries several authentication strategies in order (mobile, SSO widget, +web portal โ€” each with and without TLS impersonation) and only declares success +when the resulting token is actually accepted by the API. If a strategy obtains +a token the API later rejects (a region/account-specific condition โ€” see +[#369](https://github.com/cyberjunky/python-garminconnect/issues/369)), the +library transparently falls through to the next strategy. Set +`Garmin(..., verify_login=False)` to restore the legacy "first token wins" +behavior. + +**Cached-token gotcha & self-healing:** when a `tokenstore` is supplied, +`login()` loads those tokens *before* the strategy chain and short-circuits if +they load โ€” so stale/poisoned cached tokens used to fail every run. The library +now detects this: if cached tokens are rejected by the API, it discards them and +performs a fresh credential login automatically. To force a clean slate yourself +(e.g. between a failed resume and a retry), call: + +```python +g.logout() # clears in-memory auth + cached tokens (uses GARMINTOKENS) +g.logout(tokenstore) # or pass an explicit path +``` + +`logout()` removes only the local `garmin_tokens.json` file and preserves its +directory and unrelated files. It does **not** revoke a token that has already +been issued by Garmin. Revoke account access from Garmin's account/security +settings if a token may have been copied or exposed. + +### What running it locally does + +This is an unofficial client for Garmin's web services; it does not pair with +the Garmin Connect phone app or connect directly to a watch. When you call +`login()`, your credentials and MFA code are sent over HTTPS to Garmin's login +service. The library receives an access/refresh token pair and, when a token +store is supplied, caches it locally for later sessions. Subsequent API methods +send that token to Garmin and can read or change the same account data that the +selected method targets. + +The library does not automatically download an entire account. The demo writes +responses, activity downloads, and health reports only when you select those +actions. Demo exports are stored under `your_data/` with owner-only directory +and file permissions. Run the project in a dedicated virtual environment, read +the method you plan to call, and start with read-only methods. Upload, edit, +delete, schedule, hydration, and weigh-in methods can change Garmin account data. + +## ๐Ÿงช Testing + +The default suite is credential-free and excludes live-account integration tests: + +```bash +pdm run test # Run all tests +pdm run testcov # Run tests with coverage report +``` + +To explicitly run live integration tests, use a test account if possible. This +can create local VCR recordings and includes methods that may mutate the account: + +```bash +export GARMIN_EMAIL="you@example.com" +read -s GARMIN_PASSWORD && export GARMIN_PASSWORD +pdm run pytest -m integration --vcr-record=once +unset GARMIN_PASSWORD +``` + +VCR recordings are ignored by Git because Garmin responses contain sensitive +health, activity, location, and account data. Do not commit them. + +## ๐Ÿ“ฆ Publishing + +For package maintainers: + +**Setup PyPI credentials:** + +```bash +pip install twine +# Edit with your preferred editor, or create via here-doc: +# cat > ~/.pypirc <<'EOF' +# [pypi] +# username = __token__ +# password = +# EOF +``` + +```ini +[pypi] +username = __token__ +password = +``` + +Recommended: use environment variables and restrict file perms + +```bash +chmod 600 ~/.pypirc +export TWINE_USERNAME="__token__" +export TWINE_PASSWORD="" +``` + +**Publish new version:** + +```bash +pdm run publish # Build and publish to PyPI +``` + +**Alternative publishing steps:** + +```bash +pdm run build # Build package only +pdm publish # Publish pre-built package +``` + +## ๐Ÿค Contributing + +We welcome contributions! Here's how you can help: + +- **Report Issues**: Bug reports and feature requests via GitHub issues +- **Submit PRs**: Code improvements, new features, documentation updates +- **Testing**: Help test new features and report compatibility issues +- **Documentation**: Improve examples, add use cases, fix typos + +**Before contributing:** +1. Set up your dev environment (see [Development](#๏ธ-development) above) +2. Format and lint: `pdm run format && pdm run lint` +3. Run tests: `pdm run test` +4. Follow existing code style and patterns + +### Jupyter Notebook + +Explore the API interactively with our [reference notebook](https://github.com/cyberjunky/python-garminconnect/blob/master/docs/reference.ipynb). + +### Python Code Examples + +```python +import os +from getpass import getpass +from datetime import date +from garminconnect import Garmin + +# First run: logs in and saves tokens to ~/.garminconnect +# Subsequent runs: loads saved tokens and auto-refreshes +client = Garmin( + os.getenv("GARMIN_EMAIL"), + getpass("Garmin password: "), + prompt_mfa=lambda: input("MFA code: "), +) +client.login("~/.garminconnect") + +# Get today's stats +today = date.today().isoformat() +stats = client.get_stats(today) + +# Get heart rate data +hr_data = client.get_heart_rates(today) +print(f"Resting HR: {hr_data.get('restingHeartRate', 'n/a')}") +``` + +### Typed Workouts (Pydantic Models) + +The library includes optional typed workout models for creating type-safe workout definitions: + +```bash +pip install garminconnect[workout] +``` + +```python +from garminconnect.workout import ( + RunningWorkout, + WorkoutSegment, + create_warmup_step, + create_interval_step, + create_distance_interval_step, + create_cooldown_step, + create_repeat_group, +) + +# Create a structured running workout +workout = RunningWorkout( + workoutName="Easy Run", + estimatedDurationInSecs=1800, + workoutSegments=[ + WorkoutSegment( + segmentOrder=1, + sportType={"sportTypeId": 1, "sportTypeKey": "running"}, + workoutSteps=[create_warmup_step(300.0)], + ) + ], +) + +# Upload and optionally schedule it +result = client.upload_running_workout(workout) +client.schedule_workout(result["workoutId"], "2026-03-20") + +# Edit it in place - keeps its id, so any schedules pointing at it stay valid +workout_data = client.get_workout_by_id(result["workoutId"]) +workout_data["workoutName"] = "Easy Run (revised)" +client.update_workout(result["workoutId"], workout_data) + +# Delete a workout or remove it from the calendar +client.delete_workout(workout_id) +client.unschedule_workout(scheduled_workout_id) + +# Push a workout to a device - defaults to the last workout / last used device +client.push_workout_to_device(result["workoutId"], device_id) +``` + +**Available workout classes:** `RunningWorkout`, `CyclingWorkout`, `SwimmingWorkout`, `WalkingWorkout`, `HikingWorkout`, `StrengthWorkout`, `MultiSportWorkout`, `FitnessEquipmentWorkout` + +**Strength workouts** are rep-based. Build each exercise with `create_strength_set(category, step_order, sets, reps, rest_seconds, exercise_name="", weight_kg=None)` and identify exercises with a `category` / `exercise` pair from the bundled catalog in `garminconnect.exercises` (1,527 exercises across 47 categories, with `resolve(name)` and `find(term)` helpers): + +```python +from garminconnect import exercises +from garminconnect.workout import StrengthWorkout, WorkoutSegment, create_strength_set + +lat = exercises.resolve( + "Lat Pull-down" +) # {'category': 'PULL_UP', 'exercise': 'LAT_PULLDOWN'} + +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 + ), + create_strength_set( + lat["category"], + step_order=4, + sets=3, + reps=12, + rest_seconds=90, + exercise_name=lat["exercise"], + ), + ], + ) + ], +) +client.upload_strength_workout(workout) +``` + +**Helper functions:** `create_warmup_step`, `create_interval_step`, `create_distance_interval_step`, `create_recovery_step`, `create_cooldown_step`, `create_repeat_group`, `create_strength_exercise_step`, `create_strength_rest_step`, `create_strength_set` + +Use `create_distance_interval_step(600.0, step_order=1)` for interval steps that should end after a distance in meters instead of after a duration. + +### Golf + +The library supports reading Garmin Golf scorecards, shot-level data, club statistics, and golf user statistics. + +```python +# List recent golf scorecards +summary = client.get_golf_summary(limit=10) +scorecard_id = summary["scorecardSummaries"][0]["id"] + +# Get the full scorecard details +scorecard = client.get_golf_scorecard(scorecard_id) + +# Get shot-by-shot data +# - Omit hole_numbers to receive every hole (recommended for holes 10-18). +# - Single-digit holes work as a comma- or dash-separated list, e.g. "1,2,3". +# Commas are normalized to dashes because Garmin only accepts '-' as the +# separator. +# - Double-digit holes (10-18) cannot be fetched with a filter at all; the +# API either drops them or returns an empty response. If you request holes +# 10-18, the library falls back to returning all 18 holes. +shots = client.get_golf_shot_data(scorecard_id) +shots = client.get_golf_shot_data(scorecard_id, hole_numbers="1,2,3") + +# Club and player statistics +club_stats = client.get_golf_club_stats() +user_stats = client.get_golf_user_stats() +``` + +### Additional Resources +- **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide +- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 130+ API methods +- **API Documentation**: Comprehensive method documentation in source code +- **Test Cases**: Real-world usage examples in `tests/` directory + +## ๐Ÿ™ Acknowledgments + +Special thanks to all contributors who have helped improve this project: + +- **Community Contributors**: Bug reports, feature requests, and code improvements +- **Issue Reporters**: Helping identify and resolve compatibility issues +- **Feature Developers**: Adding new API endpoints and functionality +- **Documentation Authors**: Improving examples and user guides + +This project thrives thanks to community involvement and feedback. + +## ๐Ÿ’– Support This Project + +If you find this library useful for your projects, please consider supporting its continued development and maintenance: + +### ๐ŸŒŸ Ways to Support + +- **โญ Star this repository** - Help others discover the project +- **๐Ÿ’ฐ Financial Support** - Contribute to development and hosting costs +- **๐Ÿ› Report Issues** - Help improve stability and compatibility +- **๐Ÿ“– Spread the Word** - Share with other developers + +### ๐Ÿ’ณ Financial Support Options + +[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/) +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky) + +**Why Support?** +- Keeps the project actively maintained +- Enables faster bug fixes and new features +- Supports infrastructure costs (testing, AI, CI/CD) +- Shows appreciation for hundreds of hours of development + +Every contribution, no matter the size, makes a difference and is greatly appreciated! ๐Ÿ™ + +[releases-shield]: https://img.shields.io/github/release/cyberjunky/python-garminconnect.svg?style=for-the-badge +[releases]: https://github.com/cyberjunky/python-garminconnect/releases +[commits-shield]: https://img.shields.io/github/commit-activity/y/cyberjunky/python-garminconnect.svg?style=for-the-badge +[commits]: https://github.com/cyberjunky/python-garminconnect/commits/master +[license-shield]: https://img.shields.io/github/license/cyberjunky/python-garminconnect.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-cyberjunky-blue.svg?style=for-the-badge diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py new file mode 100644 index 0000000..291df03 --- /dev/null +++ b/garminconnect/__init__.py @@ -0,0 +1,3743 @@ +"""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", "") + 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, +) diff --git a/garminconnect/activity_details.py b/garminconnect/activity_details.py new file mode 100644 index 0000000..882a346 --- /dev/null +++ b/garminconnect/activity_details.py @@ -0,0 +1,44 @@ +"""Helpers for parsing the positional metrics returned by activity detail endpoints.""" + +from __future__ import annotations + +from typing import Any + + +def parse_activity_detail_metrics(details: dict[str, Any]) -> list[dict[str, Any]]: + """Resolve positional activity detail samples into per-sample dicts keyed by metric name. + + `details` is the raw response from `Garmin.get_activity_details()`. Each sample in + `activityDetailMetrics` stores values positionally in `metrics`; the position-to-name + mapping is given by `metricDescriptors[].metricsIndex`/`key` and varies by device and + activity type. Descriptors with a missing, non-int, or negative `metricsIndex` are + skipped, and a sample missing a channel entirely (index out of range for that sample) + simply omits that key rather than raising. Duration keys (`sumDuration`, + `sumElapsedDuration`, `sumMovingDuration`) are not equivalent and are passed through + unchanged under their own names โ€” callers must pick the one they mean. + """ + index_to_key: dict[int, str] = {} + for descriptor in details.get("metricDescriptors") or []: + key = descriptor.get("key") + index = descriptor.get("metricsIndex") + if ( + not isinstance(key, str) + or not isinstance(index, int) + or isinstance(index, bool) + ): + continue + if index < 0: + continue + index_to_key[index] = key + + parsed: list[dict[str, Any]] = [] + for sample in details.get("activityDetailMetrics") or []: + metrics = sample.get("metrics") or [] + parsed.append( + { + key: metrics[index] + for index, key in index_to_key.items() + if index < len(metrics) + } + ) + return parsed diff --git a/garminconnect/client.py b/garminconnect/client.py new file mode 100644 index 0000000..eaa95d1 --- /dev/null +++ b/garminconnect/client.py @@ -0,0 +1,1734 @@ +"""Authentication engine for Garmin Connect. + +Strategy chain (each strategy is tried in order; only auth errors stop the chain): +1. Mobile iOS + curl_cffi (TLS fingerprint rotation, no delay needed) +2. Mobile iOS + requests (plain HTTP fallback) +3. SSO embed widget + cffi (HTML form flow, bypasses clientId rate limits) +4. Portal web + curl_cffi (TLS fingerprint rotation, 10-20s anti-WAF delay) +5. Portal web + requests (plain HTTP last resort) +""" + +import base64 +import contextlib +import http.cookiejar +import json +import logging +import math +import os +import random +import re +import secrets +import threading +import time +from collections.abc import Iterator, Mapping +from pathlib import Path +from typing import Any, cast +from urllib.parse import unquote + +import requests +from requests.adapters import HTTPAdapter + +try: + from curl_cffi import requests as cffi_requests + + HAS_CFFI = True +except ImportError: + HAS_CFFI = False + +try: + from ua_generator import generate as _generate_ua + + HAS_UA_GEN = True +except ImportError: + HAS_UA_GEN = False + +from .exceptions import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectNotFoundError, + GarminConnectTooManyRequestsError, +) + +_LOGGER = logging.getLogger(__name__) + + +# Detect ~username expansion that would point into another user's home directory. +_OTHER_USER_HOME_RE = re.compile(r"^~[^/\\]") + + +def token_file_path(path: str) -> Path: + """Return the token file represented by a directory or JSON path. + + Rejects paths that expand into another user's home directory via + ``~username`` syntax. Bare ``~`` and ``~/...`` are allowed because they + resolve to the current user's home. + + Also rejects symlinked tokenstore paths so a pre-planted symlink cannot + redirect load/dump/logout to an attacker-controlled location. + """ + if _OTHER_USER_HOME_RE.match(path): + raise ValueError( + f"Token path must not reference another user's home directory: {path!r}" + ) + token_path = Path(path).expanduser() + # Reject symlinks anywhere in the tokenstore ancestry (e.g. + # ~/.garminconnect -> /attacker/dir). O_NOFOLLOW on the final open() + # only covers the last component; an intermediate symlinked directory + # would still redirect load/dump/logout into an attacker-controlled tree. + for check_path in (token_path, *token_path.parents): + try: + if check_path.is_symlink(): + raise ValueError(f"Token path must not be a symlink: {path!r}") + except OSError as e: + raise ValueError( + f"Token path cannot be checked for symlinks: {path!r}" + ) from e + if token_path.is_dir() or token_path.suffix.casefold() != ".json": + return token_path / "garmin_tokens.json" + return token_path + + +# -- Domain allowlist -- +# Only official Garmin domains are valid for authentication and API traffic. +# Arbitrary values would let a malicious caller redirect credentials elsewhere. +ALLOWED_DOMAINS = {"garmin.com", "garmin.cn"} + +# -- iOS mobile app constants (Strategy 1 & 2) -- +IOS_SSO_CLIENT_ID = "GCM_IOS_DARK" +IOS_SERVICE_URL = "https://mobile.integration.garmin.com/gcm/ios" +IOS_LOGIN_UA = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" +) + +# -- Android mobile app constants (legacy alias, kept for backward compat) -- +MOBILE_SSO_CLIENT_ID = "GCM_ANDROID_DARK" +MOBILE_SSO_SERVICE_URL = "https://mobile.integration.garmin.com/gcm/android" +MOBILE_SSO_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Mobile Safari/537.36" +) + +# -- Portal (fallback) constants -- +PORTAL_SSO_CLIENT_ID = "GarminConnect" +PORTAL_SSO_SERVICE_URL = "https://connect.garmin.com/app" +DESKTOP_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" +) + +# -- Anti-WAF delay bounds (seconds) -- +# Cloudflare flags rapid GETโ†’POST sequences as bot-like. +LOGIN_DELAY_MIN_S = 10.0 +LOGIN_DELAY_MAX_S = 20.0 +# Widget flow uses a shorter delay (different rate-limit bucket). +WIDGET_DELAY_MIN_S = 3.0 +WIDGET_DELAY_MAX_S = 8.0 + +# -- TLS impersonation profiles -- +MOBILE_IMPERSONATIONS: tuple[str, ...] = ("safari_ios", "safari", "chrome120") +PORTAL_IMPERSONATIONS: tuple[str, ...] = ( + "safari", + "safari_ios", + "chrome120", + "edge101", + "chrome", +) + +# -- Regex helpers for HTML parsing (widget flow) -- +_CSRF_RE = re.compile(r'name="_csrf"\s+value="(.+?)"') +_TITLE_RE = re.compile(r"(.+?)") +# Garmin's widget MFA page exposes these variables in inline