0.3.11 with curl_cffi as optional tls extra (FreeBSD support)
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
||||
558
PKG-INFO
Normal file
558
PKG-INFO
Normal file
@ -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 <ron@cyberjunky.nl>
|
||||
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]
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](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
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](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 <https://connect.garmin.com/>
|
||||
|
||||
## 📦 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 = <PyPI_API_TOKEN>
|
||||
# EOF
|
||||
```
|
||||
|
||||
```ini
|
||||
[pypi]
|
||||
username = __token__
|
||||
password = <PyPI_API_TOKEN>
|
||||
```
|
||||
|
||||
Recommended: use environment variables and restrict file perms
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.pypirc
|
||||
export TWINE_USERNAME="__token__"
|
||||
export TWINE_PASSWORD="<PyPI_API_TOKEN>"
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](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
|
||||
506
README.md
Normal file
506
README.md
Normal file
@ -0,0 +1,506 @@
|
||||
[![GitHub Release][releases-shield]][releases]
|
||||
[![GitHub Activity][commits-shield]][commits]
|
||||
[![License][license-shield]](LICENSE)
|
||||
![Project Maintenance][maintenance-shield]
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](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
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](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 <https://connect.garmin.com/>
|
||||
|
||||
## 📦 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 = <PyPI_API_TOKEN>
|
||||
# EOF
|
||||
```
|
||||
|
||||
```ini
|
||||
[pypi]
|
||||
username = __token__
|
||||
password = <PyPI_API_TOKEN>
|
||||
```
|
||||
|
||||
Recommended: use environment variables and restrict file perms
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.pypirc
|
||||
export TWINE_USERNAME="__token__"
|
||||
export TWINE_PASSWORD="<PyPI_API_TOKEN>"
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](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
|
||||
3743
garminconnect/__init__.py
Normal file
3743
garminconnect/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
44
garminconnect/activity_details.py
Normal file
44
garminconnect/activity_details.py
Normal file
@ -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
|
||||
1734
garminconnect/client.py
Normal file
1734
garminconnect/client.py
Normal file
File diff suppressed because it is too large
Load Diff
33
garminconnect/exceptions.py
Normal file
33
garminconnect/exceptions.py
Normal file
@ -0,0 +1,33 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GarminConnectConnectionError(Exception):
|
||||
"""Raised when communication ended in error.
|
||||
|
||||
``response`` carries the original HTTP response when the error wraps an
|
||||
HTTP failure, so callers can inspect status codes and bodies.
|
||||
"""
|
||||
|
||||
response: Any = None
|
||||
|
||||
|
||||
class GarminConnectNotFoundError(GarminConnectConnectionError):
|
||||
"""Raised when a requested resource does not exist (HTTP 404).
|
||||
|
||||
Subclasses GarminConnectConnectionError for backwards compatibility, so
|
||||
existing ``except GarminConnectConnectionError`` handlers keep working while
|
||||
callers can now catch a missing resource specifically (e.g. deleting an
|
||||
already-deleted workout).
|
||||
"""
|
||||
|
||||
|
||||
class GarminConnectTooManyRequestsError(Exception):
|
||||
"""Raised when rate limit is exceeded."""
|
||||
|
||||
|
||||
class GarminConnectAuthenticationError(Exception):
|
||||
"""Raised when authentication is failed."""
|
||||
|
||||
|
||||
class GarminConnectInvalidFileFormatError(Exception):
|
||||
"""Raised when an invalid file format is provided."""
|
||||
2635
garminconnect/exercises.py
Normal file
2635
garminconnect/exercises.py
Normal file
File diff suppressed because it is too large
Load Diff
518
garminconnect/fit.py
Normal file
518
garminconnect/fit.py
Normal file
@ -0,0 +1,518 @@
|
||||
# type: ignore # Complex binary data handling - mypy errors expected
|
||||
import time
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from struct import pack, unpack
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _calcCRC(crc: int, byte: int) -> int:
|
||||
table = [
|
||||
0x0000,
|
||||
0xCC01,
|
||||
0xD801,
|
||||
0x1400,
|
||||
0xF001,
|
||||
0x3C00,
|
||||
0x2800,
|
||||
0xE401,
|
||||
0xA001,
|
||||
0x6C00,
|
||||
0x7800,
|
||||
0xB401,
|
||||
0x5000,
|
||||
0x9C01,
|
||||
0x8801,
|
||||
0x4400,
|
||||
]
|
||||
# compute checksum of lower four bits of byte
|
||||
tmp = table[crc & 0xF]
|
||||
crc = (crc >> 4) & 0x0FFF
|
||||
crc = crc ^ tmp ^ table[byte & 0xF]
|
||||
# now compute checksum of upper four bits of byte
|
||||
tmp = table[crc & 0xF]
|
||||
crc = (crc >> 4) & 0x0FFF
|
||||
return crc ^ tmp ^ table[(byte >> 4) & 0xF]
|
||||
|
||||
|
||||
class FitBaseType:
|
||||
"""BaseType Definition.
|
||||
|
||||
see FIT Protocol Document(Page.20)
|
||||
"""
|
||||
|
||||
enum = {
|
||||
"#": 0,
|
||||
"endian": 0,
|
||||
"field": 0x00,
|
||||
"name": "enum",
|
||||
"invalid": 0xFF,
|
||||
"size": 1,
|
||||
}
|
||||
sint8 = {
|
||||
"#": 1,
|
||||
"endian": 0,
|
||||
"field": 0x01,
|
||||
"name": "sint8",
|
||||
"invalid": 0x7F,
|
||||
"size": 1,
|
||||
}
|
||||
uint8 = {
|
||||
"#": 2,
|
||||
"endian": 0,
|
||||
"field": 0x02,
|
||||
"name": "uint8",
|
||||
"invalid": 0xFF,
|
||||
"size": 1,
|
||||
}
|
||||
sint16 = {
|
||||
"#": 3,
|
||||
"endian": 1,
|
||||
"field": 0x83,
|
||||
"name": "sint16",
|
||||
"invalid": 0x7FFF,
|
||||
"size": 2,
|
||||
}
|
||||
uint16 = {
|
||||
"#": 4,
|
||||
"endian": 1,
|
||||
"field": 0x84,
|
||||
"name": "uint16",
|
||||
"invalid": 0xFFFF,
|
||||
"size": 2,
|
||||
}
|
||||
sint32 = {
|
||||
"#": 5,
|
||||
"endian": 1,
|
||||
"field": 0x85,
|
||||
"name": "sint32",
|
||||
"invalid": 0x7FFFFFFF,
|
||||
"size": 4,
|
||||
}
|
||||
uint32 = {
|
||||
"#": 6,
|
||||
"endian": 1,
|
||||
"field": 0x86,
|
||||
"name": "uint32",
|
||||
"invalid": 0xFFFFFFFF,
|
||||
"size": 4,
|
||||
}
|
||||
string = {
|
||||
"#": 7,
|
||||
"endian": 0,
|
||||
"field": 0x07,
|
||||
"name": "string",
|
||||
"invalid": 0x00,
|
||||
"size": 1,
|
||||
}
|
||||
float32 = {
|
||||
"#": 8,
|
||||
"endian": 1,
|
||||
"field": 0x88,
|
||||
"name": "float32",
|
||||
"invalid": 0xFFFFFFFF,
|
||||
"size": 2,
|
||||
}
|
||||
float64 = {
|
||||
"#": 9,
|
||||
"endian": 1,
|
||||
"field": 0x89,
|
||||
"name": "float64",
|
||||
"invalid": 0xFFFFFFFFFFFFFFFF,
|
||||
"size": 4,
|
||||
}
|
||||
uint8z = {
|
||||
"#": 10,
|
||||
"endian": 0,
|
||||
"field": 0x0A,
|
||||
"name": "uint8z",
|
||||
"invalid": 0x00,
|
||||
"size": 1,
|
||||
}
|
||||
uint16z = {
|
||||
"#": 11,
|
||||
"endian": 1,
|
||||
"field": 0x8B,
|
||||
"name": "uint16z",
|
||||
"invalid": 0x0000,
|
||||
"size": 2,
|
||||
}
|
||||
uint32z = {
|
||||
"#": 12,
|
||||
"endian": 1,
|
||||
"field": 0x8C,
|
||||
"name": "uint32z",
|
||||
"invalid": 0x00000000,
|
||||
"size": 4,
|
||||
}
|
||||
byte = {
|
||||
"#": 13,
|
||||
"endian": 0,
|
||||
"field": 0x0D,
|
||||
"name": "byte",
|
||||
"invalid": 0xFF,
|
||||
"size": 1,
|
||||
} # array of byte, field is invalid if all bytes are invalid
|
||||
|
||||
@staticmethod
|
||||
def get_format(basetype: int) -> str:
|
||||
formats = {
|
||||
0: "B",
|
||||
1: "b",
|
||||
2: "B",
|
||||
3: "h",
|
||||
4: "H",
|
||||
5: "i",
|
||||
6: "I",
|
||||
7: "s",
|
||||
8: "f",
|
||||
9: "d",
|
||||
10: "B",
|
||||
11: "H",
|
||||
12: "I",
|
||||
13: "c",
|
||||
}
|
||||
return formats[basetype["#"]]
|
||||
|
||||
@staticmethod
|
||||
def pack(basetype: dict[str, Any], value: Any) -> bytes:
|
||||
"""Function to avoid DeprecationWarning."""
|
||||
if basetype["#"] in (1, 2, 3, 4, 5, 6, 10, 11, 12):
|
||||
value = int(value)
|
||||
fmt = FitBaseType.get_format(basetype)
|
||||
return pack(fmt, value)
|
||||
|
||||
|
||||
class Fit:
|
||||
HEADER_SIZE = 12
|
||||
|
||||
# not sure if this is the mesg_num
|
||||
GMSG_NUMS = {
|
||||
"file_id": 0,
|
||||
"device_info": 23,
|
||||
"weight_scale": 30,
|
||||
"file_creator": 49,
|
||||
"blood_pressure": 51,
|
||||
}
|
||||
|
||||
|
||||
class FitEncoder(Fit):
|
||||
FILE_TYPE = 9
|
||||
LMSG_TYPE_FILE_INFO = 0
|
||||
LMSG_TYPE_FILE_CREATOR = 1
|
||||
LMSG_TYPE_DEVICE_INFO = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buf = BytesIO()
|
||||
self.write_header() # create header first
|
||||
self.device_info_defined = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
orig_pos = self.buf.tell()
|
||||
self.buf.seek(0)
|
||||
lines = []
|
||||
while True:
|
||||
b = self.buf.read(16)
|
||||
if not b:
|
||||
break
|
||||
lines.append(" ".join([f"{ord(c):02x}" for c in b]))
|
||||
self.buf.seek(orig_pos)
|
||||
return "\n".join(lines)
|
||||
|
||||
def write_header(
|
||||
self,
|
||||
header_size: int = 12, # Fit.HEADER_SIZE
|
||||
protocol_version: int = 16,
|
||||
profile_version: int = 108,
|
||||
data_size: int = 0,
|
||||
data_type: bytes = b".FIT",
|
||||
) -> None:
|
||||
self.buf.seek(0)
|
||||
s = pack(
|
||||
"BBHI4s",
|
||||
header_size,
|
||||
protocol_version,
|
||||
profile_version,
|
||||
data_size,
|
||||
data_type,
|
||||
)
|
||||
self.buf.write(s)
|
||||
|
||||
def _build_content_block(self, content: dict[str, Any]) -> bytes:
|
||||
field_defs = []
|
||||
values = []
|
||||
for num, basetype, value, scale in content:
|
||||
s = pack("BBB", num, basetype["size"], basetype["field"])
|
||||
field_defs.append(s)
|
||||
if value is None:
|
||||
# invalid value
|
||||
value = basetype["invalid"]
|
||||
elif scale is not None:
|
||||
value *= scale
|
||||
values.append(FitBaseType.pack(basetype, value))
|
||||
return (b"".join(field_defs), b"".join(values))
|
||||
|
||||
def write_file_info(
|
||||
self,
|
||||
serial_number: int | None = None,
|
||||
time_created: datetime | None = None,
|
||||
manufacturer: int | None = None,
|
||||
product: int | None = None,
|
||||
number: int | None = None,
|
||||
) -> None:
|
||||
if time_created is None:
|
||||
time_created = datetime.now()
|
||||
|
||||
content = [
|
||||
(3, FitBaseType.uint32z, serial_number, None),
|
||||
(4, FitBaseType.uint32, self.timestamp(time_created), None),
|
||||
(1, FitBaseType.uint16, manufacturer, None),
|
||||
(2, FitBaseType.uint16, product, None),
|
||||
(5, FitBaseType.uint16, number, None),
|
||||
(0, FitBaseType.enum, self.FILE_TYPE, None), # type
|
||||
]
|
||||
fields, values = self._build_content_block(content)
|
||||
|
||||
# create fixed content
|
||||
msg_number = self.GMSG_NUMS["file_id"]
|
||||
fixed_content = pack(
|
||||
"BBHB", 0, 0, msg_number, len(content)
|
||||
) # reserved, architecture(0: little endian)
|
||||
|
||||
self.buf.write(
|
||||
b"".join(
|
||||
[
|
||||
# definition
|
||||
self.record_header(
|
||||
definition=True, lmsg_type=self.LMSG_TYPE_FILE_INFO
|
||||
),
|
||||
fixed_content,
|
||||
fields,
|
||||
# record
|
||||
self.record_header(lmsg_type=self.LMSG_TYPE_FILE_INFO),
|
||||
values,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def write_file_creator(
|
||||
self,
|
||||
software_version: int | None = None,
|
||||
hardware_version: int | None = None,
|
||||
) -> None:
|
||||
content = [
|
||||
(0, FitBaseType.uint16, software_version, None),
|
||||
(1, FitBaseType.uint8, hardware_version, None),
|
||||
]
|
||||
fields, values = self._build_content_block(content)
|
||||
|
||||
msg_number = self.GMSG_NUMS["file_creator"]
|
||||
fixed_content = pack(
|
||||
"BBHB", 0, 0, msg_number, len(content)
|
||||
) # reserved, architecture(0: little endian)
|
||||
self.buf.write(
|
||||
b"".join(
|
||||
[
|
||||
# definition
|
||||
self.record_header(
|
||||
definition=True, lmsg_type=self.LMSG_TYPE_FILE_CREATOR
|
||||
),
|
||||
fixed_content,
|
||||
fields,
|
||||
# record
|
||||
self.record_header(lmsg_type=self.LMSG_TYPE_FILE_CREATOR),
|
||||
values,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def write_device_info(
|
||||
self,
|
||||
timestamp: datetime,
|
||||
serial_number: int | None = None,
|
||||
cum_operationg_time: int | None = None,
|
||||
manufacturer: int | None = None,
|
||||
product: int | None = None,
|
||||
software_version: int | None = None,
|
||||
battery_voltage: int | None = None,
|
||||
device_index: int | None = None,
|
||||
device_type: int | None = None,
|
||||
hardware_version: int | None = None,
|
||||
battery_status: int | None = None,
|
||||
) -> None:
|
||||
content = [
|
||||
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
|
||||
(3, FitBaseType.uint32z, serial_number, 1),
|
||||
(7, FitBaseType.uint32, cum_operationg_time, 1),
|
||||
(8, FitBaseType.uint32, None, None), # unknown field(undocumented)
|
||||
(2, FitBaseType.uint16, manufacturer, 1),
|
||||
(4, FitBaseType.uint16, product, 1),
|
||||
(5, FitBaseType.uint16, software_version, 100),
|
||||
(10, FitBaseType.uint16, battery_voltage, 256),
|
||||
(0, FitBaseType.uint8, device_index, 1),
|
||||
(1, FitBaseType.uint8, device_type, 1),
|
||||
(6, FitBaseType.uint8, hardware_version, 1),
|
||||
(11, FitBaseType.uint8, battery_status, None),
|
||||
]
|
||||
fields, values = self._build_content_block(content)
|
||||
|
||||
if not self.device_info_defined:
|
||||
header = self.record_header(
|
||||
definition=True, lmsg_type=self.LMSG_TYPE_DEVICE_INFO
|
||||
)
|
||||
msg_number = self.GMSG_NUMS["device_info"]
|
||||
fixed_content = pack(
|
||||
"BBHB", 0, 0, msg_number, len(content)
|
||||
) # reserved, architecture(0: little endian)
|
||||
self.buf.write(header + fixed_content + fields)
|
||||
self.device_info_defined = True
|
||||
|
||||
header = self.record_header(lmsg_type=self.LMSG_TYPE_DEVICE_INFO)
|
||||
self.buf.write(header + values)
|
||||
|
||||
def record_header(self, definition: bool = False, lmsg_type: int = 0) -> bytes:
|
||||
msg = 0
|
||||
if definition:
|
||||
msg = 1 << 6 # 6th bit is a definition message
|
||||
return pack("B", msg + lmsg_type)
|
||||
|
||||
def crc(self) -> int:
|
||||
orig_pos = self.buf.tell()
|
||||
self.buf.seek(0)
|
||||
|
||||
crc = 0
|
||||
while True:
|
||||
b = self.buf.read(1)
|
||||
if not b:
|
||||
break
|
||||
crc = _calcCRC(crc, unpack("b", b)[0])
|
||||
self.buf.seek(orig_pos)
|
||||
return pack("H", crc)
|
||||
|
||||
def finish(self) -> None:
|
||||
"""re-weite file-header, then append crc to end of file."""
|
||||
data_size = self.get_size() - self.HEADER_SIZE
|
||||
self.write_header(data_size=data_size)
|
||||
crc = self.crc()
|
||||
self.buf.seek(0, 2)
|
||||
self.buf.write(crc)
|
||||
|
||||
def get_size(self) -> int:
|
||||
orig_pos = self.buf.tell()
|
||||
self.buf.seek(0, 2)
|
||||
size = self.buf.tell()
|
||||
self.buf.seek(orig_pos)
|
||||
return size
|
||||
|
||||
def getvalue(self) -> bytes:
|
||||
return self.buf.getvalue()
|
||||
|
||||
def timestamp(self, t: datetime | float) -> float:
|
||||
"""The timestamp in fit protocol is seconds since
|
||||
UTC 00:00 Dec 31 1989 (631065600).
|
||||
"""
|
||||
if isinstance(t, datetime):
|
||||
t = time.mktime(t.timetuple())
|
||||
return t - 631065600
|
||||
|
||||
|
||||
class FitEncoderBloodPressure(FitEncoder):
|
||||
# Here might be dragons - no idea what lsmg stand for, found 14 somewhere in the deepest web
|
||||
LMSG_TYPE_BLOOD_PRESSURE = 14
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.blood_pressure_monitor_defined = False
|
||||
|
||||
def write_blood_pressure(
|
||||
self,
|
||||
timestamp: datetime | int | float,
|
||||
diastolic_blood_pressure: int | None = None,
|
||||
systolic_blood_pressure: int | None = None,
|
||||
mean_arterial_pressure: int | None = None,
|
||||
map_3_sample_mean: int | None = None,
|
||||
map_morning_values: int | None = None,
|
||||
map_evening_values: int | None = None,
|
||||
heart_rate: int | None = None,
|
||||
) -> None:
|
||||
# BLOOD PRESSURE FILE MESSAGES
|
||||
content = [
|
||||
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
|
||||
(0, FitBaseType.uint16, systolic_blood_pressure, 1),
|
||||
(1, FitBaseType.uint16, diastolic_blood_pressure, 1),
|
||||
(2, FitBaseType.uint16, mean_arterial_pressure, 1),
|
||||
(3, FitBaseType.uint16, map_3_sample_mean, 1),
|
||||
(4, FitBaseType.uint16, map_morning_values, 1),
|
||||
(5, FitBaseType.uint16, map_evening_values, 1),
|
||||
(6, FitBaseType.uint8, heart_rate, 1),
|
||||
]
|
||||
fields, values = self._build_content_block(content)
|
||||
|
||||
if not self.blood_pressure_monitor_defined:
|
||||
header = self.record_header(
|
||||
definition=True, lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE
|
||||
)
|
||||
msg_number = self.GMSG_NUMS["blood_pressure"]
|
||||
fixed_content = pack(
|
||||
"BBHB", 0, 0, msg_number, len(content)
|
||||
) # reserved, architecture(0: little endian)
|
||||
self.buf.write(header + fixed_content + fields)
|
||||
self.blood_pressure_monitor_defined = True
|
||||
|
||||
header = self.record_header(lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE)
|
||||
self.buf.write(header + values)
|
||||
|
||||
|
||||
class FitEncoderWeight(FitEncoder):
|
||||
LMSG_TYPE_WEIGHT_SCALE = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.weight_scale_defined = False
|
||||
|
||||
def write_weight_scale(
|
||||
self,
|
||||
timestamp: datetime | int | float,
|
||||
weight: int | float,
|
||||
percent_fat: int | float | None = None,
|
||||
percent_hydration: int | float | None = None,
|
||||
visceral_fat_mass: int | float | None = None,
|
||||
bone_mass: int | float | None = None,
|
||||
muscle_mass: int | float | None = None,
|
||||
basal_met: int | float | None = None,
|
||||
active_met: int | float | None = None,
|
||||
physique_rating: int | float | None = None,
|
||||
metabolic_age: int | float | None = None,
|
||||
visceral_fat_rating: int | float | None = None,
|
||||
bmi: int | float | None = None,
|
||||
) -> None:
|
||||
content = [
|
||||
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
|
||||
(0, FitBaseType.uint16, weight, 100),
|
||||
(1, FitBaseType.uint16, percent_fat, 100),
|
||||
(2, FitBaseType.uint16, percent_hydration, 100),
|
||||
(3, FitBaseType.uint16, visceral_fat_mass, 100),
|
||||
(4, FitBaseType.uint16, bone_mass, 100),
|
||||
(5, FitBaseType.uint16, muscle_mass, 100),
|
||||
(7, FitBaseType.uint16, basal_met, 4),
|
||||
(9, FitBaseType.uint16, active_met, 4),
|
||||
(8, FitBaseType.uint8, physique_rating, 1),
|
||||
(10, FitBaseType.uint8, metabolic_age, 1),
|
||||
(11, FitBaseType.uint8, visceral_fat_rating, 1),
|
||||
(13, FitBaseType.uint16, bmi, 10),
|
||||
]
|
||||
fields, values = self._build_content_block(content)
|
||||
|
||||
if not self.weight_scale_defined:
|
||||
header = self.record_header(
|
||||
definition=True, lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE
|
||||
)
|
||||
msg_number = self.GMSG_NUMS["weight_scale"]
|
||||
fixed_content = pack(
|
||||
"BBHB", 0, 0, msg_number, len(content)
|
||||
) # reserved, architecture(0: little endian)
|
||||
self.buf.write(header + fixed_content + fields)
|
||||
self.weight_scale_defined = True
|
||||
|
||||
header = self.record_header(lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE)
|
||||
self.buf.write(header + values)
|
||||
593
garminconnect/typed.py
Normal file
593
garminconnect/typed.py
Normal file
@ -0,0 +1,593 @@
|
||||
"""Optional Pydantic response models for typed Garmin Connect API access.
|
||||
|
||||
Experimental — model shapes and the ``g.typed`` surface may change between
|
||||
minor releases until the pattern stabilises. Pin a specific version if you
|
||||
depend on typed response shapes.
|
||||
|
||||
The typed namespace wraps a small, curated set of high-value endpoints. All
|
||||
other endpoints remain available via the standard ``g.get_*()`` methods with
|
||||
``dict[str, Any]`` responses — this layer is purely additive.
|
||||
|
||||
Usage:
|
||||
from garminconnect import Garmin
|
||||
|
||||
g = Garmin(email, password)
|
||||
g.login()
|
||||
|
||||
raw = g.get_stats("2026-04-21") # dict[str, Any] — unchanged
|
||||
stats = g.typed.get_stats("2026-04-21") # DailyStats (Pydantic)
|
||||
print(stats.total_steps, stats.resting_heart_rate)
|
||||
|
||||
Install the optional dependency first::
|
||||
|
||||
pip install 'garminconnect[typed]'
|
||||
|
||||
On validation failure, raises :class:`GarminConnectResponseValidationError`
|
||||
with the unvalidated response preserved as ``.raw`` so callers can still
|
||||
access the data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import ValidationError as _PydanticValidationError
|
||||
except ImportError as _exc: # pragma: no cover - exercised via integration
|
||||
raise ImportError(
|
||||
"The `typed` namespace requires pydantic. Install it with:\n"
|
||||
" pip install 'garminconnect[typed]'"
|
||||
) from _exc
|
||||
|
||||
_M = TypeVar("_M", bound=BaseModel)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Garmin
|
||||
|
||||
|
||||
class GarminConnectResponseValidationError(Exception):
|
||||
"""Raised when a Garmin response fails Pydantic validation.
|
||||
|
||||
The unvalidated response is available as ``raw`` so callers can still
|
||||
inspect the data. The underlying :class:`pydantic.ValidationError` is
|
||||
available as ``pydantic_error``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
raw: Any,
|
||||
pydantic_error: _PydanticValidationError,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.raw = raw
|
||||
self.pydantic_error = pydantic_error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ``extra='allow'`` is deliberate: Garmin occasionally adds fields with new
|
||||
# firmware / subscription tiers, and we don't want validation failures for
|
||||
# benign additions. ``populate_by_name=True`` lets callers construct models
|
||||
# using either the Python attribute name or the JSON alias, which is useful
|
||||
# for tests.
|
||||
_COMMON_CONFIG = ConfigDict(
|
||||
extra="allow",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
|
||||
class _BaseResponse(BaseModel):
|
||||
model_config = _COMMON_CONFIG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daily Stats (get_stats / get_user_summary)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DailyStats(_BaseResponse):
|
||||
"""Daily summary returned by ``get_stats`` and ``get_user_summary``.
|
||||
|
||||
Only the most commonly consumed fields are modelled explicitly; additional
|
||||
fields are accessible through ``model_extra`` or by dumping the model.
|
||||
"""
|
||||
|
||||
user_profile_id: int | None = Field(default=None, alias="userProfileId")
|
||||
calendar_date: str | None = Field(default=None, alias="calendarDate")
|
||||
|
||||
total_steps: int | None = Field(default=None, alias="totalSteps")
|
||||
daily_step_goal: int | None = Field(default=None, alias="dailyStepGoal")
|
||||
total_distance_meters: float | None = Field(
|
||||
default=None, alias="totalDistanceMeters"
|
||||
)
|
||||
|
||||
total_kilocalories: float | None = Field(default=None, alias="totalKilocalories")
|
||||
active_kilocalories: float | None = Field(default=None, alias="activeKilocalories")
|
||||
bmr_kilocalories: float | None = Field(default=None, alias="bmrKilocalories")
|
||||
wellness_kilocalories: float | None = Field(
|
||||
default=None, alias="wellnessKilocalories"
|
||||
)
|
||||
|
||||
min_heart_rate: int | None = Field(default=None, alias="minHeartRate")
|
||||
max_heart_rate: int | None = Field(default=None, alias="maxHeartRate")
|
||||
resting_heart_rate: int | None = Field(default=None, alias="restingHeartRate")
|
||||
|
||||
sleeping_seconds: int | None = Field(default=None, alias="sleepingSeconds")
|
||||
sedentary_seconds: int | None = Field(default=None, alias="sedentarySeconds")
|
||||
active_seconds: int | None = Field(default=None, alias="activeSeconds")
|
||||
highly_active_seconds: int | None = Field(default=None, alias="highlyActiveSeconds")
|
||||
|
||||
moderate_intensity_minutes: int | None = Field(
|
||||
default=None, alias="moderateIntensityMinutes"
|
||||
)
|
||||
vigorous_intensity_minutes: int | None = Field(
|
||||
default=None, alias="vigorousIntensityMinutes"
|
||||
)
|
||||
|
||||
floors_ascended: float | None = Field(default=None, alias="floorsAscended")
|
||||
floors_descended: float | None = Field(default=None, alias="floorsDescended")
|
||||
|
||||
average_stress_level: int | None = Field(default=None, alias="averageStressLevel")
|
||||
max_stress_level: int | None = Field(default=None, alias="maxStressLevel")
|
||||
stress_duration: int | None = Field(default=None, alias="stressDuration")
|
||||
rest_stress_duration: int | None = Field(default=None, alias="restStressDuration")
|
||||
|
||||
body_battery_charged_value: int | None = Field(
|
||||
default=None, alias="bodyBatteryChargedValue"
|
||||
)
|
||||
body_battery_drained_value: int | None = Field(
|
||||
default=None, alias="bodyBatteryDrainedValue"
|
||||
)
|
||||
body_battery_highest_value: int | None = Field(
|
||||
default=None, alias="bodyBatteryHighestValue"
|
||||
)
|
||||
body_battery_lowest_value: int | None = Field(
|
||||
default=None, alias="bodyBatteryLowestValue"
|
||||
)
|
||||
|
||||
privacy_protected: bool | None = Field(default=None, alias="privacyProtected")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sleep (get_sleep_data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SleepScoreValue(_BaseResponse):
|
||||
"""One component of the Garmin sleep score breakdown (value + qualifier)."""
|
||||
|
||||
value: int | None = None
|
||||
qualifier_key: str | None = Field(default=None, alias="qualifierKey")
|
||||
|
||||
|
||||
class SleepScores(_BaseResponse):
|
||||
"""Sub-scores that make up the overall nightly sleep score."""
|
||||
|
||||
overall: SleepScoreValue | None = None
|
||||
total_duration: SleepScoreValue | None = Field(default=None, alias="totalDuration")
|
||||
stress: SleepScoreValue | None = None
|
||||
awake_count: SleepScoreValue | None = Field(default=None, alias="awakeCount")
|
||||
rem_percentage: SleepScoreValue | None = Field(default=None, alias="remPercentage")
|
||||
restlessness: SleepScoreValue | None = None
|
||||
light_percentage: SleepScoreValue | None = Field(
|
||||
default=None, alias="lightPercentage"
|
||||
)
|
||||
deep_percentage: SleepScoreValue | None = Field(
|
||||
default=None, alias="deepPercentage"
|
||||
)
|
||||
|
||||
|
||||
class DailySleepDTO(_BaseResponse):
|
||||
"""Nested sleep summary inside a :class:`SleepData` response."""
|
||||
|
||||
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
|
||||
calendar_date: str | None = Field(default=None, alias="calendarDate")
|
||||
|
||||
sleep_time_seconds: int | None = Field(default=None, alias="sleepTimeSeconds")
|
||||
nap_time_seconds: int | None = Field(default=None, alias="napTimeSeconds")
|
||||
sleep_window_confirmed: bool | None = Field(
|
||||
default=None, alias="sleepWindowConfirmed"
|
||||
)
|
||||
|
||||
deep_sleep_seconds: int | None = Field(default=None, alias="deepSleepSeconds")
|
||||
light_sleep_seconds: int | None = Field(default=None, alias="lightSleepSeconds")
|
||||
rem_sleep_seconds: int | None = Field(default=None, alias="remSleepSeconds")
|
||||
awake_sleep_seconds: int | None = Field(default=None, alias="awakeSleepSeconds")
|
||||
|
||||
sleep_start_timestamp_gmt: int | None = Field(
|
||||
default=None, alias="sleepStartTimestampGMT"
|
||||
)
|
||||
sleep_end_timestamp_gmt: int | None = Field(
|
||||
default=None, alias="sleepEndTimestampGMT"
|
||||
)
|
||||
sleep_start_timestamp_local: int | None = Field(
|
||||
default=None, alias="sleepStartTimestampLocal"
|
||||
)
|
||||
sleep_end_timestamp_local: int | None = Field(
|
||||
default=None, alias="sleepEndTimestampLocal"
|
||||
)
|
||||
|
||||
avg_sleep_hrv: float | None = Field(default=None, alias="avgSleepHRV")
|
||||
avg_spo2: float | None = Field(default=None, alias="avgSpO2")
|
||||
avg_respiration_value: float | None = Field(
|
||||
default=None, alias="avgRespirationValue"
|
||||
)
|
||||
lowest_respiration_value: float | None = Field(
|
||||
default=None, alias="lowestRespirationValue"
|
||||
)
|
||||
highest_respiration_value: float | None = Field(
|
||||
default=None, alias="highestRespirationValue"
|
||||
)
|
||||
|
||||
sleep_scores: SleepScores | None = Field(default=None, alias="sleepScores")
|
||||
|
||||
|
||||
class SleepData(_BaseResponse):
|
||||
"""Response for ``get_sleep_data``.
|
||||
|
||||
The most useful summary lives under ``daily_sleep_dto``; callers that want
|
||||
per-minute heart rate / movement / SpO2 arrays should use the raw dict via
|
||||
``g.get_sleep_data`` since those arrays are large and rarely needed in
|
||||
typed form.
|
||||
"""
|
||||
|
||||
daily_sleep_dto: DailySleepDTO | None = Field(default=None, alias="dailySleepDTO")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HRV (get_hrv_data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HrvBaseline(_BaseResponse):
|
||||
"""Personal HRV baseline ranges derived from the user's history."""
|
||||
|
||||
low_upper: float | None = Field(default=None, alias="lowUpper")
|
||||
balanced_low: float | None = Field(default=None, alias="balancedLow")
|
||||
balanced_upper: float | None = Field(default=None, alias="balancedUpper")
|
||||
marker_value: float | None = Field(default=None, alias="markerValue")
|
||||
|
||||
|
||||
class HrvSummary(_BaseResponse):
|
||||
"""Summary of HRV stats (weekly / last-night averages, status, feedback)."""
|
||||
|
||||
calendar_date: str | None = Field(default=None, alias="calendarDate")
|
||||
weekly_avg: float | None = Field(default=None, alias="weeklyAvg")
|
||||
last_night_avg: float | None = Field(default=None, alias="lastNightAvg")
|
||||
last_night_5_min_high: float | None = Field(default=None, alias="lastNight5MinHigh")
|
||||
status: str | None = None
|
||||
feedback_phrase: str | None = Field(default=None, alias="feedbackPhrase")
|
||||
baseline: HrvBaseline | None = None
|
||||
|
||||
|
||||
class HrvData(_BaseResponse):
|
||||
"""Response for ``get_hrv_data``.
|
||||
|
||||
Note: ``get_hrv_data`` may return ``None`` if HRV data is not available for
|
||||
the requested date. The typed wrapper preserves this — ``g.typed.get_hrv_data``
|
||||
returns ``HrvData | None``.
|
||||
"""
|
||||
|
||||
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
|
||||
hrv_summary: HrvSummary | None = Field(default=None, alias="hrvSummary")
|
||||
hrv_readings: list[dict[str, Any]] | None = Field(default=None, alias="hrvReadings")
|
||||
start_timestamp_gmt: str | None = Field(default=None, alias="startTimestampGMT")
|
||||
end_timestamp_gmt: str | None = Field(default=None, alias="endTimestampGMT")
|
||||
start_timestamp_local: str | None = Field(default=None, alias="startTimestampLocal")
|
||||
end_timestamp_local: str | None = Field(default=None, alias="endTimestampLocal")
|
||||
sleep_start_timestamp_gmt: str | None = Field(
|
||||
default=None, alias="sleepStartTimestampGMT"
|
||||
)
|
||||
sleep_end_timestamp_gmt: str | None = Field(
|
||||
default=None, alias="sleepEndTimestampGMT"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body Battery (get_body_battery)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BodyBatteryEntry(_BaseResponse):
|
||||
"""One entry from ``get_body_battery``.
|
||||
|
||||
``get_body_battery`` always returns a list; for a single date the list has
|
||||
one entry. ``body_battery_values_array`` is a list of ``[timestamp, level]``
|
||||
pairs sampled throughout the day.
|
||||
"""
|
||||
|
||||
date: str | None = None
|
||||
charged: int | None = None
|
||||
drained: int | None = None
|
||||
start_timestamp_gmt: str | None = Field(default=None, alias="startTimestampGMT")
|
||||
end_timestamp_gmt: str | None = Field(default=None, alias="endTimestampGMT")
|
||||
start_timestamp_local: str | None = Field(default=None, alias="startTimestampLocal")
|
||||
end_timestamp_local: str | None = Field(default=None, alias="endTimestampLocal")
|
||||
body_battery_values_array: list[list[Any]] | None = Field(
|
||||
default=None, alias="bodyBatteryValuesArray"
|
||||
)
|
||||
body_battery_value_descriptors_dto_list: list[dict[str, Any]] | None = Field(
|
||||
default=None, alias="bodyBatteryValueDescriptorDTOList"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Training Readiness (get_training_readiness)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TrainingReadiness(_BaseResponse):
|
||||
"""One snapshot from ``get_training_readiness``.
|
||||
|
||||
The endpoint returns a list of snapshots — typically one per wake-up event
|
||||
or scheduled update. Use the snapshot with the most recent ``timestamp``
|
||||
for the current reading.
|
||||
|
||||
``recovery_time`` is reported in **minutes**. When
|
||||
``recovery_time_change_phrase == 'REACHED_ZERO'`` the user is fully
|
||||
recovered regardless of the numeric value (Garmin keeps the last assigned
|
||||
value after the clock drains).
|
||||
"""
|
||||
|
||||
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
|
||||
calendar_date: str | None = Field(default=None, alias="calendarDate")
|
||||
timestamp: str | None = None
|
||||
timestamp_local: str | None = Field(default=None, alias="timestampLocal")
|
||||
device_id: int | None = Field(default=None, alias="deviceId")
|
||||
|
||||
score: int | None = None
|
||||
level: str | None = None
|
||||
feedback_long: str | None = Field(default=None, alias="feedbackLong")
|
||||
feedback_short: str | None = Field(default=None, alias="feedbackShort")
|
||||
|
||||
sleep_score: int | None = Field(default=None, alias="sleepScore")
|
||||
sleep_score_factor_percent: int | None = Field(
|
||||
default=None, alias="sleepScoreFactorPercent"
|
||||
)
|
||||
sleep_score_factor_feedback: str | None = Field(
|
||||
default=None, alias="sleepScoreFactorFeedback"
|
||||
)
|
||||
|
||||
recovery_time: int | None = Field(default=None, alias="recoveryTime")
|
||||
recovery_time_factor_percent: int | None = Field(
|
||||
default=None, alias="recoveryTimeFactorPercent"
|
||||
)
|
||||
recovery_time_factor_feedback: str | None = Field(
|
||||
default=None, alias="recoveryTimeFactorFeedback"
|
||||
)
|
||||
recovery_time_change_phrase: str | None = Field(
|
||||
default=None, alias="recoveryTimeChangePhrase"
|
||||
)
|
||||
|
||||
acwr_factor_percent: int | None = Field(default=None, alias="acwrFactorPercent")
|
||||
acwr_factor_feedback: str | None = Field(default=None, alias="acwrFactorFeedback")
|
||||
|
||||
hrv_factor_percent: int | None = Field(default=None, alias="hrvFactorPercent")
|
||||
hrv_factor_feedback: str | None = Field(default=None, alias="hrvFactorFeedback")
|
||||
|
||||
stress_history_factor_percent: int | None = Field(
|
||||
default=None, alias="stressHistoryFactorPercent"
|
||||
)
|
||||
stress_history_factor_feedback: str | None = Field(
|
||||
default=None, alias="stressHistoryFactorFeedback"
|
||||
)
|
||||
|
||||
input_context: str | None = Field(default=None, alias="inputContext")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activity (get_activities_by_date)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ActivityType(_BaseResponse):
|
||||
"""Garmin activity type classification (``typeKey`` is the main lookup)."""
|
||||
|
||||
type_id: int | None = Field(default=None, alias="typeId")
|
||||
type_key: str | None = Field(default=None, alias="typeKey")
|
||||
parent_type_id: int | None = Field(default=None, alias="parentTypeId")
|
||||
is_hidden: bool | None = Field(default=None, alias="isHidden")
|
||||
|
||||
|
||||
class Activity(_BaseResponse):
|
||||
"""One activity from ``get_activities_by_date``.
|
||||
|
||||
Strength-training activities populate ``total_sets``, ``total_reps`` and
|
||||
``total_volume``; other activity types leave those fields as ``None``.
|
||||
"""
|
||||
|
||||
activity_id: int | None = Field(default=None, alias="activityId")
|
||||
activity_name: str | None = Field(default=None, alias="activityName")
|
||||
|
||||
start_time_local: str | None = Field(default=None, alias="startTimeLocal")
|
||||
start_time_gmt: str | None = Field(default=None, alias="startTimeGMT")
|
||||
|
||||
activity_type: ActivityType | None = Field(default=None, alias="activityType")
|
||||
|
||||
duration: float | None = None
|
||||
moving_duration: float | None = Field(default=None, alias="movingDuration")
|
||||
elapsed_duration: float | None = Field(default=None, alias="elapsedDuration")
|
||||
|
||||
distance: float | None = None
|
||||
elevation_gain: float | None = Field(default=None, alias="elevationGain")
|
||||
elevation_loss: float | None = Field(default=None, alias="elevationLoss")
|
||||
|
||||
average_speed: float | None = Field(default=None, alias="averageSpeed")
|
||||
max_speed: float | None = Field(default=None, alias="maxSpeed")
|
||||
|
||||
average_hr: float | None = Field(default=None, alias="averageHR")
|
||||
max_hr: float | None = Field(default=None, alias="maxHR")
|
||||
|
||||
calories: float | None = None
|
||||
bmr_calories: float | None = Field(default=None, alias="bmrCalories")
|
||||
|
||||
avg_power: float | None = Field(default=None, alias="avgPower")
|
||||
max_power: float | None = Field(default=None, alias="maxPower")
|
||||
normalized_power: float | None = Field(default=None, alias="normPower")
|
||||
|
||||
aerobic_training_effect: float | None = Field(
|
||||
default=None, alias="aerobicTrainingEffect"
|
||||
)
|
||||
anaerobic_training_effect: float | None = Field(
|
||||
default=None, alias="anaerobicTrainingEffect"
|
||||
)
|
||||
activity_training_load: float | None = Field(
|
||||
default=None, alias="activityTrainingLoad"
|
||||
)
|
||||
training_effect_label: str | None = Field(default=None, alias="trainingEffectLabel")
|
||||
|
||||
average_running_cadence: float | None = Field(
|
||||
default=None, alias="averageRunningCadenceInStepsPerMinute"
|
||||
)
|
||||
max_running_cadence: float | None = Field(
|
||||
default=None, alias="maxRunningCadenceInStepsPerMinute"
|
||||
)
|
||||
|
||||
total_sets: int | None = Field(default=None, alias="totalSets")
|
||||
active_sets: int | None = Field(default=None, alias="activeSets")
|
||||
total_reps: int | None = Field(default=None, alias="totalReps")
|
||||
total_volume: float | None = Field(default=None, alias="totalVolume")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TypedGarmin:
|
||||
"""Typed namespace accessor for a curated set of Garmin Connect endpoints.
|
||||
|
||||
Access via the :attr:`Garmin.typed` cached property, never instantiate
|
||||
directly::
|
||||
|
||||
g = Garmin(email, password)
|
||||
g.login()
|
||||
stats = g.typed.get_stats("2026-04-21")
|
||||
|
||||
Each method is a thin wrapper around the corresponding ``Garmin`` method
|
||||
that validates the response with a Pydantic model. On validation failure,
|
||||
raises :class:`GarminConnectResponseValidationError` with the unvalidated
|
||||
response available as ``.raw``.
|
||||
|
||||
**Experimental.** Model shapes and method signatures may change in future
|
||||
releases; pin a specific version if you depend on them.
|
||||
"""
|
||||
|
||||
def __init__(self, garmin: Garmin) -> None:
|
||||
self._garmin = garmin
|
||||
|
||||
@staticmethod
|
||||
def _validate(model_cls: type[_M], raw: Any, method_name: str) -> _M:
|
||||
try:
|
||||
return model_cls.model_validate(raw)
|
||||
except _PydanticValidationError as exc:
|
||||
raise GarminConnectResponseValidationError(
|
||||
f"Response from {method_name}() failed {model_cls.__name__} "
|
||||
f"validation: {exc}",
|
||||
raw=raw,
|
||||
pydantic_error=exc,
|
||||
) from exc
|
||||
|
||||
# -- Daily stats ---------------------------------------------------------
|
||||
|
||||
def get_stats(self, cdate: str) -> DailyStats:
|
||||
"""Return daily stats for ``cdate`` as a :class:`DailyStats` model."""
|
||||
raw = self._garmin.get_stats(cdate)
|
||||
return self._validate(DailyStats, raw, "get_stats")
|
||||
|
||||
def get_user_summary(self, cdate: str) -> DailyStats:
|
||||
"""Return the user summary for ``cdate`` as a :class:`DailyStats` model."""
|
||||
raw = self._garmin.get_user_summary(cdate)
|
||||
return self._validate(DailyStats, raw, "get_user_summary")
|
||||
|
||||
# -- Sleep ---------------------------------------------------------------
|
||||
|
||||
def get_sleep_data(self, cdate: str) -> SleepData:
|
||||
"""Return sleep data for ``cdate`` as a :class:`SleepData` model."""
|
||||
raw = self._garmin.get_sleep_data(cdate)
|
||||
return self._validate(SleepData, raw, "get_sleep_data")
|
||||
|
||||
# -- HRV -----------------------------------------------------------------
|
||||
|
||||
def get_hrv_data(self, cdate: str) -> HrvData | None:
|
||||
"""Return HRV data for ``cdate`` as :class:`HrvData`, or ``None`` if absent."""
|
||||
raw = self._garmin.get_hrv_data(cdate)
|
||||
if raw is None:
|
||||
return None
|
||||
return self._validate(HrvData, raw, "get_hrv_data")
|
||||
|
||||
# -- Body battery --------------------------------------------------------
|
||||
|
||||
def get_body_battery(
|
||||
self, startdate: str, enddate: str | None = None
|
||||
) -> list[BodyBatteryEntry]:
|
||||
"""Return body battery entries between ``startdate`` and ``enddate``."""
|
||||
raw = self._garmin.get_body_battery(startdate, enddate)
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [
|
||||
self._validate(BodyBatteryEntry, item, "get_body_battery") for item in raw
|
||||
]
|
||||
|
||||
# -- Training readiness --------------------------------------------------
|
||||
|
||||
def get_training_readiness(self, cdate: str) -> list[TrainingReadiness]:
|
||||
"""Return training readiness snapshots for ``cdate``.
|
||||
|
||||
The underlying endpoint may return either a list of snapshots or a
|
||||
single snapshot object depending on account/firmware behavior. This
|
||||
wrapper normalizes both shapes to ``list[TrainingReadiness]``.
|
||||
"""
|
||||
raw = self._garmin.get_training_readiness(cdate)
|
||||
if not raw:
|
||||
# Empty list / empty dict / None — no snapshots available.
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [
|
||||
self._validate(TrainingReadiness, item, "get_training_readiness")
|
||||
for item in raw
|
||||
]
|
||||
if isinstance(raw, dict):
|
||||
return [self._validate(TrainingReadiness, raw, "get_training_readiness")]
|
||||
return []
|
||||
|
||||
# -- Activities ----------------------------------------------------------
|
||||
|
||||
def get_activities_by_date(
|
||||
self,
|
||||
startdate: str,
|
||||
enddate: str | None = None,
|
||||
activitytype: str | None = None,
|
||||
sortorder: str | None = None,
|
||||
) -> list[Activity]:
|
||||
"""Return activities between two dates as a list of :class:`Activity`."""
|
||||
raw = self._garmin.get_activities_by_date(
|
||||
startdate, enddate, activitytype, sortorder
|
||||
)
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [
|
||||
self._validate(Activity, item, "get_activities_by_date") for item in raw
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"BodyBatteryEntry",
|
||||
"DailySleepDTO",
|
||||
"DailyStats",
|
||||
"GarminConnectResponseValidationError",
|
||||
"HrvBaseline",
|
||||
"HrvData",
|
||||
"HrvSummary",
|
||||
"SleepData",
|
||||
"SleepScoreValue",
|
||||
"SleepScores",
|
||||
"TrainingReadiness",
|
||||
"TypedGarmin",
|
||||
]
|
||||
573
garminconnect/workout.py
Normal file
573
garminconnect/workout.py
Normal file
@ -0,0 +1,573 @@
|
||||
"""Typed workout models for Garmin Connect workouts.
|
||||
|
||||
This module provides Pydantic models for creating type-safe workout definitions.
|
||||
Pydantic is an optional dependency - install it with: pip install pydantic
|
||||
or: pip install garminconnect[workout]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
else:
|
||||
try:
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
except ImportError:
|
||||
# Fallback if pydantic is not installed
|
||||
BaseModel = object # type: ignore[assignment,misc]
|
||||
ConfigDict = dict # type: ignore[assignment,misc]
|
||||
|
||||
def Field(*_args: Any, **_kwargs: Any) -> Any: # type: ignore[misc]
|
||||
"""Placeholder Field function when pydantic is not installed."""
|
||||
return None
|
||||
|
||||
|
||||
# Sport Type IDs — from /workout-service/workout/types
|
||||
class SportType:
|
||||
"""Garmin workout sport type IDs."""
|
||||
|
||||
RUNNING = 1
|
||||
CYCLING = 2
|
||||
OTHER = 3
|
||||
SWIMMING = 4
|
||||
STRENGTH_TRAINING = 5
|
||||
CARDIO_TRAINING = 6
|
||||
YOGA = 7
|
||||
PILATES = 8
|
||||
HIIT = 9
|
||||
MULTI_SPORT = 10
|
||||
MOBILITY = 11
|
||||
|
||||
|
||||
# Step Type IDs — from /workout-service/workout/types
|
||||
class StepType:
|
||||
"""Garmin workout step type IDs."""
|
||||
|
||||
WARMUP = 1
|
||||
COOLDOWN = 2
|
||||
INTERVAL = 3
|
||||
RECOVERY = 4
|
||||
REST = 5
|
||||
REPEAT = 6
|
||||
OTHER = 7
|
||||
MAIN = 8
|
||||
|
||||
|
||||
# Condition Type IDs — from /workout-service/workout/types
|
||||
class ConditionType:
|
||||
"""Garmin end condition type IDs."""
|
||||
|
||||
LAP_BUTTON = 1
|
||||
TIME = 2
|
||||
DISTANCE = 3
|
||||
CALORIES = 4
|
||||
POWER = 5
|
||||
HEART_RATE = 6
|
||||
ITERATIONS = 7
|
||||
FIXED_REST = 8
|
||||
FIXED_REPETITION = 9
|
||||
REPS = 10
|
||||
|
||||
|
||||
# Target Type IDs — from /workout-service/workout/types
|
||||
class TargetType:
|
||||
"""Garmin workout target type IDs."""
|
||||
|
||||
NO_TARGET = 1
|
||||
POWER_ZONE = 2
|
||||
CADENCE = 3
|
||||
HEART_RATE_ZONE = 4
|
||||
SPEED_ZONE = 5
|
||||
PACE_ZONE = 6
|
||||
GRADE = 7
|
||||
HEART_RATE_LAP = 8
|
||||
POWER_LAP = 9
|
||||
RESISTANCE = 15
|
||||
|
||||
|
||||
# Weight unit for strength workout target loads.
|
||||
# Garmin stores ``weightValue`` in GRAMS tagged with this kilogram unit.
|
||||
WEIGHT_UNIT_KILOGRAM = {"unitId": 8, "unitKey": "kilogram", "factor": 1000.0}
|
||||
|
||||
|
||||
class SportTypeModel(BaseModel):
|
||||
"""Sport type model."""
|
||||
|
||||
sportTypeId: int
|
||||
sportTypeKey: str
|
||||
displayOrder: int = 1
|
||||
|
||||
|
||||
class EndConditionModel(BaseModel):
|
||||
"""End condition model for workout steps."""
|
||||
|
||||
conditionTypeId: int
|
||||
conditionTypeKey: str
|
||||
displayOrder: int
|
||||
displayable: bool = True
|
||||
|
||||
|
||||
class TargetTypeModel(BaseModel):
|
||||
"""Target type model for workout steps."""
|
||||
|
||||
workoutTargetTypeId: int
|
||||
workoutTargetTypeKey: str
|
||||
displayOrder: int
|
||||
|
||||
|
||||
class StrokeTypeModel(BaseModel):
|
||||
"""Stroke type model (for swimming workouts)."""
|
||||
|
||||
strokeTypeId: int = 0
|
||||
displayOrder: int = 0
|
||||
|
||||
|
||||
class EquipmentTypeModel(BaseModel):
|
||||
"""Equipment type model."""
|
||||
|
||||
equipmentTypeId: int = 0
|
||||
displayOrder: int = 0
|
||||
|
||||
|
||||
class ExecutableStep(BaseModel):
|
||||
"""Executable workout step (warmup, interval, recovery, cooldown, etc.)."""
|
||||
|
||||
type: str = "ExecutableStepDTO"
|
||||
stepOrder: int
|
||||
stepType: dict[str, Any] | None = None
|
||||
endCondition: dict[str, Any] | None = None
|
||||
endConditionValue: float | None = None
|
||||
targetType: dict[str, Any] | None = None
|
||||
strokeType: dict[str, Any] | None = None
|
||||
equipmentType: dict[str, Any] | None = None
|
||||
childStepId: int | None = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class RepeatGroup(BaseModel):
|
||||
"""Repeat group for repeating workout steps."""
|
||||
|
||||
type: str = "RepeatGroupDTO"
|
||||
stepOrder: int
|
||||
stepType: dict[str, Any] | None = None
|
||||
numberOfIterations: int
|
||||
workoutSteps: list[ExecutableStep | RepeatGroup]
|
||||
endCondition: dict[str, Any] | None = None
|
||||
endConditionValue: float | None = None
|
||||
childStepId: int | None = None
|
||||
smartRepeat: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
# Update forward reference (only if pydantic is available)
|
||||
with suppress(AttributeError, TypeError):
|
||||
RepeatGroup.model_rebuild()
|
||||
|
||||
|
||||
class WorkoutSegment(BaseModel):
|
||||
"""Workout segment containing workout steps."""
|
||||
|
||||
segmentOrder: int
|
||||
sportType: dict[str, Any]
|
||||
workoutSteps: list[ExecutableStep | RepeatGroup]
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class BaseWorkout(BaseModel):
|
||||
"""Base workout model."""
|
||||
|
||||
workoutName: str
|
||||
sportType: dict[str, Any]
|
||||
estimatedDurationInSecs: int
|
||||
workoutSegments: list[WorkoutSegment]
|
||||
author: dict[str, Any] = Field(default_factory=dict)
|
||||
description: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert workout to dictionary for API upload."""
|
||||
return self.model_dump(exclude_none=True, mode="json")
|
||||
|
||||
|
||||
class RunningWorkout(BaseWorkout):
|
||||
"""Running workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": SportType.RUNNING,
|
||||
"sportTypeKey": "running",
|
||||
"displayOrder": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CyclingWorkout(BaseWorkout):
|
||||
"""Cycling workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": SportType.CYCLING,
|
||||
"sportTypeKey": "cycling",
|
||||
"displayOrder": 2,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SwimmingWorkout(BaseWorkout):
|
||||
"""Swimming workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": SportType.SWIMMING,
|
||||
"sportTypeKey": "swimming",
|
||||
"displayOrder": 3,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class WalkingWorkout(BaseWorkout):
|
||||
"""Walking workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": 17,
|
||||
"sportTypeKey": "walking",
|
||||
"displayOrder": 17,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MultiSportWorkout(BaseWorkout):
|
||||
"""Multi-sport workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": SportType.MULTI_SPORT,
|
||||
"sportTypeKey": "multi_sport",
|
||||
"displayOrder": 10,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FitnessEquipmentWorkout(BaseWorkout):
|
||||
"""Fitness equipment workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": SportType.CARDIO_TRAINING,
|
||||
"sportTypeKey": "cardio_training",
|
||||
"displayOrder": 6,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HikingWorkout(BaseWorkout):
|
||||
"""Hiking workout model."""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": 18,
|
||||
"sportTypeKey": "hiking",
|
||||
"displayOrder": 18,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StrengthWorkout(BaseWorkout):
|
||||
"""Strength training workout model.
|
||||
|
||||
Strength workouts are rep-based rather than time/distance-based. Build the
|
||||
steps with :func:`create_strength_exercise_step` /
|
||||
:func:`create_strength_rest_step` (or the :func:`create_strength_set`
|
||||
convenience), and identify each exercise with a ``category`` /
|
||||
``exerciseName`` pair from :mod:`garminconnect.exercises`.
|
||||
"""
|
||||
|
||||
sportType: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"sportTypeId": SportType.STRENGTH_TRAINING,
|
||||
"sportTypeKey": "strength_training",
|
||||
"displayOrder": 5,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Helper functions for creating common workout steps
|
||||
def create_warmup_step(
|
||||
duration_seconds: float,
|
||||
step_order: int = 1,
|
||||
target_type: dict[str, Any] | None = None,
|
||||
) -> ExecutableStep:
|
||||
"""Create a warmup step."""
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.WARMUP,
|
||||
"stepTypeKey": "warmup",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.TIME,
|
||||
"conditionTypeKey": "time",
|
||||
"displayOrder": 2,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=duration_seconds,
|
||||
targetType=target_type
|
||||
or {
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_interval_step(
|
||||
duration_seconds: float,
|
||||
step_order: int,
|
||||
target_type: dict[str, Any] | None = None,
|
||||
) -> ExecutableStep:
|
||||
"""Create an interval step."""
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.INTERVAL,
|
||||
"stepTypeKey": "interval",
|
||||
"displayOrder": 3,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.TIME,
|
||||
"conditionTypeKey": "time",
|
||||
"displayOrder": 2,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=duration_seconds,
|
||||
targetType=target_type
|
||||
or {
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_distance_interval_step(
|
||||
distance_meters: float,
|
||||
step_order: int,
|
||||
target_type: dict[str, Any] | None = None,
|
||||
) -> ExecutableStep:
|
||||
"""Create an interval step that ends after a distance in meters."""
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.INTERVAL,
|
||||
"stepTypeKey": "interval",
|
||||
"displayOrder": 3,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.DISTANCE,
|
||||
"conditionTypeKey": "distance",
|
||||
"displayOrder": 3,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=distance_meters,
|
||||
targetType=target_type
|
||||
or {
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_recovery_step(
|
||||
duration_seconds: float,
|
||||
step_order: int,
|
||||
target_type: dict[str, Any] | None = None,
|
||||
) -> ExecutableStep:
|
||||
"""Create a recovery step."""
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.RECOVERY,
|
||||
"stepTypeKey": "recovery",
|
||||
"displayOrder": 4,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.TIME,
|
||||
"conditionTypeKey": "time",
|
||||
"displayOrder": 2,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=duration_seconds,
|
||||
targetType=target_type
|
||||
or {
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_cooldown_step(
|
||||
duration_seconds: float,
|
||||
step_order: int,
|
||||
target_type: dict[str, Any] | None = None,
|
||||
) -> ExecutableStep:
|
||||
"""Create a cooldown step."""
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.COOLDOWN,
|
||||
"stepTypeKey": "cooldown",
|
||||
"displayOrder": 2,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.TIME,
|
||||
"conditionTypeKey": "time",
|
||||
"displayOrder": 2,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=duration_seconds,
|
||||
targetType=target_type
|
||||
or {
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_repeat_group(
|
||||
iterations: int,
|
||||
workout_steps: list[ExecutableStep | RepeatGroup],
|
||||
step_order: int,
|
||||
) -> RepeatGroup:
|
||||
"""Create a repeat group."""
|
||||
return RepeatGroup(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.REPEAT,
|
||||
"stepTypeKey": "repeat",
|
||||
"displayOrder": 6,
|
||||
},
|
||||
numberOfIterations=iterations,
|
||||
workoutSteps=workout_steps,
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.ITERATIONS,
|
||||
"conditionTypeKey": "iterations",
|
||||
"displayOrder": 7,
|
||||
"displayable": False,
|
||||
},
|
||||
endConditionValue=float(iterations),
|
||||
)
|
||||
|
||||
|
||||
def create_strength_exercise_step(
|
||||
category: str,
|
||||
step_order: int,
|
||||
reps: int,
|
||||
exercise_name: str = "",
|
||||
weight_kg: float | None = None,
|
||||
) -> ExecutableStep:
|
||||
"""Create a rep-based strength exercise step.
|
||||
|
||||
Args:
|
||||
category: Garmin exercise category, e.g. ``"BENCH_PRESS"``. See
|
||||
:mod:`garminconnect.exercises` for the full list of valid values.
|
||||
step_order: Position of this step within the segment (1-indexed, unique).
|
||||
reps: Number of repetitions to perform.
|
||||
exercise_name: Specific exercise variant, e.g. ``"LAT_PULLDOWN"``. An
|
||||
empty string shows only the category name.
|
||||
weight_kg: Optional target weight in kilograms.
|
||||
|
||||
"""
|
||||
extra: dict[str, Any] = {"category": category, "exerciseName": exercise_name}
|
||||
if weight_kg is not None:
|
||||
extra["weightValue"] = float(weight_kg) * 1000.0
|
||||
extra["weightUnit"] = dict(WEIGHT_UNIT_KILOGRAM)
|
||||
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.INTERVAL,
|
||||
"stepTypeKey": "interval",
|
||||
"displayOrder": 3,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.REPS,
|
||||
"conditionTypeKey": "reps",
|
||||
"displayOrder": 10,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=float(reps),
|
||||
targetType={
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def create_strength_rest_step(
|
||||
duration_seconds: float,
|
||||
step_order: int,
|
||||
) -> ExecutableStep:
|
||||
"""Create a timed rest step between strength sets."""
|
||||
return ExecutableStep(
|
||||
stepOrder=step_order,
|
||||
stepType={
|
||||
"stepTypeId": StepType.REST,
|
||||
"stepTypeKey": "rest",
|
||||
"displayOrder": 5,
|
||||
},
|
||||
endCondition={
|
||||
"conditionTypeId": ConditionType.TIME,
|
||||
"conditionTypeKey": "time",
|
||||
"displayOrder": 2,
|
||||
"displayable": True,
|
||||
},
|
||||
endConditionValue=float(duration_seconds),
|
||||
targetType={
|
||||
"workoutTargetTypeId": TargetType.NO_TARGET,
|
||||
"workoutTargetTypeKey": "no.target",
|
||||
"displayOrder": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_strength_set(
|
||||
category: str,
|
||||
step_order: int,
|
||||
sets: int,
|
||||
reps: int,
|
||||
rest_seconds: float,
|
||||
exercise_name: str = "",
|
||||
weight_kg: float | None = None,
|
||||
) -> RepeatGroup:
|
||||
"""Create a full strength exercise block as a repeat group.
|
||||
|
||||
Produces ``sets`` repetitions of ``reps`` reps of the exercise followed by
|
||||
a timed rest, i.e. one "N Sets" block in the Garmin workout editor.
|
||||
|
||||
``step_order`` is the order of the repeat group; the inner exercise and rest
|
||||
steps take ``step_order + 1`` and ``step_order + 2``. Advance the caller's
|
||||
running order counter by 3 for each block so every ``stepOrder`` is unique.
|
||||
"""
|
||||
exercise = create_strength_exercise_step(
|
||||
category,
|
||||
step_order + 1,
|
||||
reps,
|
||||
exercise_name=exercise_name,
|
||||
weight_kg=weight_kg,
|
||||
)
|
||||
rest = create_strength_rest_step(rest_seconds, step_order + 2)
|
||||
return create_repeat_group(sets, [exercise, rest], step_order)
|
||||
357
pyproject.toml
Normal file
357
pyproject.toml
Normal file
@ -0,0 +1,357 @@
|
||||
[project]
|
||||
name = "garminconnect"
|
||||
version = "0.3.11"
|
||||
description = "Python 3 API wrapper for Garmin Connect"
|
||||
authors = [
|
||||
{ name = "Ron Klinkien", email = "ron@cyberjunky.nl" },
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.33.0",
|
||||
"ua-generator>=1.0",
|
||||
]
|
||||
readme = "README.md"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
keywords = [
|
||||
"garmin connect",
|
||||
"api",
|
||||
"garmin",
|
||||
]
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[project.license]
|
||||
text = "MIT"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/cyberjunky/python-garminconnect"
|
||||
Issues = "https://github.com/cyberjunky/python-garminconnect/issues"
|
||||
Changelog = "https://github.com/cyberjunky/python-garminconnect/releases"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tls = [
|
||||
"curl_cffi>=0.15.0",
|
||||
]
|
||||
dev = [
|
||||
"ipython",
|
||||
"ipdb",
|
||||
"ipykernel",
|
||||
"pandas",
|
||||
"matplotlib",
|
||||
]
|
||||
workout = [
|
||||
"pydantic>=2.4.0",
|
||||
]
|
||||
typed = [
|
||||
"pydantic>=2.4.0",
|
||||
]
|
||||
security = [
|
||||
"bandit[toml]",
|
||||
"pip-audit",
|
||||
]
|
||||
linting = [
|
||||
"black[jupyter]",
|
||||
"ruff",
|
||||
"mypy",
|
||||
"isort",
|
||||
"types-requests",
|
||||
"pre-commit",
|
||||
"codespell",
|
||||
]
|
||||
testing = [
|
||||
"coverage",
|
||||
"pytest",
|
||||
"pytest-vcr>=1.0.2",
|
||||
"vcrpy>=7.0.0",
|
||||
]
|
||||
example = [
|
||||
"readchar",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = [
|
||||
"pdm-backend",
|
||||
]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--ignore=__pypackages__ --ignore-glob=*.yaml -m 'not integration'"
|
||||
markers = [
|
||||
"integration: requires a real Garmin account or a private VCR recording",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
ignore_missing_imports = true
|
||||
python_version = "3.12"
|
||||
disallow_untyped_defs = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"test_garmin",
|
||||
"test_garmin_unit",
|
||||
"test_retry_decorator",
|
||||
"test_workout_constants",
|
||||
"test_strength_workout",
|
||||
"test_login_recovery",
|
||||
"test_token_permissions",
|
||||
"test_widget_mfa",
|
||||
"test_cassette_sanitization",
|
||||
"test_activity_details",
|
||||
"test_demo_security",
|
||||
"conftest",
|
||||
]
|
||||
disallow_untyped_defs = false
|
||||
warn_unused_ignores = false
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 88
|
||||
known_first_party = "garminconnect"
|
||||
skip_glob = [
|
||||
"tests/*",
|
||||
"test_data/*",
|
||||
]
|
||||
|
||||
[tool.pdm]
|
||||
distribution = true
|
||||
|
||||
[tool.pdm.build]
|
||||
excludes = [
|
||||
"tests/**",
|
||||
"test_data/**",
|
||||
".github/**",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts]
|
||||
install = "pdm install --group :all"
|
||||
codespell = "pre-commit run codespell --all-files"
|
||||
clean = "python -c \"import shutil, pathlib; [shutil.rmtree(p, ignore_errors=True) for p in pathlib.Path('.').rglob('__pycache__')]; [p.unlink(missing_ok=True) for p in pathlib.Path('.').rglob('*.py[co]')]\""
|
||||
pre-commit-install = "pre-commit install"
|
||||
pre-commit-run = "pre-commit run --all-files"
|
||||
pre-commit-run-staged = "pre-commit run"
|
||||
pre-commit-update = "pre-commit autoupdate"
|
||||
build = "pdm build"
|
||||
clean-vcr = "python3 -c \"import pathlib; p=pathlib.Path('tests/cassettes'); [f.unlink() for f in p.glob('*.yaml')]\""
|
||||
|
||||
[tool.pdm.scripts.format]
|
||||
composite = [
|
||||
"pdm run ruff check . --fix --unsafe-fixes",
|
||||
"pdm run isort . --skip-gitignore",
|
||||
"pdm run black -l 88 .",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.lint]
|
||||
composite = [
|
||||
"pdm run isort --check-only . --skip-gitignore",
|
||||
"pdm run ruff check .",
|
||||
"pdm run black -l 88 . --check --diff",
|
||||
"pdm run mypy garminconnect tests",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.test]
|
||||
cmd = "pdm run coverage run -m pytest -v --durations=10"
|
||||
|
||||
[tool.pdm.scripts.testcov]
|
||||
composite = [
|
||||
"test",
|
||||
"pdm run coverage html",
|
||||
"pdm run coverage xml -o coverage/coverage.xml",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.publish]
|
||||
composite = [
|
||||
"build",
|
||||
"pdm publish",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.record-vcr]
|
||||
cmd = "pdm run pytest tests/test_garmin.py -v --vcr-record=new_episodes"
|
||||
|
||||
[tool.pdm.scripts.record-vcr.env]
|
||||
GARMINTOKENS = "~/.garminconnect"
|
||||
|
||||
[tool.pdm.scripts.reset-vcr]
|
||||
composite = [
|
||||
"clean-vcr",
|
||||
"record-vcr",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.all]
|
||||
composite = [
|
||||
"lint",
|
||||
"codespell",
|
||||
"pre-commit-run",
|
||||
"test",
|
||||
]
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"ipython",
|
||||
"ipdb",
|
||||
"ipykernel",
|
||||
"pandas",
|
||||
"matplotlib",
|
||||
]
|
||||
linting = [
|
||||
"black[jupyter]",
|
||||
"ruff",
|
||||
"mypy",
|
||||
"isort",
|
||||
"types-requests",
|
||||
"pre-commit",
|
||||
"codespell",
|
||||
]
|
||||
testing = [
|
||||
"coverage",
|
||||
"pytest",
|
||||
"pytest-vcr>=1.0.2",
|
||||
"vcrpy>=7.0.0",
|
||||
]
|
||||
example = [
|
||||
"readchar",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py312"
|
||||
exclude = [
|
||||
".git",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"build",
|
||||
"dist",
|
||||
"tests",
|
||||
"test_data",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E",
|
||||
"W",
|
||||
"F",
|
||||
"I",
|
||||
"B",
|
||||
"C4",
|
||||
"UP",
|
||||
"ARG",
|
||||
"SIM",
|
||||
"S",
|
||||
"D",
|
||||
"PTH",
|
||||
"PL",
|
||||
"RUF",
|
||||
"TRY",
|
||||
"PERF",
|
||||
"LOG",
|
||||
"G",
|
||||
"T20",
|
||||
"PIE",
|
||||
"RET",
|
||||
"TCH",
|
||||
"ERA",
|
||||
]
|
||||
ignore = [
|
||||
"E501",
|
||||
"B008",
|
||||
"C901",
|
||||
"D100",
|
||||
"D104",
|
||||
"D105",
|
||||
"D107",
|
||||
"D203",
|
||||
"D213",
|
||||
"PLR0913",
|
||||
"PLR0917",
|
||||
"PLR2004",
|
||||
"PLR0912",
|
||||
"PLR0915",
|
||||
"PLC0415",
|
||||
"TRY003",
|
||||
"TRY004",
|
||||
"TRY301",
|
||||
"G004",
|
||||
"D205",
|
||||
"D401",
|
||||
"D102",
|
||||
"D106",
|
||||
"D417",
|
||||
"TRY401",
|
||||
"TRY203",
|
||||
"TRY300",
|
||||
"ERA001",
|
||||
]
|
||||
unfixable = []
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"ARG",
|
||||
"S101",
|
||||
"D103",
|
||||
]
|
||||
"garminconnect/fit.py" = [
|
||||
"D",
|
||||
"RUF012",
|
||||
"PLW2901",
|
||||
]
|
||||
"garminconnect/workout.py" = [
|
||||
"D401",
|
||||
]
|
||||
"demo.py" = [
|
||||
"T20",
|
||||
"S101",
|
||||
"ERA",
|
||||
"RUF001",
|
||||
"PTH",
|
||||
"PERF401",
|
||||
"PERF203",
|
||||
"PLR0911",
|
||||
"D103",
|
||||
]
|
||||
"example.py" = [
|
||||
"S110",
|
||||
"PLR0911",
|
||||
"T20",
|
||||
"PERF203",
|
||||
]
|
||||
"test_strategy.py" = [
|
||||
"T20",
|
||||
"D103",
|
||||
]
|
||||
"scripts/*" = [
|
||||
"T20",
|
||||
"D103",
|
||||
]
|
||||
"tests/test_token_permissions.py" = [
|
||||
"S105",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = [
|
||||
"garminconnect",
|
||||
]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/test_*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == \"__main__\":",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
Reference in New Issue
Block a user