Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
bbd56d2
Fix missing async keyword, see https://github.com/home-assistant/core…
marc7s Nov 3, 2024
a66e14e
Add nearby caches to status
marc7s Nov 4, 2024
5f1a5d8
Change trackable distance traveled data type
marc7s Nov 4, 2024
a820811
Fix missing status initialization for nearby caches
marc7s Nov 20, 2024
f0631a1
Fix typo causing cache coordinates not to be parsed
marc7s Nov 21, 2024
a018bed
Added update_caches and _get_cache_info, with some more stuff, have n…
hulkper Nov 22, 2024
6b21457
add seperate call for geocaches
Nov 24, 2024
425be24
Started working on the trackable journey list, not yet tested /Albin Per
hulkper Nov 24, 2024
caf5beb
fix for appending to array in trackable journey api
Nov 24, 2024
9d4af3a
add support for trackable objects
Nov 24, 2024
dea1433
uncommented code
Nov 24, 2024
051db11
Revert formatting
marc7s Nov 24, 2024
e98dd84
Fix errors and add missing cache data points
marc7s Nov 24, 2024
aabbc74
Rename caches -> tracked_caches
marc7s Nov 24, 2024
c40377e
Correctly parse location data for caches
marc7s Nov 24, 2024
3176b7f
Rename caches_codes -> cache_codes
marc7s Nov 24, 2024
b4a77fb
Merge pull request #2 from marc7s/cache-info-trackable-journeys
marc7s Nov 24, 2024
26be5b3
Fix trackable parsing, add additonal fields to caches and trackables,…
marc7s Nov 25, 2024
11f252c
Formatting
marc7s Nov 25, 2024
6d89c90
Merge pull request #3 from marc7s/fix-trackable-parsing
marc7s Nov 25, 2024
71fd60f
Always return original value if new value could not be parsed
marc7s Nov 27, 2024
fdc0c4a
Update NearbyCachesSetting
belgien Nov 27, 2024
ee8343e
Rename cache update function
marc7s Nov 27, 2024
50c7001
Replace cache find count with found date and found switch
marc7s Nov 27, 2024
73c041f
Rework foundByUser to be nullable
marc7s Nov 27, 2024
ff85ef5
Fix nearby caches update condition and limit take parameter for API
marc7s Nov 27, 2024
c1c6653
Merge pull request #4 from marc7s/input-nearby-caches
marc7s Nov 27, 2024
2001c49
Merge pull request #5 from marc7s/change_find_count
belgien Nov 27, 2024
412e898
Remove unused function, align variables and functions to snake_case
marc7s Nov 27, 2024
384e2ed
Merge pull request #6 from marc7s/cleanup
belgien Nov 28, 2024
a4030e4
Improve trackable journey data and change API endpoint
marc7s Nov 28, 2024
674f43f
Reverse geocode trackable journey locations
marc7s Nov 28, 2024
45bf373
Add distance between journeys
marc7s Nov 28, 2024
7452276
Handle blocking reverse geocoding outside of init function
marc7s Nov 28, 2024
1785fd7
Merge pull request #7 from marc7s/trackable-journey
belgien Nov 28, 2024
9a457b7
Separate nearby caches logic to allow directed usage
marc7s Dec 5, 2024
9379c99
Merge pull request #8 from marc7s/nearby-caches-separation
belgien Dec 9, 2024
75285e8
Add comments and improve documentation
marc7s Dec 9, 2024
a672bfc
Merge pull request #9 from marc7s/docs
belgien Dec 9, 2024
411aa0a
Add settings validation method
marc7s Dec 9, 2024
9623de5
Merge pull request #10 from marc7s/settings-validation
belgien Dec 12, 2024
7586c3d
Add cache and trackable URLs
marc7s Dec 12, 2024
0692fd5
Merge pull request #11 from marc7s/urls
belgien Dec 12, 2024
2524b00
Formatting and cleanup
marc7s Dec 12, 2024
1160118
Merge pull request #12 from marc7s/cleanup
belgien Dec 12, 2024
074fad6
Update test
marc7s Dec 12, 2024
08b5a4f
Limits: cache and trackable limits in settings and in API call. Error…
marc7s Dec 14, 2024
8171c85
Move limits to limits.py
marc7s Dec 14, 2024
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
13 changes: 13 additions & 0 deletions geocachingapi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,16 @@
2: "Charter",
3: "Premium"
}

# Required parameters for fetching caches in order to generate complete GeocachingCache objects
CACHE_FIELDS_PARAMETER: str = ",".join([
"referenceCode",
"name",
"owner",
"postedCoordinates",
"url",
"favoritePoints",
"userData",
"placedDate",
"location"
])
11 changes: 10 additions & 1 deletion geocachingapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
"""Exceptions for the Gecaching API."""
"""Exceptions for the Geocaching API."""

class GeocachingApiError(Exception):
"""Generic GeocachingApi exception."""

class GeocachingInvalidSettingsError(Exception):
"""GeocachingApi invalid settings exception."""
def __init__(self, code_type: str, invalid_codes: set[str]):
super().__init__(f"Invalid {code_type} codes: {', '.join(invalid_codes)}")

class GeocachingTooManyCodesError(GeocachingApiError):
"""GeocachingApi settings exception: too many codes."""
def __init__(self, message: str):
super().__init__(message)

class GeocachingApiConnectionError(GeocachingApiError):
"""GeocachingApi connection exception."""
Expand Down
164 changes: 150 additions & 14 deletions geocachingapi/geocachingapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@
from yarl import URL
from aiohttp import ClientResponse, ClientSession, ClientError

from typing import Any, Awaitable, Callable, Dict, List, Optional
from .const import ENVIRONMENT_SETTINGS
from typing import Any, Awaitable, Callable, Dict, Optional
from .const import ENVIRONMENT_SETTINGS, CACHE_FIELDS_PARAMETER
from .limits import MAXIMUM_NEARBY_CACHES
from .exceptions import (
GeocachingApiConnectionError,
GeocachingApiConnectionTimeoutError,
GeocachingApiError,
GeocachingApiRateLimitError,
GeocachingInvalidSettingsError,
)
from .utils import clamp

from .models import (
GeocachingCache,
GeocachingCoordinate,
GeocachingStatus,
GeocachingSettings,
GeocachingApiEnvironment,
Expand Down Expand Up @@ -50,7 +55,7 @@ def __init__(
"""Initialize connection with the Geocaching API."""
self._environment_settings = ENVIRONMENT_SETTINGS[environment]
self._status = GeocachingStatus()
self._settings = settings or GeocachingSettings(False)
self._settings = settings or GeocachingSettings()
self._session = session
self.request_timeout = request_timeout
self.token = token
Expand Down Expand Up @@ -90,7 +95,7 @@ async def _request(self, method, uri, **kwargs) -> ClientResponse:
self._close_session = True

try:
with async_timeout.timeout(self.request_timeout):
async with async_timeout.timeout(self.request_timeout):
response = await self._session.request(
method,
f"{url}",
Expand Down Expand Up @@ -135,14 +140,35 @@ async def _request(self, method, uri, **kwargs) -> ClientResponse:
_LOGGER.debug(f'Response:')
_LOGGER.debug(f'{str(result)}')
return result

def _tracked_trackables_enabled(self) -> bool:
return len(self._settings.tracked_trackable_codes) > 0

def _tracked_caches_enabled(self) -> bool:
return len(self._settings.tracked_cache_codes) > 0

def _nearby_caches_enabled(self) -> bool:
return self._settings.nearby_caches_setting is not None and self._settings.nearby_caches_setting.max_count > 0

async def update(self) -> GeocachingStatus:
await self._update_user(None)
if len(self._settings.trackable_codes) > 0:
# First, update the user
await self._update_user()

# If we are tracking trackables, update them
if self._tracked_trackables_enabled():
await self._update_trackables()

# If we are tracking caches, update them
if self._tracked_caches_enabled():
await self._update_tracked_caches()

# If the nearby caches setting is enabled, update them
if self._nearby_caches_enabled():
await self._update_nearby_caches()

_LOGGER.info(f'Status updated.')
return self._status

async def _update_user(self, data: Dict[str, Any] = None) -> None:
assert self._status
if data is None:
Expand All @@ -160,13 +186,25 @@ async def _update_user(self, data: Dict[str, Any] = None) -> None:
self._status.update_user_from_dict(data)
_LOGGER.debug(f'User updated.')

async def _update_tracked_caches(self, data: Dict[str, Any] = None) -> None:
assert self._status
if data is None:
cache_codes = ",".join(self._settings.tracked_cache_codes)
data = await self._request("GET", f"/geocaches?referenceCodes={cache_codes}&fields={CACHE_FIELDS_PARAMETER}&lite=true")

self._status.update_caches(data)
_LOGGER.debug(f'Tracked caches updated.')

async def _update_trackables(self, data: Dict[str, Any] = None) -> None:
assert self._status
if data is None:
fields = ",".join([
"referenceCode",
"name",
"holder",
"owner",
"url",
"releasedDate",
"trackingNumber",
"kilometersTraveled",
"milesTraveled",
Expand All @@ -175,19 +213,117 @@ async def _update_trackables(self, data: Dict[str, Any] = None) -> None:
"isMissing",
"type"
])
trackable_parameters = ",".join(self._settings.trackable_codes)
data = await self._request("GET", f"/trackables?referenceCodes={trackable_parameters}&fields={fields}&expand=trackablelogs:1")
trackable_parameters = ",".join(self._settings.tracked_trackable_codes)
max_count_param: int = clamp(len(self._settings.tracked_trackable_codes), 0, 50) # Take range is 0-50 in API
data = await self._request("GET", f"/trackables?referenceCodes={trackable_parameters}&fields={fields}&take={max_count_param}&expand=trackablelogs:1")
self._status.update_trackables_from_dict(data)

# Update trackable journeys
if len(self._status.trackables) > 0:
for trackable in self._status.trackables.values():
latest_journey_data = await self._request("GET",f"/trackables/{trackable.reference_code}/journeys?sort=loggedDate-&take=1")
if len(latest_journey_data) == 1:
trackable.latest_journey = GeocachingTrackableJourney(data=latest_journey_data[0])
else:
trackable.latest_journey = None
fields = ",".join([
"referenceCode",
"geocacheName",
"loggedDate",
"coordinates",
"url",
"owner"
])
max_log_count: int = clamp(10, 0, 50) # Take range is 0-50 in API

# Only fetch logs related to movement
# Reference: https://api.groundspeak.com/documentation#trackable-log-types
logTypes: list[int] = ",".join([
"14", # Dropped Off
"15" # Transfer
])
trackable_journey_data = await self._request("GET",f"/trackables/{trackable.reference_code}/trackablelogs?fields={fields}&logTypes={logTypes}&take={max_log_count}")

# Note that if we are not fetching all journeys, the distance for the first journey in our data will be incorrect, since it does not know there was a previous journey
if trackable_journey_data:
# Create a list of GeocachingTrackableJourney instances
journeys = await GeocachingTrackableJourney.from_list(trackable_journey_data)

# Calculate distances between journeys
# The journeys are sorted in order, so reverse it to iterate backwards
j_iter = iter(reversed(journeys))

# Since we are iterating backwards, next is actually the previous journey.
# However, the previous journey is set in the loop, so we assume it is missing for now
curr_journey: GeocachingTrackableJourney | None = next(j_iter)
prev_journey: GeocachingTrackableJourney | None = None
while True:
# Ensure that the current journey is valid
if curr_journey is None:
break
prev_journey = next(j_iter, None)
# If we have reached the first journey, its distance should be 0 (it did not travel from anywhere)
if prev_journey is None:
curr_journey.distance_km = 0
break

# Calculate the distance from the previous to the current location, as that is the distance the current journey travelled
curr_journey.distance_km = GeocachingCoordinate.get_distance_km(prev_journey.coordinates, curr_journey.coordinates)
curr_journey = prev_journey

trackable.journeys = journeys

# Set the trackable coordinates to that of the latest log
trackable.coordinates = journeys[-1].coordinates

_LOGGER.debug(f'Trackables updated.')

async def _update_nearby_caches(self, data: Dict[str, Any] = None) -> None:
"""Update the nearby caches"""
assert self._status
if self._settings.nearby_caches_setting is None:
_LOGGER.warning("Cannot update nearby caches, setting has not been configured.")
return

if data is None:
self._status.nearby_caches = await self.get_nearby_caches(
self._settings.nearby_caches_setting.location,
self._settings.nearby_caches_setting.radius_km,
self._settings.nearby_caches_setting.max_count
)
else:
self._status.update_nearby_caches_from_dict(data)

_LOGGER.debug(f'Nearby caches updated.')

async def get_nearby_caches(self, coordinates: GeocachingCoordinate, radius_km: float, max_count: int = 10) -> list[GeocachingCache]:
"""Get caches nearby the provided coordinates, within the provided radius"""
radiusM: int = round(radius_km * 1000) # Convert the radius from km to m
max_count_param: int = clamp(max_count, 0, MAXIMUM_NEARBY_CACHES) # Take range is 0-100 in API

URL = f"/geocaches/search?q=location:[{coordinates.latitude},{coordinates.longitude}]+radius:{radiusM}m&fields={CACHE_FIELDS_PARAMETER}&take={max_count_param}&sort=distance+&lite=true"
# The + sign is not encoded correctly, so we encode it manually
data = await self._request("GET", URL.replace("+", "%2B"))

return GeocachingStatus.parse_caches(data)

async def _verify_codes(self, endpoint: str, code_type: str, reference_codes: set[str], extra_params: dict[str, str] = {}) -> None:
"""Verifies a set of reference codes to ensure they are valid, and returns a set of all invalid codes"""
ref_codes_param: str = ",".join(reference_codes)
additional_params: str = "&".join([f'{name}={val}' for name, val in extra_params.items()])
additional_params = "&" + additional_params if len(additional_params) > 0 else ""

data = await self._request("GET", f"/{endpoint}?referenceCodes={ref_codes_param}&fields=referenceCode{additional_params}")
invalid_codes: set[str] = reference_codes.difference([d["referenceCode"] for d in data])

if len(invalid_codes) > 0:
raise GeocachingInvalidSettingsError(code_type, invalid_codes)

async def verify_settings(self) -> None:
"""Verifies the settings, checking for invalid reference codes"""
# Verify the tracked trackable reference codes
if self._tracked_trackables_enabled():
await self._verify_codes("trackables", "trackable", self._settings.tracked_trackable_codes)

# Verify the tracked cache reference codes
if self._tracked_caches_enabled():
await self._verify_codes("geocaches", "geocache", self._settings.tracked_cache_codes, {"lite": "true"})

async def update_settings(self, settings: GeocachingSettings):
"""Update the Geocaching settings"""
self._settings = settings
Expand Down
5 changes: 5 additions & 0 deletions geocachingapi/limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Geocaching Api Limits."""

MAXIMUM_TRACKED_CACHES: int = 50
MAXIMUM_TRACKED_TRACKABLES: int = 10
MAXIMUM_NEARBY_CACHES: int = 50
Loading