Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
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
- **`demo.py`** - Comprehensive demo providing access to **145+ API methods** organized into **14 categories** for easy navigation

```bash
$ ./demo.py
Expand Down Expand Up @@ -50,21 +50,22 @@ Make your selection:

## API Coverage Statistics

- **Total API Methods**: 144+ unique endpoints (snapshot)
- **Categories**: 13 organized sections
- **Total API Methods**: 145+ unique endpoints (snapshot)
- **Categories**: 14 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)
- **Activities & Workouts**: 36 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, activity type/subtype filtering)
- **Body Composition & Weight**: 7 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)
- **Gear & Equipment**: 8 methods (gear management, tracking, creation)
- **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)
- **Training Plans**: 9 methods (plans, plan by ID, typed strength workout upload, exercise catalog search, in-place workout editing, push to device, workout scheduling management)
- **Golf**: 5 methods (scorecard summary, scorecard detail, shot data, club stats, user stats)
- **Activity Editing**: 2 methods (set activity description, set strength-activity exercise sets)

### Interactive Features

Expand Down Expand Up @@ -113,7 +114,7 @@ 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)
python3 ./demo.py # comprehensive demo (145+ API methods)
```

## 🛠️ Development
Expand Down Expand Up @@ -459,7 +460,7 @@ 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
- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 145+ API methods
- **API Documentation**: Comprehensive method documentation in source code
- **Test Cases**: Real-world usage examples in `tests/` directory

Expand Down
122 changes: 120 additions & 2 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ def __init__(self):
self.start_badge = 1 # Badge related calls start counting at 1

# Activity settings
self.activitytype = "" # Possible values: cycling, running, swimming, multi_sport, fitness_equipment, hiking, walking, other
self.activityfile = "test_data/*.gpx" # Supported file types: .fit .gpx .tcx
self.workoutfile = "test_data/sample_workout.json" # Sample workout JSON file

Expand Down Expand Up @@ -434,6 +433,10 @@ def __init__(self):
"desc": "Upload typed hiking workout (sample)",
"key": "upload_hiking_workout",
},
"A": {
"desc": "Get activities filtered by type/subtype (interactive)",
"key": "get_activities_filtered",
},
},
},
"6": {
Expand Down Expand Up @@ -532,6 +535,10 @@ def __init__(self):
"desc": "Add and remove gear to/from activity (interactive)",
"key": "add_and_remove_gear_to_activity",
},
"8": {
"desc": "Create new gear, e.g. shoes (interactive)",
"key": "create_gear",
},
},
},
"0": {
Expand Down Expand Up @@ -3491,6 +3498,115 @@ def add_and_remove_gear_to_activity(api: Garmin) -> None:
print(f"❌ Error adding gear: {e}")


def get_activities_filtered_data(api: Garmin) -> None:
"""Get activities filtered by type/subtype, picked from the account's own activity type list."""
try:
activity_types = api.get_activity_types()
print("\nAvailable activity types:")
for i, activity_type in enumerate(activity_types):
print(
f"{i}: {activity_type.get('typeKey', 'Unknown')} - {activity_type.get('display', 'No description')}"
)

type_index = input(
"\nEnter activity type index to filter by (blank for no filter): "
).strip()

activitytype = None
if type_index:
try:
idx = int(type_index)
if 0 <= idx < len(activity_types):
activitytype = activity_types[idx]["typeKey"]
else:
print("❌ Invalid index, no type filter applied")
except ValueError:
print("❌ Invalid index, no type filter applied")

activitysubtype = None
if activitytype:
hint = (
" (e.g. 'strength_training')"
if activitytype == "fitness_equipment"
else ""
)
activitysubtype = (
input(f"Activity subtype{hint} (blank for none): ").strip() or None
)

call_and_display(
api.get_activities,
config.start,
config.default_limit,
activitytype=activitytype,
activitysubtype=activitysubtype,
method_name="get_activities_filtered",
api_call_desc=(
f"api.get_activities({config.start}, {config.default_limit}, "
f"activitytype={activitytype!r}, activitysubtype={activitysubtype!r})"
),
)
except Exception as e:
print(f"❌ Error getting filtered activities: {e}")


def create_gear_data(api: Garmin) -> None:
"""Create a new piece of gear, e.g. a pair of shoes."""
try:
print("Creating new gear...")
print("Enter gear details (press Enter for defaults):")

gear_type = input("Gear type [SHOES]: ").strip() or "SHOES"
brand = input("Brand [Anta]: ").strip() or "Anta"
model = input("Model [A-Flash]: ").strip() or "A-Flash"
name = input("Nickname [Test]: ").strip() or "Test"
first_use_date = (
input(f"First use date [{config.today.isoformat()}]: ").strip()
or config.today.isoformat()
)
usage_type = input("Usage tracking type [DISTANCE]: ").strip() or "DISTANCE"
max_km = input("Max use threshold in km (blank for none): ").strip()
activity_types_input = input(
"Default activity types, comma-separated [running]: "
).strip()
activity_type_keys = [
key.strip()
for key in (activity_types_input or "running").split(",")
if key.strip()
]
notes = input("Notes (blank for none): ").strip()

try:
max_usage_distance_km = float(max_km) if max_km else None

success, _ = call_and_display(
api.create_gear,
gear_type=gear_type,
brand=brand,
model=model,
name=name,
first_use_date=first_use_date,
usage_type=usage_type,
max_usage_distance_km=max_usage_distance_km,
notes=notes,
activity_type_keys=activity_type_keys,
method_name="create_gear",
api_call_desc=(
f"api.create_gear(gear_type='{gear_type}', brand='{brand}', "
f"model='{model}', name='{name}', "
f"first_use_date='{first_use_date}', usage_type='{usage_type}', "
f"max_usage_distance_km={max_usage_distance_km}, "
f"activity_type_keys={activity_type_keys})"
),
)
if success:
print("✅ Gear created!")
except ValueError:
print("❌ Invalid numeric input")
except Exception as e:
print(f"❌ Error creating gear: {e}")


def set_activity_name_data(api: Garmin) -> None:
"""Set activity name."""
try:
Expand Down Expand Up @@ -4351,6 +4467,7 @@ def execute_api_call(api: Garmin, key: str) -> None:
method_name="get_activities",
api_call_desc=f"api.get_activities({config.start}, {config.default_limit})",
),
"get_activities_filtered": lambda: get_activities_filtered_data(api),
"get_last_activity": lambda: call_and_display(
api.get_last_activity,
method_name="get_last_activity",
Expand Down Expand Up @@ -4596,6 +4713,7 @@ def execute_api_call(api: Garmin, key: str) -> None:
"add_and_remove_gear_to_activity": lambda: add_and_remove_gear_to_activity(
api
),
"create_gear": lambda: create_gear_data(api),
# Hydration & Wellness
"get_hydration_data": lambda: call_and_display(
api.get_hydration_data,
Expand Down Expand Up @@ -4899,7 +5017,7 @@ def main():
# Handle category menu options
if option == "q":
current_category = None # Back to main menu
elif option in "0123456789abcdefghijklmnopqrstuvwxyz":
elif option in "0123456789abcdefghijklmnopqrstuvwxyzA":
try:
category_data = menu_categories[current_category]
category_options = category_data["options"]
Expand Down
114 changes: 113 additions & 1 deletion garminconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import contextlib
import functools
import logging
import math
import numbers
import os
import random
Expand Down Expand Up @@ -95,13 +96,16 @@ def _validate_date_range(start: str, end: str) -> tuple[str, str]:
def _validate_positive_number(
value: int | float, param_name: str = "value"
) -> int | float:
"""Validate that a number is positive."""
"""Validate that a number is positive and finite."""
if not isinstance(value, numbers.Real):
raise ValueError(f"{param_name} must be a number")

if isinstance(value, bool):
raise ValueError(f"{param_name} must be a number, not bool")

if not math.isfinite(value):
raise ValueError(f"{param_name} must be finite, got: {value}")

if value <= 0:
raise ValueError(f"{param_name} must be positive, got: {value}")

Expand Down Expand Up @@ -2777,6 +2781,114 @@ def get_gear(self, userProfileNumber: str) -> dict[str, Any]:

return self.connectapi(url, params={"userProfilePk": userProfileNumber})

def create_gear(
self,
gear_type: str,
brand: str,
model: str,
name: str,
first_use_date: str,
usage_type: str = "DISTANCE",
max_usage_distance_km: float | None = None,
max_usage_duration_min: float | None = None,
notes: str = "",
activity_type_keys: list[str] | None = None,
) -> Any:
"""Create a new piece of gear (e.g. a pair of shoes) and return it.

Mirrors the payload the Garmin Connect web "Add Gear" form sends to
``gear-service/gear/v2``. Only ``gear_type="SHOES"`` and
``usage_type="DISTANCE"`` have been confirmed against a real
account; other gear/usage type values are almost certainly also
SCREAMING_SNAKE_CASE (e.g. "BIKE", "TIME") but are unverified — if
one is rejected, check the "Gear Type"/"Usage Tracking" dropdown
option values on the Garmin Connect "Add Gear" page.

:param gear_type: Gear category, e.g. "SHOES".
:param brand: Brand/make name, e.g. "Anta".
:param model: Model name, e.g. "A-Flash".
:param name: Nickname shown in Garmin Connect, e.g. "Test".
:param first_use_date: Date gear was first used, "YYYY-MM-DD".
:param usage_type: How usage is tracked, e.g. "DISTANCE" or "TIME".
:param max_usage_distance_km: Optional retirement threshold in km.
:param max_usage_duration_min: Optional retirement threshold in minutes.
:param notes: Optional free-text notes.
:param activity_type_keys: Optional activity type keys (e.g.
["running"], lowercase — matching :meth:`get_activities`'
``activitytype``, not :meth:`set_gear_default`'s uppercase
convention) to associate as default gear for those activities.
:return: The created gear record from Garmin.
"""
gear_type = _validate_sport_key(gear_type, "gear_type")
usage_type = _validate_sport_key(usage_type, "usage_type")
if not isinstance(brand, str) or not brand.strip():
raise ValueError("brand must be a non-empty string")
if not isinstance(model, str) or not model.strip():
raise ValueError("model must be a non-empty string")
if not isinstance(name, str) or not name.strip():
raise ValueError("name must be a non-empty string")
first_use_date = _validate_date_format(first_use_date, "first_use_date")

max_usage_distance_meters = 0
if max_usage_distance_km is not None:
max_usage_distance_meters = round(
_validate_positive_number(
max_usage_distance_km, "max_usage_distance_km"
)
* 1000
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if max_usage_distance_meters < 1:
raise ValueError(
"max_usage_distance_km must be at least 0.001 (1 meter) — "
"a smaller value would round down to 0, which means "
"'no threshold' rather than the value requested"
)

max_usage_duration_seconds = 0
if max_usage_duration_min is not None:
max_usage_duration_seconds = round(
_validate_positive_number(
max_usage_duration_min, "max_usage_duration_min"
)
* 60
)
if max_usage_duration_seconds < 1:
raise ValueError(
"max_usage_duration_min must be at least 1/60 (1 second) — "
"a smaller value would round down to 0, which means "
"'no threshold' rather than the value requested"
)

if activity_type_keys is not None and not isinstance(activity_type_keys, list):
raise ValueError("activity_type_keys must be a list of strings")

associated_activity_types = []
for key in activity_type_keys or []:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not isinstance(key, str) or not key.strip():
raise ValueError("activity_type_keys entries must be non-empty strings")
associated_activity_types.append(
{"activityTypeKey": key, "defaultGear": True, "preferredGear": False}
)

payload = {
"uuid": None,
"gearType": gear_type,
"brand": brand,
"model": model,
"name": name,
"firstUseDate": first_use_date,
"maxUsageDate": None,
"maxUsageDistanceMeters": max_usage_distance_meters,
"maxUsageDurationSeconds": max_usage_duration_seconds,
"usageType": usage_type,
"notes": notes,
"associatedActivityTypes": associated_activity_types,
}

url = f"{self.garmin_connect_gear_baseurl}/v2"
logger.debug("Creating gear: %s", payload)
return self.client.post("connectapi", url, json=payload, api=True)

def get_gear_stats(self, gearUUID: str) -> dict[str, Any]:
"""Return statistics (e.g. distance) for specific gear UUID."""
gearUUID = _validate_uuid(gearUUID, "gearUUID")
Expand Down
Loading
Loading