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 +2 -0
- petsafe/__main__.py +36 -0
- petsafe/client.py +364 -0
- petsafe/const.py +12 -0
- petsafe/devices.py +914 -0
- petsafe/general.py +29 -0
- petsafe/pets.py +35 -0
- petsafe_api-3.0.1.dist-info/METADATA +264 -0
- petsafe_api-3.0.1.dist-info/RECORD +12 -0
- petsafe_api-3.0.1.dist-info/WHEEL +5 -0
- petsafe_api-3.0.1.dist-info/licenses/LICENSE +19 -0
- petsafe_api-3.0.1.dist-info/top_level.txt +1 -0
petsafe/__init__.py
ADDED
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"
|