diff --git a/README.md b/README.md index 1518a151..b861076a 100644 --- a/README.md +++ b/README.md @@ -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 **154+ API methods** organized into **14 categories** for easy navigation +- **`demo.py`** - Comprehensive demo providing access to **158+ API methods** organized into **14 categories** for easy navigation ```bash $ ./demo.py @@ -50,7 +50,7 @@ Make your selection: ## API Coverage Statistics -- **Total API Methods**: 154+ methods (snapshot) +- **Total API Methods**: 158+ methods (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) @@ -61,7 +61,7 @@ Make your selection: - **Goals & Achievements**: 15 methods (challenges, badges, goals) - **Device & Technical**: 7 methods (device info, settings) - **Gear & Equipment**: 8 methods (gear management, tracking, creation) -- **Hydration & Wellness**: 20 methods (hydration, nutrition, blood pressure, menstrual reads and writes) +- **Hydration & Wellness**: 24 methods (hydration, nutrition, blood pressure, menstrual reads and writes, Jet Lag Adviser trips) - **System & Export**: 5 methods (reporting, logout, GraphQL, health snapshot download) - **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) @@ -128,7 +128,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 (154+ API methods) +python3 ./demo.py # comprehensive demo (158+ API methods) ``` ## ๐Ÿ› ๏ธ Development @@ -241,7 +241,7 @@ 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, weigh-in, and menstrual write methods can change Garmin account data. +delete, schedule, hydration, weigh-in, menstrual, and jet lag trip write methods can change Garmin account data. ## ๐Ÿงช Testing @@ -498,9 +498,34 @@ club_stats = client.get_golf_club_stats() user_stats = client.get_golf_user_stats() ``` +### Jet Lag Adviser + +The library can list, create, replace, and delete Jet Lag Adviser trips. Garmin does not geocode a location: give each place its UTC offset on that date, and give each time as the local time at that place, without an offset. + +Each flight is one origin and destination, so you can include legs as part of a trip or not. Garmin accepts any legs, including a leg that changes no timezone and a trip in the past, which the web page does not allow. Garmin says it deletes a trip by itself after the trip ends. + +```python +flights = [ + { + "originLocation": "Portland, OR, United States", + "originTimezone": "-07:00", + "originTimestamp": "2026-10-04T15:20:00.00", + "destinationLocation": "Melbourne, VIC, Australia", + "destinationTimezone": "+11:00", + "destinationTimestamp": "2026-10-06T06:45:00.00", + }, +] +trip = client.add_jetlag_trip("2026-10-04", "2026-10-09", flights) + +# A PUT replaces the whole trip: send every flight, and keep each flightId +client.update_jetlag_trip(trip["tripId"], trip["startDate"], "2026-10-12", trip["flights"]) + +client.delete_jetlag_trip(trip["tripId"]) +``` + ### 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 154+ API methods +- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 158+ API methods - **API Documentation**: Comprehensive method documentation in source code - **Test Cases**: Real-world usage examples in `tests/` directory diff --git a/demo.py b/demo.py index 53957e55..c1737a06 100755 --- a/demo.py +++ b/demo.py @@ -637,6 +637,19 @@ def __init__(self): "desc": "Update menstrual tracking settings (interactive)", "key": "update_menstrual_settings", }, + "l": {"desc": "Get jet lag trips", "key": "get_jetlag_trips"}, + "m": { + "desc": "Add jet lag trip (interactive)", + "key": "add_jetlag_trip", + }, + "n": { + "desc": "Update jet lag trip (interactive)", + "key": "update_jetlag_trip", + }, + "o": { + "desc": "Delete jet lag trip (interactive)", + "key": "delete_jetlag_trip", + }, }, }, "a": { @@ -4468,6 +4481,113 @@ def update_menstrual_settings_entry(api: Garmin) -> None: print(f"โŒ Error updating menstrual settings: {e}") +JETLAG_FLIGHTS_EXAMPLE = ( + '[{"originLocation": "Portland, OR, United States", ' + '"originTimezone": "-07:00", "originTimestamp": "2026-10-04T15:20:00.00", ' + '"destinationLocation": "Melbourne, VIC 3004, Australia", ' + '"destinationTimezone": "+11:00", ' + '"destinationTimestamp": "2026-10-06T06:45:00.00"}]' +) + + +def _select_jetlag_trip(api: Garmin) -> dict | None: + """List jet lag trips and return the one the user picks.""" + trips = api.get_jetlag_trips() + if not trips: + print("โ„น๏ธ No jet lag trips found") + return None + print("\nJet lag trips:") + for i, trip in enumerate(trips): + print( + f" [{i}] {trip.get('startDate')} to {trip.get('endDate')}, " + f"{len(trip.get('flights', []))} flight(s) (ID: {trip.get('tripId')})" + ) + index_input = input( + f"\nEnter trip index (0-{len(trips) - 1}, or 'q' to cancel): " + ).strip() + if index_input.lower() == "q": + print("โŒ Cancelled") + return None + index = int(index_input) + if not 0 <= index < len(trips): + print("โŒ Invalid index") + return None + return trips[index] + + +def add_jetlag_trip_entry(api: Garmin) -> None: + """Create a jet lag trip from dates and a pasted JSON list of flights.""" + try: + start_date = input("Start date (YYYY-MM-DD): ").strip() + end_date = input("End date (YYYY-MM-DD): ").strip() + print("Paste the flights as a JSON list. Times are local, with no offset.") + print(f"Example: {JETLAG_FLIGHTS_EXAMPLE}") + flights = json.loads(input("Flights JSON: ").strip()) + call_and_display( + api.add_jetlag_trip, + start_date, + end_date, + flights, + method_name="add_jetlag_trip", + api_call_desc=f"api.add_jetlag_trip('{start_date}', '{end_date}', [...])", + ) + except Exception as e: + print(f"โŒ Error adding jet lag trip: {e}") + + +def update_jetlag_trip_entry(api: Garmin) -> None: + """Replace the dates and, optionally, the flights of a jet lag trip.""" + try: + trip = _select_jetlag_trip(api) + if trip is None: + return + trip_id = trip["tripId"] + start_date = ( + input(f"Start date [{trip['startDate']}]: ").strip() or trip["startDate"] + ) + end_date = input(f"End date [{trip['endDate']}]: ").strip() or trip["endDate"] + print("Paste a JSON list to replace all flights, or press Enter to keep them.") + raw = input("Flights JSON: ").strip() + flights = json.loads(raw) if raw else trip["flights"] + call_and_display( + api.update_jetlag_trip, + trip_id, + start_date, + end_date, + flights, + method_name="update_jetlag_trip", + api_call_desc=( + f"api.update_jetlag_trip({trip_id}, '{start_date}', '{end_date}', [...])" + ), + ) + except Exception as e: + print(f"โŒ Error updating jet lag trip: {e}") + + +def delete_jetlag_trip_entry(api: Garmin) -> None: + """Delete a jet lag trip after a y/N confirmation.""" + try: + trip = _select_jetlag_trip(api) + if trip is None: + return + trip_id = trip["tripId"] + confirm = input( + f"Delete trip {trip['startDate']} to {trip['endDate']} " + f"(ID: {trip_id})? (y/N): " + ).strip() + if confirm.lower() != "y": + print("โŒ Cancelled") + return + call_and_display( + api.delete_jetlag_trip, + trip_id, + method_name="delete_jetlag_trip", + api_call_desc=f"api.delete_jetlag_trip({trip_id})", + ) + except Exception as e: + print(f"โŒ Error deleting jet lag trip: {e}") + + def set_blood_pressure_data(api: Garmin) -> None: """Set blood pressure (and pulse) data.""" try: @@ -5145,6 +5265,15 @@ def execute_api_call(api: Garmin, key: str) -> None: lambda: confirm_menstrual_period_start_entry(api) ), "update_menstrual_settings": lambda: update_menstrual_settings_entry(api), + # Jet lag + "get_jetlag_trips": lambda: call_and_display( + api.get_jetlag_trips, + method_name="get_jetlag_trips", + api_call_desc="api.get_jetlag_trips()", + ), + "add_jetlag_trip": lambda: add_jetlag_trip_entry(api), + "update_jetlag_trip": lambda: update_jetlag_trip_entry(api), + "delete_jetlag_trip": lambda: delete_jetlag_trip_entry(api), # Nutrition "get_nutrition_daily_food_log": lambda: call_and_display( api.get_nutrition_daily_food_log, diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index 1f1015f9..2ad41335 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -103,6 +103,16 @@ VALID_MENSTRUAL_SEX_DRIVE = frozenset({"AVERAGE", "HIGH", "LOW"}) VALID_MENSTRUAL_SEXUAL_ACTIVITY = frozenset({"PROTECTED", "UNPROTECTED"}) VALID_MENSTRUAL_REPORT_CYCLES = frozenset({1, 6, 12}) +JETLAG_FLIGHT_KEYS = ( + "originLocation", + "originTimezone", + "originTimestamp", + "destinationLocation", + "destinationTimezone", + "destinationTimestamp", +) +# Real UTC offsets run from -12:00 to +14:00. +UTC_OFFSET_REGEX = r"^[+-](0\d|1[0-4]):[0-5]\d$" # Add validation utilities @@ -309,6 +319,46 @@ def _clean_menstrual_daily_log(payload: dict[str, Any]) -> dict[str, Any]: return cleaned +def _validate_jetlag_flights(flights: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Validate Jet Lag Adviser flights and return shallow copies.""" + if not isinstance(flights, list) or not flights: + raise ValueError("flights must be a non-empty list") + result: list[dict[str, Any]] = [] + for index, flight in enumerate(flights): + name = f"flights[{index}]" + if not isinstance(flight, dict): + raise ValueError(f"{name} must be a dictionary") + missing = [key for key in JETLAG_FLIGHT_KEYS if not flight.get(key)] + if missing: + raise ValueError(f"{name} is missing {', '.join(missing)}") + for side in ("origin", "destination"): + offset = flight[f"{side}Timezone"] + if not isinstance(offset, str) or not re.fullmatch( + UTC_OFFSET_REGEX, offset + ): + raise ValueError( + f"{name}.{side}Timezone must be a UTC offset such as " + f"'-07:00', got: {offset!r}" + ) + raw_stamp = flight[f"{side}Timestamp"] + # fromisoformat() reads a date alone as midnight. + if not isinstance(raw_stamp, str) or "T" not in raw_stamp: + raise ValueError( + f"invalid {name}.{side}Timestamp: must hold a date and a " + f"time such as '2026-10-04T15:20:00', got: {raw_stamp!r}" + ) + try: + stamp = datetime.fromisoformat(raw_stamp) + except ValueError as e: + raise ValueError(f"invalid {name}.{side}Timestamp: {e}") from e + if stamp.tzinfo is not None: + raise ValueError( + f"{name}.{side}Timestamp must be local time without an offset" + ) + result.append(dict(flight)) + return result + + def _validate_json_exists(response: requests.Response) -> dict[str, Any] | None: if response.status_code == 204: return None @@ -692,6 +742,7 @@ def __init__( self.garmin_connect_pregnancy_snapshot_url = ( "/periodichealth-service/menstrualcycle/pregnancysnapshot" ) + self.garmin_connect_jetlag_trip_url = "/jetlag-service/jetlag/trip" self.garmin_connect_goals_url = "/goal-service/goal/goals" self.garmin_connect_rhr_url = "/userstats-service/wellness/daily" @@ -4296,6 +4347,91 @@ def update_menstrual_settings( api=True, ) + def get_jetlag_trips(self) -> list[dict[str, Any]]: + """Return all Jet Lag Adviser trips, each with its flights.""" + url = f"{self.garmin_connect_jetlag_trip_url}/all" + logger.debug("Requesting jet lag trips") + return self.connectapi(url) + + def _jetlag_trip_payload( + self, start_date: str, end_date: str, flights: list[dict[str, Any]] + ) -> dict[str, Any]: + start_date, end_date = _validate_date_range(start_date, end_date) + payload: dict[str, Any] = { + "startDate": start_date, + "endDate": end_date, + "flights": _validate_jetlag_flights(flights), + } + if self.profile_id is not None: + payload["userId"] = self.profile_id + return payload + + def add_jetlag_trip( + self, start_date: str, end_date: str, flights: list[dict[str, Any]] + ) -> dict[str, Any]: + """Create a Jet Lag Adviser trip. + + Each flight is a dict with ``originLocation``, ``originTimezone`` and + ``originTimestamp``, and the same three ``destination*`` keys. A + location is free text. A timezone is the UTC offset at that place on + that date, such as ``"-07:00"``. A timestamp is the local time at that + place without an offset, such as ``"2026-10-04T15:20:00.00"``. Garmin + does not geocode the location, so the offset must be correct. + + Each flight is one leg, so a connection is two flights (PDX to MSP, + then MSP to ATL). Garmin does not check that the legs connect or + that a leg changes timezone. It also accepts dates in the past and a + ``start_date`` before the first flight, which the web page does not + allow. The web page names a trip after its first flight only, and + shows the first departure to the last arrival. Garmin deletes a trip + by itself after the trip ends and the user has adjusted to the home + timezone. Garmin can refuse a trip whose dates overlap an existing + trip, with HTTP 400 "A trip already exists for this date range". + + Returns the trip with its new ``tripId`` and a ``flightId`` on each + flight. ``dailyStatus``, the guidance that Garmin calculates, is not + in this response. It appears in :meth:`get_jetlag_trips` only after + travel starts. + """ + payload = self._jetlag_trip_payload(start_date, end_date, flights) + logger.debug("Creating jet lag trip %s to %s", start_date, end_date) + return self.client.post( + "connectapi", self.garmin_connect_jetlag_trip_url, json=payload, api=True + ) + + def update_jetlag_trip( + self, + trip_id: int | str, + start_date: str, + end_date: str, + flights: list[dict[str, Any]], + ) -> Any: + """Replace a Jet Lag Adviser trip. + + The PUT replaces the whole trip, so ``flights`` must hold every flight, + in the format of :meth:`add_jetlag_trip`. Keep the ``flightId`` from + :meth:`get_jetlag_trips` on each flight that stays. ``tripId`` is set + to ``trip_id`` on the trip and on each flight. Garmin answers with an + empty body. + """ + trip_id = _validate_positive_integer(int(trip_id), "trip_id") + payload = self._jetlag_trip_payload(start_date, end_date, flights) + payload["tripId"] = trip_id + payload["flights"] = [ + flight | {"tripId": trip_id} for flight in payload["flights"] + ] + logger.debug("Updating jet lag trip %s", trip_id) + return self.client.put( + "connectapi", self.garmin_connect_jetlag_trip_url, json=payload, api=True + ) + + def delete_jetlag_trip(self, trip_id: int | str) -> Any: + """Delete a Jet Lag Adviser trip. Garmin answers with an empty body.""" + trip_id = _validate_positive_integer(int(trip_id), "trip_id") + url = f"{self.garmin_connect_jetlag_trip_url}/{trip_id}" + logger.debug("Deleting jet lag trip %s", trip_id) + return self.client.delete("connectapi", url, api=True) + def query_garmin_graphql(self, query: dict[str, Any]) -> dict[str, Any]: """Execute a POST to Garmin's GraphQL endpoint. diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 23055485..e6a2e9be 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -3062,6 +3062,151 @@ def test_reports_builds_query(self, garmin: garminconnect.Garmin): } +# --------------------------------------------------------------------------- +# Jet Lag Adviser trips +# --------------------------------------------------------------------------- + + +def _jetlag_flight(**overrides: Any) -> dict[str, Any]: + return { + "originLocation": "Portland, OR, United States", + "originTimezone": "-07:00", + "originTimestamp": "2026-10-04T15:20:00.00", + "destinationLocation": "Melbourne, VIC 3004, Australia", + "destinationTimezone": "+11:00", + "destinationTimestamp": "2026-10-06T06:45:00.00", + } | overrides + + +class TestJetLagTrips: + """Jet Lag Adviser trips live under /jetlag-service/jetlag/trip.""" + + def test_get_trips_requests_all(self, garmin: garminconnect.Garmin): + with patch.object(garmin, "connectapi", return_value=[]) as mock: + assert garmin.get_jetlag_trips() == [] + mock.assert_called_once_with("/jetlag-service/jetlag/trip/all") + + def test_add_posts_trip_with_user_id(self, garmin: garminconnect.Garmin): + garmin.profile_id = 99 + flight = _jetlag_flight() + with patch.object(garmin, "client") as client: + client.post.return_value = {"tripId": 7} + result = garmin.add_jetlag_trip("2026-10-04", "2026-10-09", [flight]) + + args, kwargs = client.post.call_args + assert args == ("connectapi", "/jetlag-service/jetlag/trip") + assert kwargs["api"] is True + assert kwargs["json"] == { + "startDate": "2026-10-04", + "endDate": "2026-10-09", + "flights": [flight], + "userId": 99, + } + assert result == {"tripId": 7} + + @pytest.mark.parametrize("offset", ["-12:00", "+05:45", "+14:00"]) + def test_add_accepts_real_offsets( + self, garmin: garminconnect.Garmin, offset: str + ): + flight = _jetlag_flight(destinationTimezone=offset) + with patch.object(garmin, "client") as client: + garmin.add_jetlag_trip("2026-10-04", "2026-10-09", [flight]) + assert client.post.call_args.kwargs["json"]["flights"] == [flight] + + def test_add_omits_user_id_without_profile(self, garmin: garminconnect.Garmin): + with patch.object(garmin, "client") as client: + garmin.add_jetlag_trip("2026-10-04", "2026-10-09", [_jetlag_flight()]) + assert "userId" not in client.post.call_args.kwargs["json"] + + def test_update_puts_trip_id_on_trip_and_flights( + self, garmin: garminconnect.Garmin + ): + flight = _jetlag_flight(flightId=5, tripId=999) + with patch.object(garmin, "client") as client: + garmin.update_jetlag_trip("7", "2026-10-04", "2026-10-12", [flight]) + + args, kwargs = client.put.call_args + assert args == ("connectapi", "/jetlag-service/jetlag/trip") + assert kwargs["api"] is True + assert kwargs["json"]["tripId"] == 7 + assert kwargs["json"]["endDate"] == "2026-10-12" + assert kwargs["json"]["flights"] == [flight | {"tripId": 7}] + # caller dict is not mutated + assert flight["tripId"] == 999 + + def test_delete_uses_trip_id_in_path(self, garmin: garminconnect.Garmin): + with patch.object(garmin, "client") as client: + garmin.delete_jetlag_trip(7) + client.delete.assert_called_once_with( + "connectapi", "/jetlag-service/jetlag/trip/7", api=True + ) + + @pytest.mark.parametrize("method", ["update_jetlag_trip", "delete_jetlag_trip"]) + def test_rejects_non_positive_trip_id( + self, garmin: garminconnect.Garmin, method: str + ): + args: tuple[Any, ...] = (0,) + if method == "update_jetlag_trip": + args += ("2026-10-04", "2026-10-09", [_jetlag_flight()]) + with patch.object(garmin, "client") as client: + with pytest.raises(ValueError, match="positive integer"): + getattr(garmin, method)(*args) + assert not client.method_calls + + def test_rejects_end_before_start(self, garmin: garminconnect.Garmin): + with patch.object(garmin, "client") as client: + with pytest.raises(ValueError, match="start date cannot be after"): + garmin.add_jetlag_trip("2026-10-09", "2026-10-04", [_jetlag_flight()]) + client.post.assert_not_called() + + @pytest.mark.parametrize( + ("flights", "message"), + [ + ([], "non-empty list"), + (["flight"], r"flights\[0\] must be a dictionary"), + ( + [_jetlag_flight(originLocation="")], + r"flights\[0\] is missing originLocation", + ), + ( + [_jetlag_flight(destinationTimezone="Australia/Melbourne")], + "destinationTimezone must be a UTC offset", + ), + ( + [_jetlag_flight(originTimezone="+99:99")], + "originTimezone must be a UTC offset", + ), + ( + [_jetlag_flight(originTimezone="+15:00")], + "originTimezone must be a UTC offset", + ), + ( + [_jetlag_flight(originTimestamp="2026-10-04 3:20pm")], + r"invalid flights\[0\]\.originTimestamp", + ), + ( + [_jetlag_flight(destinationTimestamp="2026-10-06")], + r"invalid flights\[0\]\.destinationTimestamp: must hold a date", + ), + ( + [_jetlag_flight(originTimestamp="2026-10-04T25:00:00")], + r"invalid flights\[0\]\.originTimestamp", + ), + ( + [_jetlag_flight(originTimestamp="2026-10-04T15:20:00-07:00")], + "local time without an offset", + ), + ], + ) + def test_rejects_invalid_flights( + self, garmin: garminconnect.Garmin, flights: list[Any], message: str + ): + with patch.object(garmin, "client") as client: + with pytest.raises(ValueError, match=message): + garmin.add_jetlag_trip("2026-10-04", "2026-10-09", flights) + client.post.assert_not_called() + + # --------------------------------------------------------------------------- # Activity upload filename handling # ---------------------------------------------------------------------------