Skip to content

Commit 0aebbd4

Browse files
marc7shulkperAlbinbelgien
authored
Updates to support Geocaching Home Assistant integration feature updates (#23)
* Fix missing async keyword, see home-assistant/core#99977 * Add nearby caches to status * Change trackable distance traveled data type * Fix missing status initialization for nearby caches * Fix typo causing cache coordinates not to be parsed * Added update_caches and _get_cache_info, with some more stuff, have not been able to test that it works yet, /Per&Albin * add seperate call for geocaches * Started working on the trackable journey list, not yet tested /Albin Per * fix for appending to array in trackable journey api * add support for trackable objects * uncommented code * Revert formatting * Fix errors and add missing cache data points * Rename caches -> tracked_caches * Correctly parse location data for caches * Rename caches_codes -> cache_codes * Fix trackable parsing, add additonal fields to caches and trackables, some refactoring and cleanup * Formatting * Always return original value if new value could not be parsed * Update NearbyCachesSetting * Rename cache update function * Replace cache find count with found date and found switch * Rework foundByUser to be nullable * Fix nearby caches update condition and limit take parameter for API * Remove unused function, align variables and functions to snake_case * Improve trackable journey data and change API endpoint * Reverse geocode trackable journey locations * Add distance between journeys * Handle blocking reverse geocoding outside of init function * Separate nearby caches logic to allow directed usage * Add comments and improve documentation * Add settings validation method * Add cache and trackable URLs * Formatting and cleanup * Update test * Limits: cache and trackable limits in settings and in API call. Error handling: Raise error if too many codes were configured in settings. Automatically remove duplicate codes * Move limits to limits.py --------- Co-authored-by: Per Samuelsson <[email protected]> Co-authored-by: Albin <[email protected]> Co-authored-by: Jakob Windt <[email protected]> Co-authored-by: jwindt <[email protected]>
1 parent 973ec71 commit 0aebbd4

File tree

8 files changed

+400
-75
lines changed

8 files changed

+400
-75
lines changed

geocachingapi/const.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,16 @@
2323
2: "Charter",
2424
3: "Premium"
2525
}
26+
27+
# Required parameters for fetching caches in order to generate complete GeocachingCache objects
28+
CACHE_FIELDS_PARAMETER: str = ",".join([
29+
"referenceCode",
30+
"name",
31+
"owner",
32+
"postedCoordinates",
33+
"url",
34+
"favoritePoints",
35+
"userData",
36+
"placedDate",
37+
"location"
38+
])

geocachingapi/exceptions.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
"""Exceptions for the Gecaching API."""
1+
"""Exceptions for the Geocaching API."""
22

33
class GeocachingApiError(Exception):
44
"""Generic GeocachingApi exception."""
55

6+
class GeocachingInvalidSettingsError(Exception):
7+
"""GeocachingApi invalid settings exception."""
8+
def __init__(self, code_type: str, invalid_codes: set[str]):
9+
super().__init__(f"Invalid {code_type} codes: {', '.join(invalid_codes)}")
10+
11+
class GeocachingTooManyCodesError(GeocachingApiError):
12+
"""GeocachingApi settings exception: too many codes."""
13+
def __init__(self, message: str):
14+
super().__init__(message)
615

716
class GeocachingApiConnectionError(GeocachingApiError):
817
"""GeocachingApi connection exception."""

geocachingapi/geocachingapi.py

Lines changed: 150 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,21 @@
1111
from yarl import URL
1212
from aiohttp import ClientResponse, ClientSession, ClientError
1313

14-
from typing import Any, Awaitable, Callable, Dict, List, Optional
15-
from .const import ENVIRONMENT_SETTINGS
14+
from typing import Any, Awaitable, Callable, Dict, Optional
15+
from .const import ENVIRONMENT_SETTINGS, CACHE_FIELDS_PARAMETER
16+
from .limits import MAXIMUM_NEARBY_CACHES
1617
from .exceptions import (
1718
GeocachingApiConnectionError,
1819
GeocachingApiConnectionTimeoutError,
1920
GeocachingApiError,
2021
GeocachingApiRateLimitError,
22+
GeocachingInvalidSettingsError,
2123
)
24+
from .utils import clamp
2225

2326
from .models import (
27+
GeocachingCache,
28+
GeocachingCoordinate,
2429
GeocachingStatus,
2530
GeocachingSettings,
2631
GeocachingApiEnvironment,
@@ -50,7 +55,7 @@ def __init__(
5055
"""Initialize connection with the Geocaching API."""
5156
self._environment_settings = ENVIRONMENT_SETTINGS[environment]
5257
self._status = GeocachingStatus()
53-
self._settings = settings or GeocachingSettings(False)
58+
self._settings = settings or GeocachingSettings()
5459
self._session = session
5560
self.request_timeout = request_timeout
5661
self.token = token
@@ -90,7 +95,7 @@ async def _request(self, method, uri, **kwargs) -> ClientResponse:
9095
self._close_session = True
9196

9297
try:
93-
with async_timeout.timeout(self.request_timeout):
98+
async with async_timeout.timeout(self.request_timeout):
9499
response = await self._session.request(
95100
method,
96101
f"{url}",
@@ -135,14 +140,35 @@ async def _request(self, method, uri, **kwargs) -> ClientResponse:
135140
_LOGGER.debug(f'Response:')
136141
_LOGGER.debug(f'{str(result)}')
137142
return result
143+
144+
def _tracked_trackables_enabled(self) -> bool:
145+
return len(self._settings.tracked_trackable_codes) > 0
146+
147+
def _tracked_caches_enabled(self) -> bool:
148+
return len(self._settings.tracked_cache_codes) > 0
149+
150+
def _nearby_caches_enabled(self) -> bool:
151+
return self._settings.nearby_caches_setting is not None and self._settings.nearby_caches_setting.max_count > 0
138152

139153
async def update(self) -> GeocachingStatus:
140-
await self._update_user(None)
141-
if len(self._settings.trackable_codes) > 0:
154+
# First, update the user
155+
await self._update_user()
156+
157+
# If we are tracking trackables, update them
158+
if self._tracked_trackables_enabled():
142159
await self._update_trackables()
160+
161+
# If we are tracking caches, update them
162+
if self._tracked_caches_enabled():
163+
await self._update_tracked_caches()
164+
165+
# If the nearby caches setting is enabled, update them
166+
if self._nearby_caches_enabled():
167+
await self._update_nearby_caches()
168+
143169
_LOGGER.info(f'Status updated.')
144170
return self._status
145-
171+
146172
async def _update_user(self, data: Dict[str, Any] = None) -> None:
147173
assert self._status
148174
if data is None:
@@ -160,13 +186,25 @@ async def _update_user(self, data: Dict[str, Any] = None) -> None:
160186
self._status.update_user_from_dict(data)
161187
_LOGGER.debug(f'User updated.')
162188

189+
async def _update_tracked_caches(self, data: Dict[str, Any] = None) -> None:
190+
assert self._status
191+
if data is None:
192+
cache_codes = ",".join(self._settings.tracked_cache_codes)
193+
data = await self._request("GET", f"/geocaches?referenceCodes={cache_codes}&fields={CACHE_FIELDS_PARAMETER}&lite=true")
194+
195+
self._status.update_caches(data)
196+
_LOGGER.debug(f'Tracked caches updated.')
197+
163198
async def _update_trackables(self, data: Dict[str, Any] = None) -> None:
164199
assert self._status
165200
if data is None:
166201
fields = ",".join([
167202
"referenceCode",
168203
"name",
169204
"holder",
205+
"owner",
206+
"url",
207+
"releasedDate",
170208
"trackingNumber",
171209
"kilometersTraveled",
172210
"milesTraveled",
@@ -175,19 +213,117 @@ async def _update_trackables(self, data: Dict[str, Any] = None) -> None:
175213
"isMissing",
176214
"type"
177215
])
178-
trackable_parameters = ",".join(self._settings.trackable_codes)
179-
data = await self._request("GET", f"/trackables?referenceCodes={trackable_parameters}&fields={fields}&expand=trackablelogs:1")
216+
trackable_parameters = ",".join(self._settings.tracked_trackable_codes)
217+
max_count_param: int = clamp(len(self._settings.tracked_trackable_codes), 0, 50) # Take range is 0-50 in API
218+
data = await self._request("GET", f"/trackables?referenceCodes={trackable_parameters}&fields={fields}&take={max_count_param}&expand=trackablelogs:1")
180219
self._status.update_trackables_from_dict(data)
220+
221+
# Update trackable journeys
181222
if len(self._status.trackables) > 0:
182223
for trackable in self._status.trackables.values():
183-
latest_journey_data = await self._request("GET",f"/trackables/{trackable.reference_code}/journeys?sort=loggedDate-&take=1")
184-
if len(latest_journey_data) == 1:
185-
trackable.latest_journey = GeocachingTrackableJourney(data=latest_journey_data[0])
186-
else:
187-
trackable.latest_journey = None
224+
fields = ",".join([
225+
"referenceCode",
226+
"geocacheName",
227+
"loggedDate",
228+
"coordinates",
229+
"url",
230+
"owner"
231+
])
232+
max_log_count: int = clamp(10, 0, 50) # Take range is 0-50 in API
233+
234+
# Only fetch logs related to movement
235+
# Reference: https://api.groundspeak.com/documentation#trackable-log-types
236+
logTypes: list[int] = ",".join([
237+
"14", # Dropped Off
238+
"15" # Transfer
239+
])
240+
trackable_journey_data = await self._request("GET",f"/trackables/{trackable.reference_code}/trackablelogs?fields={fields}&logTypes={logTypes}&take={max_log_count}")
241+
242+
# 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
243+
if trackable_journey_data:
244+
# Create a list of GeocachingTrackableJourney instances
245+
journeys = await GeocachingTrackableJourney.from_list(trackable_journey_data)
246+
247+
# Calculate distances between journeys
248+
# The journeys are sorted in order, so reverse it to iterate backwards
249+
j_iter = iter(reversed(journeys))
250+
251+
# Since we are iterating backwards, next is actually the previous journey.
252+
# However, the previous journey is set in the loop, so we assume it is missing for now
253+
curr_journey: GeocachingTrackableJourney | None = next(j_iter)
254+
prev_journey: GeocachingTrackableJourney | None = None
255+
while True:
256+
# Ensure that the current journey is valid
257+
if curr_journey is None:
258+
break
259+
prev_journey = next(j_iter, None)
260+
# If we have reached the first journey, its distance should be 0 (it did not travel from anywhere)
261+
if prev_journey is None:
262+
curr_journey.distance_km = 0
263+
break
264+
265+
# Calculate the distance from the previous to the current location, as that is the distance the current journey travelled
266+
curr_journey.distance_km = GeocachingCoordinate.get_distance_km(prev_journey.coordinates, curr_journey.coordinates)
267+
curr_journey = prev_journey
268+
269+
trackable.journeys = journeys
270+
271+
# Set the trackable coordinates to that of the latest log
272+
trackable.coordinates = journeys[-1].coordinates
188273

189274
_LOGGER.debug(f'Trackables updated.')
190275

276+
async def _update_nearby_caches(self, data: Dict[str, Any] = None) -> None:
277+
"""Update the nearby caches"""
278+
assert self._status
279+
if self._settings.nearby_caches_setting is None:
280+
_LOGGER.warning("Cannot update nearby caches, setting has not been configured.")
281+
return
282+
283+
if data is None:
284+
self._status.nearby_caches = await self.get_nearby_caches(
285+
self._settings.nearby_caches_setting.location,
286+
self._settings.nearby_caches_setting.radius_km,
287+
self._settings.nearby_caches_setting.max_count
288+
)
289+
else:
290+
self._status.update_nearby_caches_from_dict(data)
291+
292+
_LOGGER.debug(f'Nearby caches updated.')
293+
294+
async def get_nearby_caches(self, coordinates: GeocachingCoordinate, radius_km: float, max_count: int = 10) -> list[GeocachingCache]:
295+
"""Get caches nearby the provided coordinates, within the provided radius"""
296+
radiusM: int = round(radius_km * 1000) # Convert the radius from km to m
297+
max_count_param: int = clamp(max_count, 0, MAXIMUM_NEARBY_CACHES) # Take range is 0-100 in API
298+
299+
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"
300+
# The + sign is not encoded correctly, so we encode it manually
301+
data = await self._request("GET", URL.replace("+", "%2B"))
302+
303+
return GeocachingStatus.parse_caches(data)
304+
305+
async def _verify_codes(self, endpoint: str, code_type: str, reference_codes: set[str], extra_params: dict[str, str] = {}) -> None:
306+
"""Verifies a set of reference codes to ensure they are valid, and returns a set of all invalid codes"""
307+
ref_codes_param: str = ",".join(reference_codes)
308+
additional_params: str = "&".join([f'{name}={val}' for name, val in extra_params.items()])
309+
additional_params = "&" + additional_params if len(additional_params) > 0 else ""
310+
311+
data = await self._request("GET", f"/{endpoint}?referenceCodes={ref_codes_param}&fields=referenceCode{additional_params}")
312+
invalid_codes: set[str] = reference_codes.difference([d["referenceCode"] for d in data])
313+
314+
if len(invalid_codes) > 0:
315+
raise GeocachingInvalidSettingsError(code_type, invalid_codes)
316+
317+
async def verify_settings(self) -> None:
318+
"""Verifies the settings, checking for invalid reference codes"""
319+
# Verify the tracked trackable reference codes
320+
if self._tracked_trackables_enabled():
321+
await self._verify_codes("trackables", "trackable", self._settings.tracked_trackable_codes)
322+
323+
# Verify the tracked cache reference codes
324+
if self._tracked_caches_enabled():
325+
await self._verify_codes("geocaches", "geocache", self._settings.tracked_cache_codes, {"lite": "true"})
326+
191327
async def update_settings(self, settings: GeocachingSettings):
192328
"""Update the Geocaching settings"""
193329
self._settings = settings

geocachingapi/limits.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Geocaching Api Limits."""
2+
3+
MAXIMUM_TRACKED_CACHES: int = 50
4+
MAXIMUM_TRACKED_TRACKABLES: int = 10
5+
MAXIMUM_NEARBY_CACHES: int = 50

0 commit comments

Comments
 (0)