petsafe-api 3.0.1__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.
petsafe/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from . import devices, general, pets
2
+ from .client import PetSafeClient
petsafe/__main__.py ADDED
@@ -0,0 +1,36 @@
1
+ import sys
2
+ import argparse
3
+
4
+ from petsafe import PetSafeClient
5
+
6
+ # create parser for arguments
7
+ parser = argparse.ArgumentParser(
8
+ usage="python -m petsafe email [-t email_code]"
9
+ )
10
+ parser.add_argument("email", help="account email address")
11
+
12
+ # if no arguments specified, show help
13
+ if len(sys.argv) < 2:
14
+ parser.print_help()
15
+ sys.exit(1)
16
+
17
+ # parse for arguments
18
+ args = parser.parse_args()
19
+
20
+ client = PetSafeClient(email=args.email)
21
+ client.request_code()
22
+ print("Code requested, please check your email.")
23
+ print("")
24
+
25
+ code = input("Enter email code: ")
26
+ client.request_tokens_from_code(code)
27
+
28
+ print("")
29
+ print("IdToken:")
30
+ print(client.id_token)
31
+ print("")
32
+ print("AccessToken:")
33
+ print(client.access_token)
34
+ print("")
35
+ print("RefreshToken:")
36
+ print(client.refresh_token)
petsafe/client.py ADDED
@@ -0,0 +1,364 @@
1
+ import json
2
+ import re
3
+ import time
4
+
5
+ from botocore.config import Config
6
+ from botocore.session import Session, get_session
7
+ from botocore import UNSIGNED
8
+ import asyncio
9
+ import httpx
10
+
11
+ from petsafe.devices import DeviceScoopfree, DeviceSmartDoor, DeviceSmartFeed
12
+
13
+ from .const import (
14
+ PETSAFE_API_BASE,
15
+ PETSAFE_CLIENT_ID,
16
+ PETSAFE_REGION,
17
+ )
18
+
19
+
20
+ class PetSafeClient:
21
+ def __init__(
22
+ self,
23
+ email: str,
24
+ id_token: str = None,
25
+ refresh_token: str = None,
26
+ access_token: str = None,
27
+ session: str = None,
28
+ client: httpx.AsyncClient = None,
29
+ ):
30
+ self._id_token = id_token
31
+ self._refresh_token = refresh_token
32
+ self._access_token = access_token
33
+ self._email = email
34
+ self._session = session
35
+ self._username = None
36
+ self._token_expires_time = 0
37
+ self._challenge_name = None
38
+ if client is not None:
39
+ self._client = client
40
+ else:
41
+ self._client = httpx.AsyncClient()
42
+ self._cognitoSession = None
43
+ self._cognitoClient = None
44
+
45
+ async def get_feeders(self) -> list[DeviceSmartFeed]:
46
+ """
47
+ Sends a request to PetSafe's API for all feeders associated with account.
48
+
49
+ :param client: PetSafeClient with authorization tokens
50
+ :return: list of Feeders
51
+
52
+ """
53
+ response = await self.api_get("smart-feed/feeders")
54
+ content = response.content.decode("UTF-8")
55
+ return [
56
+ DeviceSmartFeed(self, feeder_data) for feeder_data in json.loads(content)
57
+ ]
58
+
59
+ async def get_litterboxes(self) -> list[DeviceScoopfree]:
60
+ """
61
+ Sends a request to PetSafe's API for all litterboxes associated with account.
62
+
63
+ :param client: PetSafeClient with authorization tokens
64
+ :return: list of Scoopfree litterboxes
65
+
66
+ """
67
+ response = await self.api_get("scoopfree/product/product")
68
+ content = response.content.decode("UTF-8")
69
+ return [
70
+ DeviceScoopfree(self, litterbox_data)
71
+ for litterbox_data in json.loads(content)["data"]
72
+ ]
73
+
74
+ async def get_smartdoors(self) -> list[DeviceSmartDoor]:
75
+ """
76
+ Sends a request to PetSafe's API for all smart doors associated with account.
77
+
78
+ :param client: PetSafeClient with authorization tokens
79
+ :return: list of SmartDoor devices
80
+
81
+ """
82
+ response = await self.api_get("smartdoor/product/product")
83
+ content = response.content.decode("UTF-8")
84
+ data = json.loads(content)
85
+ devices = data.get("data", data)
86
+ doors = [DeviceSmartDoor(self, door_data) for door_data in devices]
87
+ if not doors:
88
+ return []
89
+
90
+ preferences = await asyncio.gather(
91
+ *(door.get_preferences() for door in doors)
92
+ )
93
+
94
+ for door, prefs in zip(doors, preferences):
95
+ if isinstance(prefs, dict):
96
+ friendly_name = prefs.get("friendlyName")
97
+ if friendly_name is not None:
98
+ door.data["friendlyName"] = friendly_name
99
+
100
+ timezone = prefs.get("tz")
101
+ if timezone is not None:
102
+ door.data["tz"] = timezone
103
+
104
+ return doors
105
+
106
+ async def request_code(self) -> None:
107
+ """
108
+ Requests an email code from PetSafe authentication.
109
+
110
+ :return: response from PetSafe
111
+
112
+ """
113
+ await self.__get_cognito_session()
114
+ idp = await self.__get_cognito_client()
115
+ try:
116
+ loop = asyncio.get_event_loop()
117
+ response = await loop.run_in_executor(None, lambda:
118
+ idp.initiate_auth(
119
+ AuthFlow="CUSTOM_AUTH",
120
+ ClientId=PETSAFE_CLIENT_ID,
121
+ AuthParameters={
122
+ "USERNAME": self._email,
123
+ "AuthFlow": "CUSTOM_CHALLENGE",
124
+ },
125
+ )
126
+ )
127
+ self._challenge_name = response["ChallengeName"]
128
+ self._session = response["Session"]
129
+ self._username = response["ChallengeParameters"]["USERNAME"]
130
+ return response
131
+ except idp.exceptions.UserNotFoundException as ex:
132
+ raise InvalidUserException() from ex
133
+
134
+ async def request_tokens_from_code(self, code: str) -> None:
135
+ """
136
+ Requests tokens from PetSafe API using emailed code.
137
+
138
+ :param code: email code
139
+ :return: response from PetSafe
140
+
141
+ """
142
+ await self.__get_cognito_session()
143
+ idp = await self.__get_cognito_client()
144
+ loop = asyncio.get_event_loop()
145
+ response = await loop.run_in_executor(None, lambda:
146
+ idp.respond_to_auth_challenge(
147
+ ClientId=PETSAFE_CLIENT_ID,
148
+ ChallengeName=self._challenge_name,
149
+ Session=self._session,
150
+ ChallengeResponses={
151
+ "ANSWER": re.sub(r"\D", "", code),
152
+ "USERNAME": self._username,
153
+ },
154
+ )
155
+ )
156
+ if not "AuthenticationResult" in response:
157
+ raise InvalidCodeException("Invalid confirmation code")
158
+ self._id_token = response["AuthenticationResult"]["IdToken"]
159
+ self._access_token = response["AuthenticationResult"]["AccessToken"]
160
+ self._refresh_token = response["AuthenticationResult"]["RefreshToken"]
161
+ self._token_expires_time = (
162
+ time.time() + response["AuthenticationResult"]["ExpiresIn"]
163
+ )
164
+
165
+ async def __get_cognito_session(self) -> Session:
166
+ """
167
+ Retrieve a cognito session from botocore.
168
+
169
+ :return: botocore session
170
+ """
171
+ if self._cognitoSession is None:
172
+ loop = asyncio.get_event_loop()
173
+ self._cognitoSession = await loop.run_in_executor(None, get_session)
174
+ return self._cognitoSession
175
+
176
+ async def __get_cognito_client(self):
177
+ """
178
+ Retrieve a cognito client.
179
+
180
+ :return: cognito client
181
+ """
182
+ if self._cognitoClient is None:
183
+ loop = asyncio.get_event_loop()
184
+ self._cognitoClient = await loop.run_in_executor(None, lambda session:
185
+ session.create_client(
186
+ "cognito-idp",
187
+ region_name=PETSAFE_REGION,
188
+ config=Config(signature_version=UNSIGNED),
189
+ )
190
+ , self._cognitoSession)
191
+ return self._cognitoClient
192
+
193
+ async def __refresh_tokens(self) -> None:
194
+ """
195
+ Refreshes tokens with PetSafe.
196
+
197
+ :return: the response from PetSafe.
198
+
199
+ """
200
+ await self.__get_cognito_session()
201
+ idp = await self.__get_cognito_client()
202
+ loop = asyncio.get_event_loop()
203
+ response = await loop.run_in_executor(None, lambda:
204
+ idp.initiate_auth(
205
+ AuthFlow="REFRESH_TOKEN_AUTH",
206
+ AuthParameters={"REFRESH_TOKEN": self._refresh_token},
207
+ ClientId=PETSAFE_CLIENT_ID,
208
+ )
209
+ )
210
+
211
+ if "Session" in response:
212
+ self._session = response["Session"]
213
+
214
+ self._id_token = response["AuthenticationResult"]["IdToken"]
215
+ self._access_token = response["AuthenticationResult"]["AccessToken"]
216
+ if "RefreshToken" in response["AuthenticationResult"]:
217
+ self._refresh_token = response["AuthenticationResult"]["RefreshToken"]
218
+ self.token_expires_time = (
219
+ time.time() + response["AuthenticationResult"]["ExpiresIn"]
220
+ )
221
+
222
+ async def api_post(self, path: str = "", data: dict = None):
223
+ """
224
+ Sends a POST to PetSafe API.
225
+
226
+ Example: api_post(path=feeder.api_path + 'meals', data=food_data)
227
+
228
+ :param path: the path on the API
229
+ :param data: the POST data
230
+ :return: the request response
231
+
232
+ """
233
+ headers = await self.__get_headers()
234
+ response = await self._client.post(
235
+ PETSAFE_API_BASE + path, headers=headers, json=data
236
+ )
237
+ if response.status_code == 403:
238
+ await self.__refresh_tokens()
239
+ response = await self._client.post(
240
+ PETSAFE_API_BASE + path, headers=headers, json=data
241
+ )
242
+ response.raise_for_status()
243
+ return response
244
+
245
+ async def api_get(self, path: str = ""):
246
+ """
247
+ Sends a GET to PetSafe API.
248
+
249
+ Example: api_get(path='feeders')
250
+
251
+ :param path: the path on the API
252
+ :return: the request response
253
+
254
+ """
255
+ headers = await self.__get_headers()
256
+ response = await self._client.get(PETSAFE_API_BASE + path, headers=headers)
257
+ if response.status_code == 403:
258
+ await self.__refresh_tokens()
259
+ response = await self._client.get(PETSAFE_API_BASE + path, headers=headers)
260
+ response.raise_for_status()
261
+ return response
262
+
263
+ async def api_put(self, path: str = "", data: dict = None):
264
+ """
265
+ Sends a PUT to PetSafe API.
266
+
267
+ Example: api_put(path='feeders', data=my_data)
268
+
269
+ :param path: the path on the API
270
+ :param data: the PUT data
271
+ :return: the request response
272
+
273
+ """
274
+ headers = await self.__get_headers()
275
+ response = await self._client.put(PETSAFE_API_BASE + path, headers=headers, json=data)
276
+ if response.status_code == 403:
277
+ await self.__refresh_tokens()
278
+ response = await self._client.put(PETSAFE_API_BASE + path, headers=headers, json=data)
279
+ response.raise_for_status()
280
+ return response
281
+
282
+ async def api_patch(self, path: str = "", data: dict = None):
283
+ """
284
+ Sends a PATCH to PetSafe API.
285
+
286
+ Example: api_patch(path='feeders', data=my_data)
287
+
288
+ :param path: the path on the API
289
+ :param data: the PATCH data
290
+ :return: the request response
291
+
292
+ """
293
+ headers = await self.__get_headers()
294
+ response = await self._client.patch(
295
+ PETSAFE_API_BASE + path, headers=headers, json=data
296
+ )
297
+ if response.status_code == 403:
298
+ await self.__refresh_tokens()
299
+ response = await self._client.patch(
300
+ PETSAFE_API_BASE + path, headers=headers, json=data
301
+ )
302
+ response.raise_for_status()
303
+ return response
304
+
305
+ async def api_delete(self, path: str = ""):
306
+ """
307
+ Sends a DELETE to PetSafe API.
308
+
309
+ Example: api_delete(path='feeders')
310
+
311
+ :param path: the path on the API
312
+ :param data: the PATCH data
313
+ :return: the request response
314
+
315
+ """
316
+ headers = await self.__get_headers()
317
+ response = await self._client.delete(PETSAFE_API_BASE + path, headers=headers)
318
+ if response.status_code == 403:
319
+ await self.__refresh_tokens()
320
+ response = await self._client.delete(PETSAFE_API_BASE + path, headers=headers)
321
+ response.raise_for_status()
322
+ return response
323
+
324
+ async def __get_headers(self) -> dict:
325
+ """
326
+ Creates a dict of headers with JSON content-type and token.
327
+
328
+ :return: dictionary of headers
329
+
330
+ """
331
+ headers = {"Content-Type": "application/json"}
332
+
333
+ if self._id_token is None:
334
+ raise Exception("Not authorized! Have you requested a token?")
335
+
336
+ if time.time() >= self._token_expires_time - 100:
337
+ await self.__refresh_tokens()
338
+
339
+ headers["Authorization"] = self._id_token
340
+
341
+ return headers
342
+
343
+ @property
344
+ def id_token(self) -> str:
345
+ """Retrieve the id token associated with the connection."""
346
+ return self._id_token
347
+
348
+ @property
349
+ def access_token(self) -> str:
350
+ """Retrieve the access token associated with the connection."""
351
+ return self._access_token
352
+
353
+ @property
354
+ def refresh_token(self) -> str:
355
+ """Retrieve the refresh token associated with the connection."""
356
+ return self._refresh_token
357
+
358
+
359
+ class InvalidCodeException(Exception):
360
+ pass
361
+
362
+
363
+ class InvalidUserException(Exception):
364
+ pass
petsafe/const.py ADDED
@@ -0,0 +1,12 @@
1
+ PETSAFE_API_BASE = "https://platform.cloud.petsafe.net/"
2
+ PETSAFE_CLIENT_ID = "18hpp04puqmgf5nc6o474lcp2g"
3
+ PETSAFE_REGION = "us-east-1"
4
+
5
+ FOOD_LEVEL_FULL = 0
6
+ FOOD_LEVEL_LOW = 1
7
+ FOOD_LEVEL_EMPTY = 2
8
+ SMARTDOOR_MODE_MANUAL_LOCKED = "MANUAL_LOCKED"
9
+ SMARTDOOR_MODE_MANUAL_UNLOCKED = "MANUAL_UNLOCKED"
10
+ SMARTDOOR_MODE_SMART = "SMART"
11
+ SMARTDOOR_FINAL_ACT_LOCKED = "LOCKED"
12
+ SMARTDOOR_FINAL_ACT_UNLOCKED = "UNLOCKED"