otf-api 0.2.2__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otf_api/__init__.py +14 -69
- otf_api/api.py +873 -66
- otf_api/auth.py +314 -0
- otf_api/cli/__init__.py +4 -0
- otf_api/cli/_utilities.py +60 -0
- otf_api/cli/app.py +172 -0
- otf_api/cli/bookings.py +231 -0
- otf_api/cli/prompts.py +162 -0
- otf_api/models/__init__.py +30 -23
- otf_api/models/base.py +205 -2
- otf_api/models/responses/__init__.py +29 -29
- otf_api/models/responses/body_composition_list.py +304 -0
- otf_api/models/responses/book_class.py +405 -0
- otf_api/models/responses/bookings.py +211 -37
- otf_api/models/responses/cancel_booking.py +93 -0
- otf_api/models/responses/challenge_tracker_content.py +6 -6
- otf_api/models/responses/challenge_tracker_detail.py +6 -6
- otf_api/models/responses/classes.py +205 -7
- otf_api/models/responses/enums.py +0 -35
- otf_api/models/responses/favorite_studios.py +5 -5
- otf_api/models/responses/latest_agreement.py +2 -2
- otf_api/models/responses/lifetime_stats.py +92 -0
- otf_api/models/responses/member_detail.py +17 -12
- otf_api/models/responses/member_membership.py +2 -2
- otf_api/models/responses/member_purchases.py +9 -9
- otf_api/models/responses/out_of_studio_workout_history.py +4 -4
- otf_api/models/responses/performance_summary_detail.py +1 -1
- otf_api/models/responses/performance_summary_list.py +13 -13
- otf_api/models/responses/studio_detail.py +10 -10
- otf_api/models/responses/studio_services.py +8 -8
- otf_api/models/responses/telemetry.py +6 -6
- otf_api/models/responses/telemetry_hr_history.py +6 -6
- otf_api/models/responses/telemetry_max_hr.py +3 -3
- otf_api/models/responses/total_classes.py +2 -2
- otf_api/models/responses/workouts.py +4 -4
- otf_api-0.4.0.dist-info/METADATA +54 -0
- otf_api-0.4.0.dist-info/RECORD +42 -0
- otf_api-0.4.0.dist-info/entry_points.txt +3 -0
- otf_api/__version__.py +0 -1
- otf_api/classes_api.py +0 -44
- otf_api/member_api.py +0 -380
- otf_api/models/auth.py +0 -141
- otf_api/performance_api.py +0 -54
- otf_api/studios_api.py +0 -96
- otf_api/telemetry_api.py +0 -95
- otf_api-0.2.2.dist-info/METADATA +0 -284
- otf_api-0.2.2.dist-info/RECORD +0 -38
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/AUTHORS.md +0 -0
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/LICENSE +0 -0
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/WHEEL +0 -0
otf_api/api.py
CHANGED
@@ -1,23 +1,59 @@
|
|
1
1
|
import asyncio
|
2
|
+
import contextlib
|
3
|
+
import json
|
2
4
|
import typing
|
5
|
+
from datetime import date, datetime
|
6
|
+
from math import ceil
|
3
7
|
from typing import Any
|
4
8
|
|
5
9
|
import aiohttp
|
10
|
+
import requests
|
6
11
|
from loguru import logger
|
7
12
|
from yarl import URL
|
8
13
|
|
9
|
-
from otf_api.
|
10
|
-
from otf_api.
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
14
|
+
from otf_api.auth import OtfUser
|
15
|
+
from otf_api.models import (
|
16
|
+
BodyCompositionList,
|
17
|
+
BookClass,
|
18
|
+
BookingList,
|
19
|
+
BookingStatus,
|
20
|
+
CancelBooking,
|
21
|
+
ChallengeTrackerContent,
|
22
|
+
ChallengeTrackerDetailList,
|
23
|
+
ChallengeType,
|
24
|
+
ClassType,
|
25
|
+
DoW,
|
26
|
+
EquipmentType,
|
27
|
+
FavoriteStudioList,
|
28
|
+
LatestAgreement,
|
29
|
+
MemberDetail,
|
30
|
+
MemberMembership,
|
31
|
+
MemberPurchaseList,
|
32
|
+
OtfClassList,
|
33
|
+
OutOfStudioWorkoutHistoryList,
|
34
|
+
Pagination,
|
35
|
+
PerformanceSummaryDetail,
|
36
|
+
PerformanceSummaryList,
|
37
|
+
StatsResponse,
|
38
|
+
StatsTime,
|
39
|
+
StudioDetail,
|
40
|
+
StudioDetailList,
|
41
|
+
StudioServiceList,
|
42
|
+
Telemetry,
|
43
|
+
TelemetryHrHistory,
|
44
|
+
TelemetryMaxHr,
|
45
|
+
TotalClasses,
|
46
|
+
WorkoutList,
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
class AlreadyBookedError(Exception):
|
51
|
+
pass
|
52
|
+
|
15
53
|
|
16
54
|
if typing.TYPE_CHECKING:
|
17
55
|
from loguru import Logger
|
18
56
|
|
19
|
-
from otf_api.models.responses.member_detail import MemberDetail
|
20
|
-
from otf_api.models.responses.studio_detail import StudioDetail
|
21
57
|
|
22
58
|
API_BASE_URL = "api.orangetheory.co"
|
23
59
|
API_IO_BASE_URL = "api.orangetheory.io"
|
@@ -25,58 +61,105 @@ API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
|
|
25
61
|
REQUEST_HEADERS = {"Authorization": None, "Content-Type": "application/json", "Accept": "application/json"}
|
26
62
|
|
27
63
|
|
28
|
-
class
|
29
|
-
""
|
30
|
-
|
31
|
-
|
32
|
-
---
|
33
|
-
```python
|
34
|
-
import asyncio
|
35
|
-
from otf_api import Api
|
64
|
+
class Otf:
|
65
|
+
logger: "Logger" = logger
|
66
|
+
user: OtfUser
|
67
|
+
_session: aiohttp.ClientSession
|
36
68
|
|
37
|
-
|
38
|
-
|
39
|
-
|
69
|
+
def __init__(
|
70
|
+
self,
|
71
|
+
username: str | None = None,
|
72
|
+
password: str | None = None,
|
73
|
+
access_token: str | None = None,
|
74
|
+
id_token: str | None = None,
|
75
|
+
refresh_token: str | None = None,
|
76
|
+
device_key: str | None = None,
|
77
|
+
user: OtfUser | None = None,
|
78
|
+
):
|
79
|
+
"""Create a new Otf instance.
|
40
80
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
81
|
+
Authentication methods:
|
82
|
+
---
|
83
|
+
- Provide a username and password.
|
84
|
+
- Provide an access token and id token.
|
85
|
+
- Provide a user object.
|
45
86
|
|
46
|
-
|
47
|
-
|
48
|
-
|
87
|
+
Args:
|
88
|
+
username (str, optional): The username of the user. Default is None.
|
89
|
+
password (str, optional): The password of the user. Default is None.
|
90
|
+
access_token (str, optional): The access token. Default is None.
|
91
|
+
id_token (str, optional): The id token. Default is None.
|
92
|
+
refresh_token (str, optional): The refresh token. Default is None.
|
93
|
+
device_key (str, optional): The device key. Default is None.
|
94
|
+
user (OtfUser, optional): A user object. Default is None.
|
95
|
+
"""
|
49
96
|
|
50
|
-
def __init__(self, username: str, password: str):
|
51
97
|
self.member: MemberDetail
|
52
|
-
self.
|
98
|
+
self.home_studio_uuid: str
|
53
99
|
|
54
|
-
|
55
|
-
|
100
|
+
if user:
|
101
|
+
self.user = user
|
102
|
+
elif username and password or (access_token and id_token):
|
103
|
+
self.user = OtfUser(
|
104
|
+
username=username,
|
105
|
+
password=password,
|
106
|
+
access_token=access_token,
|
107
|
+
id_token=id_token,
|
108
|
+
refresh_token=refresh_token,
|
109
|
+
device_key=device_key,
|
110
|
+
)
|
111
|
+
else:
|
112
|
+
raise ValueError("No valid authentication method provided")
|
56
113
|
|
57
|
-
|
58
|
-
self.
|
59
|
-
self.
|
60
|
-
self.
|
61
|
-
|
114
|
+
# simplify access to member_id and member_uuid
|
115
|
+
self._member_id = self.user.member_id
|
116
|
+
self._member_uuid = self.user.member_uuid
|
117
|
+
self._perf_api_headers = {
|
118
|
+
"koji-member-id": self._member_id,
|
119
|
+
"koji-member-email": self.user.id_claims_data.email,
|
120
|
+
}
|
121
|
+
self.member = self._get_member_details_sync()
|
122
|
+
self.home_studio_uuid = self.member.home_studio.studio_uuid
|
62
123
|
|
63
|
-
|
64
|
-
|
65
|
-
"""Create a new API instance. The username and password are required arguments because even though
|
66
|
-
we cache the token, they expire so quickly that we usually end up needing to re-authenticate.
|
124
|
+
def _get_member_details_sync(self) -> MemberDetail:
|
125
|
+
"""Get the member details synchronously.
|
67
126
|
|
68
|
-
|
69
|
-
|
70
|
-
|
127
|
+
This is used to get the member details when the API is first initialized, to let use initialize
|
128
|
+
without needing to await the member details.
|
129
|
+
|
130
|
+
Returns:
|
131
|
+
MemberDetail: The member details.
|
71
132
|
"""
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
133
|
+
url = f"https://{API_BASE_URL}/member/members/{self._member_id}"
|
134
|
+
resp = requests.get(url, headers=self.headers)
|
135
|
+
return MemberDetail(**resp.json()["data"])
|
136
|
+
|
137
|
+
@property
|
138
|
+
def headers(self) -> dict[str, str]:
|
139
|
+
"""Get the headers for the API request."""
|
140
|
+
|
141
|
+
# check the token before making a request in case it has expired
|
142
|
+
|
143
|
+
self.user.cognito.check_token()
|
144
|
+
return {
|
145
|
+
"Authorization": f"Bearer {self.user.cognito.id_token}",
|
146
|
+
"Content-Type": "application/json",
|
147
|
+
"Accept": "application/json",
|
148
|
+
}
|
149
|
+
|
150
|
+
@property
|
151
|
+
def session(self) -> aiohttp.ClientSession:
|
152
|
+
"""Get the aiohttp session."""
|
153
|
+
if not getattr(self, "_session", None):
|
154
|
+
self._session = aiohttp.ClientSession(headers=self.headers)
|
155
|
+
|
156
|
+
return self._session
|
76
157
|
|
77
158
|
def __del__(self) -> None:
|
159
|
+
if not hasattr(self, "session"):
|
160
|
+
return
|
161
|
+
|
78
162
|
try:
|
79
|
-
loop = asyncio.get_event_loop()
|
80
163
|
asyncio.create_task(self._close_session()) # noqa
|
81
164
|
except RuntimeError:
|
82
165
|
loop = asyncio.new_event_loop()
|
@@ -86,18 +169,6 @@ class Api:
|
|
86
169
|
if not self.session.closed:
|
87
170
|
await self.session.close()
|
88
171
|
|
89
|
-
@property
|
90
|
-
def _base_headers(self) -> dict[str, str]:
|
91
|
-
"""Get the base headers for the API."""
|
92
|
-
if not self.user:
|
93
|
-
raise ValueError("No user is logged in.")
|
94
|
-
|
95
|
-
return {
|
96
|
-
"Authorization": f"Bearer {self.user.cognito.id_token}",
|
97
|
-
"Content-Type": "application/json",
|
98
|
-
"Accept": "application/json",
|
99
|
-
}
|
100
|
-
|
101
172
|
async def _do(
|
102
173
|
self,
|
103
174
|
method: str,
|
@@ -105,6 +176,7 @@ class Api:
|
|
105
176
|
url: str,
|
106
177
|
params: dict[str, Any] | None = None,
|
107
178
|
headers: dict[str, str] | None = None,
|
179
|
+
**kwargs: Any,
|
108
180
|
) -> Any:
|
109
181
|
"""Perform an API request."""
|
110
182
|
|
@@ -115,22 +187,34 @@ class Api:
|
|
115
187
|
|
116
188
|
logger.debug(f"Making {method!r} request to {full_url}, params: {params}")
|
117
189
|
|
118
|
-
|
119
|
-
|
190
|
+
# ensure we have headers that contain the most up-to-date token
|
191
|
+
if not headers:
|
192
|
+
headers = self.headers
|
120
193
|
else:
|
121
|
-
headers
|
194
|
+
headers.update(self.headers)
|
195
|
+
|
196
|
+
text = None
|
197
|
+
async with self.session.request(method, full_url, headers=headers, params=params, **kwargs) as response:
|
198
|
+
with contextlib.suppress(Exception):
|
199
|
+
text = await response.text()
|
200
|
+
|
201
|
+
try:
|
202
|
+
response.raise_for_status()
|
203
|
+
except aiohttp.ClientResponseError as e:
|
204
|
+
logger.exception(f"Error making request: {e}")
|
205
|
+
logger.exception(f"Response: {text}")
|
206
|
+
except Exception as e:
|
207
|
+
logger.exception(f"Error making request: {e}")
|
122
208
|
|
123
|
-
async with self.session.request(method, full_url, headers=headers, params=params) as response:
|
124
|
-
response.raise_for_status()
|
125
209
|
return await response.json()
|
126
210
|
|
127
211
|
async def _classes_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
|
128
212
|
"""Perform an API request to the classes API."""
|
129
213
|
return await self._do(method, API_IO_BASE_URL, url, params)
|
130
214
|
|
131
|
-
async def _default_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
|
215
|
+
async def _default_request(self, method: str, url: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
132
216
|
"""Perform an API request to the default API."""
|
133
|
-
return await self._do(method, API_BASE_URL, url, params)
|
217
|
+
return await self._do(method, API_BASE_URL, url, params, **kwargs)
|
134
218
|
|
135
219
|
async def _telemetry_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
|
136
220
|
"""Perform an API request to the Telemetry API."""
|
@@ -141,3 +225,726 @@ class Api:
|
|
141
225
|
) -> Any:
|
142
226
|
"""Perform an API request to the performance summary API."""
|
143
227
|
return await self._do(method, API_IO_BASE_URL, url, params, headers)
|
228
|
+
|
229
|
+
async def get_workouts(self) -> WorkoutList:
|
230
|
+
"""Get the list of workouts from OT Live.
|
231
|
+
|
232
|
+
Returns:
|
233
|
+
WorkoutList: The list of workouts.
|
234
|
+
|
235
|
+
Info:
|
236
|
+
---
|
237
|
+
This returns data from the same api the [OT Live website](https://otlive.orangetheory.com/) uses.
|
238
|
+
It is quite a bit of data, and all workouts going back to ~2019. The data includes the class history
|
239
|
+
UUID, which can be used to get telemetry data for a specific workout.
|
240
|
+
"""
|
241
|
+
|
242
|
+
res = await self._default_request("GET", "/virtual-class/in-studio-workouts")
|
243
|
+
|
244
|
+
return WorkoutList(workouts=res["data"])
|
245
|
+
|
246
|
+
async def get_total_classes(self) -> TotalClasses:
|
247
|
+
"""Get the member's total classes. This is a simple object reflecting the total number of classes attended,
|
248
|
+
both in-studio and OT Live.
|
249
|
+
|
250
|
+
Returns:
|
251
|
+
TotalClasses: The member's total classes.
|
252
|
+
"""
|
253
|
+
data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
|
254
|
+
return TotalClasses(**data["data"])
|
255
|
+
|
256
|
+
async def get_classes(
|
257
|
+
self,
|
258
|
+
studio_uuids: list[str] | None = None,
|
259
|
+
include_home_studio: bool = True,
|
260
|
+
start_date: str | None = None,
|
261
|
+
end_date: str | None = None,
|
262
|
+
limit: int | None = None,
|
263
|
+
class_type: ClassType | list[ClassType] | None = None,
|
264
|
+
exclude_cancelled: bool = False,
|
265
|
+
day_of_week: list[DoW] | None = None,
|
266
|
+
start_time: list[str] | None = None,
|
267
|
+
) -> OtfClassList:
|
268
|
+
"""Get the classes for the user.
|
269
|
+
|
270
|
+
Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
|
271
|
+
UUIDs are provided, it will default to the user's home studio.
|
272
|
+
|
273
|
+
Args:
|
274
|
+
studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
|
275
|
+
default to the user's home studio only.
|
276
|
+
include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
|
277
|
+
start_date (str | None): The start date to get classes for, in the format "YYYY-MM-DD". Default is None.
|
278
|
+
end_date (str | None): The end date to get classes for, in the format "YYYY-MM-DD". Default is None.
|
279
|
+
limit (int | None): Limit the number of classes returned. Default is None.
|
280
|
+
class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple\
|
281
|
+
class types can be provided, if there are multiple there will be a call per class type.
|
282
|
+
exclude_cancelled (bool): Whether to exclude cancelled classes. Default is False.
|
283
|
+
day_of_week (list[DoW] | None): The days of the week to filter by. Default is None.
|
284
|
+
start_time (list[str] | None): The start time to filter by. Default is None.
|
285
|
+
|
286
|
+
Returns:
|
287
|
+
OtfClassList: The classes for the user.
|
288
|
+
"""
|
289
|
+
|
290
|
+
if not studio_uuids:
|
291
|
+
studio_uuids = [self.home_studio_uuid]
|
292
|
+
elif include_home_studio and self.home_studio_uuid not in studio_uuids:
|
293
|
+
studio_uuids.append(self.home_studio_uuid)
|
294
|
+
|
295
|
+
path = "/v1/classes"
|
296
|
+
|
297
|
+
params = {"studio_ids": studio_uuids}
|
298
|
+
|
299
|
+
classes_resp = await self._classes_request("GET", path, params=params)
|
300
|
+
classes_list = OtfClassList(classes=classes_resp["items"])
|
301
|
+
|
302
|
+
if start_date:
|
303
|
+
start_dtme = datetime.strptime(start_date, "%Y-%m-%d") # noqa
|
304
|
+
classes_list.classes = [c for c in classes_list.classes if c.starts_at_local >= start_dtme]
|
305
|
+
|
306
|
+
if end_date:
|
307
|
+
end_dtme = datetime.strptime(end_date, "%Y-%m-%d") # noqa
|
308
|
+
classes_list.classes = [c for c in classes_list.classes if c.ends_at_local <= end_dtme]
|
309
|
+
|
310
|
+
if limit:
|
311
|
+
classes_list.classes = classes_list.classes[:limit]
|
312
|
+
|
313
|
+
if class_type and isinstance(class_type, str):
|
314
|
+
class_type = [class_type]
|
315
|
+
|
316
|
+
if day_of_week and not isinstance(day_of_week, list):
|
317
|
+
day_of_week = [day_of_week]
|
318
|
+
|
319
|
+
if start_time and not isinstance(start_time, list):
|
320
|
+
start_time = [start_time]
|
321
|
+
|
322
|
+
if class_type:
|
323
|
+
classes_list.classes = [c for c in classes_list.classes if c.class_type in class_type]
|
324
|
+
|
325
|
+
if exclude_cancelled:
|
326
|
+
classes_list.classes = [c for c in classes_list.classes if not c.canceled]
|
327
|
+
|
328
|
+
for otf_class in classes_list.classes:
|
329
|
+
otf_class.is_home_studio = otf_class.studio.id == self.home_studio_uuid
|
330
|
+
|
331
|
+
if day_of_week:
|
332
|
+
classes_list.classes = [c for c in classes_list.classes if c.day_of_week_enum in day_of_week]
|
333
|
+
|
334
|
+
if start_time:
|
335
|
+
classes_list.classes = [
|
336
|
+
c for c in classes_list.classes if any(c.time.strip().startswith(t) for t in start_time)
|
337
|
+
]
|
338
|
+
|
339
|
+
classes_list.classes = list(filter(lambda c: not c.canceled, classes_list.classes))
|
340
|
+
|
341
|
+
booking_resp = await self.get_bookings(start_date, end_date, status=BookingStatus.Booked)
|
342
|
+
booked_classes = {b.otf_class.class_uuid for b in booking_resp.bookings}
|
343
|
+
|
344
|
+
for otf_class in classes_list.classes:
|
345
|
+
otf_class.is_booked = otf_class.ot_class_uuid in booked_classes
|
346
|
+
|
347
|
+
return classes_list
|
348
|
+
|
349
|
+
async def book_class(self, class_uuid: str) -> BookClass | typing.Any:
|
350
|
+
"""Book a class by class_uuid.
|
351
|
+
|
352
|
+
Args:
|
353
|
+
class_uuid (str): The class UUID to book.
|
354
|
+
|
355
|
+
Returns:
|
356
|
+
None: The response is empty.
|
357
|
+
"""
|
358
|
+
|
359
|
+
bookings = await self.get_bookings()
|
360
|
+
|
361
|
+
for booking in bookings.bookings:
|
362
|
+
if booking.otf_class.class_uuid == class_uuid:
|
363
|
+
raise AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
364
|
+
|
365
|
+
body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
|
366
|
+
|
367
|
+
resp = await self._default_request("PUT", f"/member/members/{self._member_id}/bookings", json=body)
|
368
|
+
|
369
|
+
if resp["code"] == "ERROR":
|
370
|
+
if resp["data"]["errorCode"] == "603":
|
371
|
+
raise AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
372
|
+
raise Exception(f"Error booking class {class_uuid}: {json.dumps(resp)}")
|
373
|
+
|
374
|
+
data = BookClass(**resp["data"])
|
375
|
+
return data
|
376
|
+
|
377
|
+
async def cancel_booking(self, booking_uuid: str) -> CancelBooking:
|
378
|
+
"""Cancel a class by booking_uuid.
|
379
|
+
|
380
|
+
Args:
|
381
|
+
booking_uuid (str): The booking UUID to cancel.
|
382
|
+
|
383
|
+
Returns:
|
384
|
+
None: The response is empty.
|
385
|
+
"""
|
386
|
+
|
387
|
+
params = {"confirmed": "true"}
|
388
|
+
resp = await self._default_request(
|
389
|
+
"DELETE", f"/member/members/{self._member_id}/bookings/{booking_uuid}", params=params
|
390
|
+
)
|
391
|
+
return CancelBooking(**resp["data"])
|
392
|
+
|
393
|
+
async def get_bookings(
|
394
|
+
self,
|
395
|
+
start_date: date | str | None = None,
|
396
|
+
end_date: date | str | None = None,
|
397
|
+
status: BookingStatus | None = None,
|
398
|
+
limit: int | None = None,
|
399
|
+
exclude_cancelled: bool = True,
|
400
|
+
exclude_checkedin: bool = True,
|
401
|
+
) -> BookingList:
|
402
|
+
"""Get the member's bookings.
|
403
|
+
|
404
|
+
Args:
|
405
|
+
start_date (date | str | None): The start date for the bookings. Default is None.
|
406
|
+
end_date (date | str | None): The end date for the bookings. Default is None.
|
407
|
+
status (BookingStatus | None): The status of the bookings to get. Default is None, which includes\
|
408
|
+
all statuses. Only a single status can be provided.
|
409
|
+
limit (int | None): The maximum number of bookings to return. Default is None, which returns all\
|
410
|
+
bookings.
|
411
|
+
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
412
|
+
exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
|
413
|
+
|
414
|
+
Returns:
|
415
|
+
BookingList: The member's bookings.
|
416
|
+
|
417
|
+
Warning:
|
418
|
+
---
|
419
|
+
Incorrect statuses do not cause any bad status code, they just return no results.
|
420
|
+
|
421
|
+
Tip:
|
422
|
+
---
|
423
|
+
`CheckedIn` - you must provide dates if you want to get bookings with a status of CheckedIn. If you do not
|
424
|
+
provide dates, the endpoint will return no results for this status.
|
425
|
+
|
426
|
+
Dates Notes:
|
427
|
+
---
|
428
|
+
If dates are provided, the endpoint will return bookings where the class date is within the provided
|
429
|
+
date range. If no dates are provided, it will go back 45 days and forward about 30 days.
|
430
|
+
|
431
|
+
Developer Notes:
|
432
|
+
---
|
433
|
+
Looking at the code in the app, it appears that this endpoint accepts multiple statuses. Indeed,
|
434
|
+
it does not throw an error if you include a list of statuses. However, only the last status in the list is
|
435
|
+
used. I'm not sure if this is a bug or if the API is supposed to work this way.
|
436
|
+
"""
|
437
|
+
|
438
|
+
if exclude_cancelled and status == BookingStatus.Cancelled:
|
439
|
+
logger.warning(
|
440
|
+
"Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
|
441
|
+
)
|
442
|
+
exclude_cancelled = False
|
443
|
+
|
444
|
+
if isinstance(start_date, date):
|
445
|
+
start_date = start_date.isoformat()
|
446
|
+
|
447
|
+
if isinstance(end_date, date):
|
448
|
+
end_date = end_date.isoformat()
|
449
|
+
|
450
|
+
status_value = status.value if status else None
|
451
|
+
|
452
|
+
params = {"startDate": start_date, "endDate": end_date, "statuses": status_value}
|
453
|
+
|
454
|
+
res = await self._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
|
455
|
+
|
456
|
+
bookings = res["data"][:limit] if limit else res["data"]
|
457
|
+
|
458
|
+
data = BookingList(bookings=bookings)
|
459
|
+
data.bookings = sorted(data.bookings, key=lambda x: x.otf_class.starts_at_local)
|
460
|
+
|
461
|
+
for booking in data.bookings:
|
462
|
+
if not booking.otf_class:
|
463
|
+
continue
|
464
|
+
if booking.otf_class.studio.studio_uuid == self.home_studio_uuid:
|
465
|
+
booking.is_home_studio = True
|
466
|
+
else:
|
467
|
+
booking.is_home_studio = False
|
468
|
+
|
469
|
+
if exclude_cancelled:
|
470
|
+
data.bookings = [b for b in data.bookings if b.status != BookingStatus.Cancelled]
|
471
|
+
|
472
|
+
if exclude_checkedin:
|
473
|
+
data.bookings = [b for b in data.bookings if b.status != BookingStatus.CheckedIn]
|
474
|
+
|
475
|
+
return data
|
476
|
+
|
477
|
+
async def _get_bookings_old(self, status: BookingStatus | None = None) -> BookingList:
|
478
|
+
"""Get the member's bookings.
|
479
|
+
|
480
|
+
Args:
|
481
|
+
status (BookingStatus | None): The status of the bookings to get. Default is None, which includes
|
482
|
+
all statuses. Only a single status can be provided.
|
483
|
+
|
484
|
+
Returns:
|
485
|
+
BookingList: The member's bookings.
|
486
|
+
|
487
|
+
Raises:
|
488
|
+
ValueError: If an unaccepted status is provided.
|
489
|
+
|
490
|
+
Notes:
|
491
|
+
---
|
492
|
+
This one is called with the param named 'status'. Dates cannot be provided, because if the endpoint
|
493
|
+
receives a date, it will return as if the param name was 'statuses'.
|
494
|
+
|
495
|
+
Note: This seems to only work for Cancelled, Booked, CheckedIn, and Waitlisted statuses. If you provide
|
496
|
+
a different status, it will return all bookings, not filtered by status. The results in this scenario do
|
497
|
+
not line up with the `get_bookings` with no status provided, as that returns fewer records. Likely the
|
498
|
+
filtered dates are different on the backend.
|
499
|
+
|
500
|
+
My guess: the endpoint called with dates and 'statuses' is a "v2" kind of thing, where they upgraded without
|
501
|
+
changing the version of the api. Calling it with no dates and a singular (limited) status is probably v1.
|
502
|
+
|
503
|
+
I'm leaving this in here for reference, but marking it private. I just don't want to have to puzzle over
|
504
|
+
this again if I remove it and forget about it.
|
505
|
+
|
506
|
+
"""
|
507
|
+
|
508
|
+
if status and status not in [
|
509
|
+
BookingStatus.Cancelled,
|
510
|
+
BookingStatus.Booked,
|
511
|
+
BookingStatus.CheckedIn,
|
512
|
+
BookingStatus.Waitlisted,
|
513
|
+
]:
|
514
|
+
raise ValueError(
|
515
|
+
"Invalid status provided. Only Cancelled, Booked, CheckedIn, Waitlisted, and None are supported."
|
516
|
+
)
|
517
|
+
|
518
|
+
status_value = status.value if status else None
|
519
|
+
|
520
|
+
params = {"status": status_value}
|
521
|
+
|
522
|
+
res = await self._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
|
523
|
+
|
524
|
+
return BookingList(bookings=res["data"])
|
525
|
+
|
526
|
+
async def get_challenge_tracker_content(self) -> ChallengeTrackerContent:
|
527
|
+
"""Get the member's challenge tracker content.
|
528
|
+
|
529
|
+
Returns:
|
530
|
+
ChallengeTrackerContent: The member's challenge tracker content.
|
531
|
+
"""
|
532
|
+
data = await self._default_request("GET", f"/challenges/v3.1/member/{self._member_id}")
|
533
|
+
return ChallengeTrackerContent(**data["Dto"])
|
534
|
+
|
535
|
+
async def get_challenge_tracker_detail(
|
536
|
+
self, equipment_id: EquipmentType, challenge_type_id: ChallengeType, challenge_sub_type_id: int = 0
|
537
|
+
) -> ChallengeTrackerDetailList:
|
538
|
+
"""Get the member's challenge tracker details.
|
539
|
+
|
540
|
+
Args:
|
541
|
+
equipment_id (EquipmentType): The equipment ID.
|
542
|
+
challenge_type_id (ChallengeType): The challenge type ID.
|
543
|
+
challenge_sub_type_id (int): The challenge sub type ID. Default is 0.
|
544
|
+
|
545
|
+
Returns:
|
546
|
+
ChallengeTrackerDetailList: The member's challenge tracker details.
|
547
|
+
|
548
|
+
Notes:
|
549
|
+
---
|
550
|
+
I'm not sure what the challenge_sub_type_id is supposed to be, so it defaults to 0.
|
551
|
+
|
552
|
+
"""
|
553
|
+
params = {
|
554
|
+
"equipmentId": equipment_id.value,
|
555
|
+
"challengeTypeId": challenge_type_id.value,
|
556
|
+
"challengeSubTypeId": challenge_sub_type_id,
|
557
|
+
}
|
558
|
+
|
559
|
+
data = await self._default_request("GET", f"/challenges/v3/member/{self._member_id}/benchmarks", params=params)
|
560
|
+
|
561
|
+
return ChallengeTrackerDetailList(details=data["Dto"])
|
562
|
+
|
563
|
+
async def get_challenge_tracker_participation(self, challenge_type_id: ChallengeType) -> typing.Any:
|
564
|
+
"""Get the member's participation in a challenge.
|
565
|
+
|
566
|
+
Args:
|
567
|
+
challenge_type_id (ChallengeType): The challenge type ID.
|
568
|
+
|
569
|
+
Returns:
|
570
|
+
Any: The member's participation in the challenge.
|
571
|
+
|
572
|
+
Notes:
|
573
|
+
---
|
574
|
+
I've never gotten this to return anything other than invalid response. I'm not sure if it's a bug
|
575
|
+
in my code or the API.
|
576
|
+
|
577
|
+
"""
|
578
|
+
params = {"challengeTypeId": challenge_type_id.value}
|
579
|
+
|
580
|
+
data = await self._default_request(
|
581
|
+
"GET", f"/challenges/v1/member/{self._member_id}/participation", params=params
|
582
|
+
)
|
583
|
+
return data
|
584
|
+
|
585
|
+
async def get_member_detail(
|
586
|
+
self, include_addresses: bool = True, include_class_summary: bool = True, include_credit_card: bool = False
|
587
|
+
) -> MemberDetail:
|
588
|
+
"""Get the member details.
|
589
|
+
|
590
|
+
Args:
|
591
|
+
include_addresses (bool): Whether to include the member's addresses in the response.
|
592
|
+
include_class_summary (bool): Whether to include the member's class summary in the response.
|
593
|
+
include_credit_card (bool): Whether to include the member's credit card information in the response.
|
594
|
+
|
595
|
+
Returns:
|
596
|
+
MemberDetail: The member details.
|
597
|
+
|
598
|
+
|
599
|
+
Notes:
|
600
|
+
---
|
601
|
+
The include_addresses, include_class_summary, and include_credit_card parameters are optional and determine
|
602
|
+
what additional information is included in the response. By default, all additional information is included,
|
603
|
+
with the exception of the credit card information.
|
604
|
+
|
605
|
+
The base member details include the last four of a credit card regardless of the include_credit_card,
|
606
|
+
although this is not always the same details as what is in the member_credit_card field. There doesn't seem
|
607
|
+
to be a way to exclude this information, and I do not know which is which or why they differ.
|
608
|
+
"""
|
609
|
+
|
610
|
+
include: list[str] = []
|
611
|
+
if include_addresses:
|
612
|
+
include.append("memberAddresses")
|
613
|
+
|
614
|
+
if include_class_summary:
|
615
|
+
include.append("memberClassSummary")
|
616
|
+
|
617
|
+
if include_credit_card:
|
618
|
+
include.append("memberCreditCard")
|
619
|
+
|
620
|
+
params = {"include": ",".join(include)} if include else None
|
621
|
+
|
622
|
+
data = await self._default_request("GET", f"/member/members/{self._member_id}", params=params)
|
623
|
+
return MemberDetail(**data["data"])
|
624
|
+
|
625
|
+
async def get_member_membership(self) -> MemberMembership:
|
626
|
+
"""Get the member's membership details.
|
627
|
+
|
628
|
+
Returns:
|
629
|
+
MemberMembership: The member's membership details.
|
630
|
+
"""
|
631
|
+
|
632
|
+
data = await self._default_request("GET", f"/member/members/{self._member_id}/memberships")
|
633
|
+
return MemberMembership(**data["data"])
|
634
|
+
|
635
|
+
async def get_member_purchases(self) -> MemberPurchaseList:
|
636
|
+
"""Get the member's purchases, including monthly subscriptions and class packs.
|
637
|
+
|
638
|
+
Returns:
|
639
|
+
MemberPurchaseList: The member's purchases.
|
640
|
+
"""
|
641
|
+
data = await self._default_request("GET", f"/member/members/{self._member_id}/purchases")
|
642
|
+
return MemberPurchaseList(data=data["data"])
|
643
|
+
|
644
|
+
async def get_member_lifetime_stats(self, select_time: StatsTime = StatsTime.AllTime) -> StatsResponse:
|
645
|
+
"""Get the member's lifetime stats.
|
646
|
+
|
647
|
+
Args:
|
648
|
+
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
649
|
+
|
650
|
+
Notes:
|
651
|
+
---
|
652
|
+
The time period provided in the path does not do anything, and the endpoint always returns the same data.
|
653
|
+
It is being provided anyway, in case this changes in the future.
|
654
|
+
|
655
|
+
Returns:
|
656
|
+
Any: The member's lifetime stats.
|
657
|
+
"""
|
658
|
+
|
659
|
+
data = await self._default_request("GET", f"/performance/v2/{self._member_id}/over-time/{select_time.value}")
|
660
|
+
|
661
|
+
stats = StatsResponse(**data["data"])
|
662
|
+
return stats
|
663
|
+
|
664
|
+
async def get_out_of_studio_workout_history(self) -> OutOfStudioWorkoutHistoryList:
|
665
|
+
"""Get the member's out of studio workout history.
|
666
|
+
|
667
|
+
Returns:
|
668
|
+
OutOfStudioWorkoutHistoryList: The member's out of studio workout history.
|
669
|
+
"""
|
670
|
+
data = await self._default_request("GET", f"/member/members/{self._member_id}/out-of-studio-workout")
|
671
|
+
|
672
|
+
return OutOfStudioWorkoutHistoryList(data=data["data"])
|
673
|
+
|
674
|
+
async def get_favorite_studios(self) -> FavoriteStudioList:
|
675
|
+
"""Get the member's favorite studios.
|
676
|
+
|
677
|
+
Returns:
|
678
|
+
FavoriteStudioList: The member's favorite studios.
|
679
|
+
"""
|
680
|
+
data = await self._default_request("GET", f"/member/members/{self._member_id}/favorite-studios")
|
681
|
+
|
682
|
+
return FavoriteStudioList(studios=data["data"])
|
683
|
+
|
684
|
+
async def get_latest_agreement(self) -> LatestAgreement:
|
685
|
+
"""Get the latest agreement for the member.
|
686
|
+
|
687
|
+
Returns:
|
688
|
+
LatestAgreement: The agreement.
|
689
|
+
|
690
|
+
Notes:
|
691
|
+
---
|
692
|
+
In this context, "latest" means the most recent agreement with a specific ID, not the most recent agreement
|
693
|
+
in general. The agreement ID is hardcoded in the endpoint, so it will always return the same agreement.
|
694
|
+
"""
|
695
|
+
data = await self._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
|
696
|
+
return LatestAgreement(**data["data"])
|
697
|
+
|
698
|
+
async def get_studio_services(self, studio_uuid: str | None = None) -> StudioServiceList:
|
699
|
+
"""Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
|
700
|
+
will be used.
|
701
|
+
|
702
|
+
Args:
|
703
|
+
studio_uuid (str): The studio UUID to get services for. Default is None, which will use the member's home\
|
704
|
+
studio.
|
705
|
+
|
706
|
+
Returns:
|
707
|
+
StudioServiceList: The services available at the studio.
|
708
|
+
"""
|
709
|
+
studio_uuid = studio_uuid or self.home_studio_uuid
|
710
|
+
data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services")
|
711
|
+
return StudioServiceList(data=data["data"])
|
712
|
+
|
713
|
+
async def get_performance_summaries(self, limit: int = 30) -> PerformanceSummaryList:
|
714
|
+
"""Get a list of performance summaries for the authenticated user.
|
715
|
+
|
716
|
+
Args:
|
717
|
+
limit (int): The maximum number of performance summaries to return. Defaults to 30.
|
718
|
+
|
719
|
+
Returns:
|
720
|
+
PerformanceSummaryList: A list of performance summaries.
|
721
|
+
|
722
|
+
Developer Notes:
|
723
|
+
---
|
724
|
+
In the app, this is referred to as 'getInStudioWorkoutHistory'.
|
725
|
+
|
726
|
+
"""
|
727
|
+
|
728
|
+
path = "/v1/performance-summaries"
|
729
|
+
params = {"limit": limit}
|
730
|
+
res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers, params=params)
|
731
|
+
retval = PerformanceSummaryList(summaries=res["items"])
|
732
|
+
return retval
|
733
|
+
|
734
|
+
async def get_performance_summary(self, performance_summary_id: str) -> PerformanceSummaryDetail:
|
735
|
+
"""Get a detailed performance summary for a given workout.
|
736
|
+
|
737
|
+
Args:
|
738
|
+
performance_summary_id (str): The ID of the performance summary to retrieve.
|
739
|
+
|
740
|
+
Returns:
|
741
|
+
PerformanceSummaryDetail: A detailed performance summary.
|
742
|
+
"""
|
743
|
+
|
744
|
+
path = f"/v1/performance-summaries/{performance_summary_id}"
|
745
|
+
res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers)
|
746
|
+
retval = PerformanceSummaryDetail(**res)
|
747
|
+
return retval
|
748
|
+
|
749
|
+
async def get_studio_detail(self, studio_uuid: str | None = None) -> StudioDetail:
|
750
|
+
"""Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
|
751
|
+
user's home studio.
|
752
|
+
|
753
|
+
Args:
|
754
|
+
studio_uuid (str): Studio UUID to get details for. Defaults to None, which will default to the user's home\
|
755
|
+
studio.
|
756
|
+
|
757
|
+
Returns:
|
758
|
+
StudioDetail: Detailed information about the studio.
|
759
|
+
"""
|
760
|
+
studio_uuid = studio_uuid or self.home_studio_uuid
|
761
|
+
|
762
|
+
path = f"/mobile/v1/studios/{studio_uuid}"
|
763
|
+
params = {"include": "locations"}
|
764
|
+
|
765
|
+
res = await self._default_request("GET", path, params=params)
|
766
|
+
return StudioDetail(**res["data"])
|
767
|
+
|
768
|
+
async def search_studios_by_geo(
|
769
|
+
self,
|
770
|
+
latitude: float | None = None,
|
771
|
+
longitude: float | None = None,
|
772
|
+
distance: float = 50,
|
773
|
+
page_index: int = 1,
|
774
|
+
page_size: int = 50,
|
775
|
+
) -> StudioDetailList:
|
776
|
+
"""Search for studios by geographic location.
|
777
|
+
|
778
|
+
Args:
|
779
|
+
latitude (float, optional): Latitude of the location to search around, if None uses home studio latitude.
|
780
|
+
longitude (float, optional): Longitude of the location to search around, if None uses home studio longitude.
|
781
|
+
distance (float, optional): Distance in miles to search around the location. Defaults to 50.
|
782
|
+
page_index (int, optional): Page index to start at. Defaults to 1.
|
783
|
+
page_size (int, optional): Number of results per page. Defaults to 50.
|
784
|
+
|
785
|
+
Returns:
|
786
|
+
StudioDetailList: List of studios that match the search criteria.
|
787
|
+
|
788
|
+
Notes:
|
789
|
+
---
|
790
|
+
There does not seem to be a limit to the number of results that can be requested total or per page, the
|
791
|
+
library enforces a limit of 50 results per page to avoid potential rate limiting issues.
|
792
|
+
|
793
|
+
"""
|
794
|
+
path = "/mobile/v1/studios"
|
795
|
+
|
796
|
+
if not latitude and not longitude:
|
797
|
+
home_studio = await self.get_studio_detail()
|
798
|
+
|
799
|
+
latitude = home_studio.studio_location.latitude
|
800
|
+
longitude = home_studio.studio_location.longitude
|
801
|
+
|
802
|
+
if page_size > 50:
|
803
|
+
self.logger.warning("The API does not support more than 50 results per page, limiting to 50.")
|
804
|
+
page_size = 50
|
805
|
+
|
806
|
+
if page_index < 1:
|
807
|
+
self.logger.warning("Page index must be greater than 0, setting to 1.")
|
808
|
+
page_index = 1
|
809
|
+
|
810
|
+
params = {
|
811
|
+
"pageIndex": page_index,
|
812
|
+
"pageSize": page_size,
|
813
|
+
"latitude": latitude,
|
814
|
+
"longitude": longitude,
|
815
|
+
"distance": distance,
|
816
|
+
}
|
817
|
+
|
818
|
+
all_results: list[StudioDetail] = []
|
819
|
+
|
820
|
+
while True:
|
821
|
+
res = await self._default_request("GET", path, params=params)
|
822
|
+
pagination = Pagination(**res["data"].pop("pagination"))
|
823
|
+
all_results.extend([StudioDetail(**studio) for studio in res["data"]["studios"]])
|
824
|
+
|
825
|
+
if len(all_results) == pagination.total_count:
|
826
|
+
break
|
827
|
+
|
828
|
+
params["pageIndex"] += 1
|
829
|
+
|
830
|
+
return StudioDetailList(studios=all_results)
|
831
|
+
|
832
|
+
async def get_hr_history(self) -> TelemetryHrHistory:
|
833
|
+
"""Get the heartrate history for the user.
|
834
|
+
|
835
|
+
Returns a list of history items that contain the max heartrate, start/end bpm for each zone,
|
836
|
+
the change from the previous, the change bucket, and the assigned at time.
|
837
|
+
|
838
|
+
Returns:
|
839
|
+
TelemetryHrHistory: The heartrate history for the user.
|
840
|
+
|
841
|
+
"""
|
842
|
+
path = "/v1/physVars/maxHr/history"
|
843
|
+
|
844
|
+
params = {"memberUuid": self._member_id}
|
845
|
+
res = await self._telemetry_request("GET", path, params=params)
|
846
|
+
return TelemetryHrHistory(**res)
|
847
|
+
|
848
|
+
async def get_max_hr(self) -> TelemetryMaxHr:
|
849
|
+
"""Get the max heartrate for the user.
|
850
|
+
|
851
|
+
Returns a simple object that has the member_uuid and the max_hr.
|
852
|
+
|
853
|
+
Returns:
|
854
|
+
TelemetryMaxHr: The max heartrate for the user.
|
855
|
+
"""
|
856
|
+
path = "/v1/physVars/maxHr"
|
857
|
+
|
858
|
+
params = {"memberUuid": self._member_id}
|
859
|
+
|
860
|
+
res = await self._telemetry_request("GET", path, params=params)
|
861
|
+
return TelemetryMaxHr(**res)
|
862
|
+
|
863
|
+
async def get_telemetry(self, class_history_uuid: str, max_data_points: int = 0) -> Telemetry:
|
864
|
+
"""Get the telemetry for a class history.
|
865
|
+
|
866
|
+
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
867
|
+
and a list of telemetry items that contain the heartrate, splat points, calories, and timestamp.
|
868
|
+
|
869
|
+
Args:
|
870
|
+
class_history_uuid (str): The class history UUID.
|
871
|
+
max_data_points (int): The max data points to use for the telemetry. Default is 0, which will attempt to\
|
872
|
+
get the max data points from the workout. If the workout is not found, it will default to 120 data points.
|
873
|
+
|
874
|
+
Returns:
|
875
|
+
TelemetryItem: The telemetry for the class history.
|
876
|
+
|
877
|
+
"""
|
878
|
+
path = "/v1/performance/summary"
|
879
|
+
|
880
|
+
max_data_points = max_data_points or await self._get_max_data_points(class_history_uuid)
|
881
|
+
|
882
|
+
params = {"classHistoryUuid": class_history_uuid, "maxDataPoints": max_data_points}
|
883
|
+
res = await self._telemetry_request("GET", path, params=params)
|
884
|
+
return Telemetry(**res)
|
885
|
+
|
886
|
+
async def _get_max_data_points(self, class_history_uuid: str) -> int:
|
887
|
+
"""Get the max data points to use for the telemetry.
|
888
|
+
|
889
|
+
Attempts to get the amount of active time for the workout from the OT Live API. If the workout is not found,
|
890
|
+
it will default to 120 data points. If it is found, it will calculate the amount of data points needed based on
|
891
|
+
the active time. This should amount to a data point per 30 seconds, roughly.
|
892
|
+
|
893
|
+
Args:
|
894
|
+
class_history_uuid (str): The class history UUID.
|
895
|
+
|
896
|
+
Returns:
|
897
|
+
int: The max data points to use.
|
898
|
+
"""
|
899
|
+
workouts = await self.get_workouts()
|
900
|
+
workout = workouts.by_class_history_uuid.get(class_history_uuid)
|
901
|
+
max_data_points = 120 if workout is None else ceil(active_time_to_data_points(workout.active_time))
|
902
|
+
return max_data_points
|
903
|
+
|
904
|
+
# the below do not return any data for me, so I can't test them
|
905
|
+
|
906
|
+
async def _get_member_services(self, active_only: bool = True) -> typing.Any:
|
907
|
+
"""Get the member's services.
|
908
|
+
|
909
|
+
Args:
|
910
|
+
active_only (bool): Whether to only include active services. Default is True.
|
911
|
+
|
912
|
+
Returns:
|
913
|
+
Any: The member's service
|
914
|
+
."""
|
915
|
+
active_only_str = "true" if active_only else "false"
|
916
|
+
data = await self._default_request(
|
917
|
+
"GET", f"/member/members/{self._member_id}/services", params={"activeOnly": active_only_str}
|
918
|
+
)
|
919
|
+
return data
|
920
|
+
|
921
|
+
async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> typing.Any:
|
922
|
+
"""Get data from the member's aspire wearable.
|
923
|
+
|
924
|
+
Note: I don't have an aspire wearable, so I can't test this.
|
925
|
+
|
926
|
+
Args:
|
927
|
+
datetime (str | None): The date and time to get data for. Default is None.
|
928
|
+
unit (str | None): The measurement unit. Default is None.
|
929
|
+
|
930
|
+
Returns:
|
931
|
+
Any: The member's aspire data.
|
932
|
+
"""
|
933
|
+
params = {"datetime": datetime, "unit": unit}
|
934
|
+
|
935
|
+
data = self._default_request("GET", f"/member/wearables/{self._member_id}/wearable-daily", params=params)
|
936
|
+
return data
|
937
|
+
|
938
|
+
async def get_body_composition_list(self) -> BodyCompositionList:
|
939
|
+
"""Get the member's body composition list.
|
940
|
+
|
941
|
+
Returns:
|
942
|
+
Any: The member's body composition list.
|
943
|
+
"""
|
944
|
+
data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
|
945
|
+
|
946
|
+
return BodyCompositionList(data=data["data"])
|
947
|
+
|
948
|
+
|
949
|
+
def active_time_to_data_points(active_time: int) -> float:
|
950
|
+
return active_time / 60 * 2
|