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/devices.py
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from .const import (
|
|
5
|
+
SMARTDOOR_FINAL_ACT_LOCKED,
|
|
6
|
+
SMARTDOOR_FINAL_ACT_UNLOCKED,
|
|
7
|
+
SMARTDOOR_MODE_MANUAL_LOCKED,
|
|
8
|
+
SMARTDOOR_MODE_MANUAL_UNLOCKED,
|
|
9
|
+
SMARTDOOR_MODE_SMART,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeviceSmartFeed:
|
|
14
|
+
def __init__(self, client, data: dict):
|
|
15
|
+
"""
|
|
16
|
+
PetSafe SmartFeed device.
|
|
17
|
+
|
|
18
|
+
:param client: PetSafeClient with authorization tokens
|
|
19
|
+
:param data: data regarding feeder
|
|
20
|
+
"""
|
|
21
|
+
self.client = client
|
|
22
|
+
self.data = data
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return self.to_json()
|
|
26
|
+
|
|
27
|
+
def to_json(self) -> str:
|
|
28
|
+
"""
|
|
29
|
+
All feeder data formatted as JSON.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
return json.dumps(self.data, indent=2)
|
|
33
|
+
|
|
34
|
+
async def update_data(self) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Updates self.data to the feeder's current online state.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
response = await self.client.api_get(self.api_path)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
self.data = json.loads(response.content.decode("UTF-8"))
|
|
42
|
+
|
|
43
|
+
async def put_setting(
|
|
44
|
+
self, setting: str, value, force_update: bool = False
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Changes the value of a specified setting. Sends PUT to API.
|
|
48
|
+
|
|
49
|
+
:param setting: the setting to change
|
|
50
|
+
:param value: the new value of that setting
|
|
51
|
+
:param force_update: if True, update ALL data after PUT. Defaults to False.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
response = await self.client.api_put(
|
|
55
|
+
self.api_path + "settings/" + setting, data={"value": value}
|
|
56
|
+
)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
|
|
59
|
+
if force_update:
|
|
60
|
+
await self.update_data()
|
|
61
|
+
else:
|
|
62
|
+
self.data["settings"][setting] = value
|
|
63
|
+
|
|
64
|
+
async def get_messages_since(self, days: int = 7):
|
|
65
|
+
"""
|
|
66
|
+
Requests all feeder messages.
|
|
67
|
+
|
|
68
|
+
:param days: how many days to request back. Defaults to 7.
|
|
69
|
+
:return: the APIs response in JSON.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
response = await self.client.api_get(
|
|
73
|
+
self.api_path + "messages?days=" + str(days)
|
|
74
|
+
)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
return json.loads(response.content.decode("UTF-8"))
|
|
77
|
+
|
|
78
|
+
async def get_last_feeding(self):
|
|
79
|
+
"""
|
|
80
|
+
Finds the last feeding in the feeder's messages.
|
|
81
|
+
|
|
82
|
+
:return: the feeding message, if found. Otherwise, None.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
messages = await self.get_messages_since()
|
|
86
|
+
for message in messages:
|
|
87
|
+
if message["message_type"] == "FEED_DONE":
|
|
88
|
+
return message
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
async def feed(
|
|
92
|
+
self, amount: int = 1, slow_feed: bool = None, update_data: bool = True
|
|
93
|
+
) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Triggers the feeder to begin feeding.
|
|
96
|
+
|
|
97
|
+
:param amount: the amount to feed in 1/8 increments.
|
|
98
|
+
:param slow_feed: if True, will use slow feeding. If None, defaults to current settings.
|
|
99
|
+
:param update_data: if True, will update the feeder's data after feeding. Defaults to True.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
if slow_feed is None:
|
|
103
|
+
slow_feed = self.data["settings"]["slow_feed"]
|
|
104
|
+
response = await self.client.api_post(
|
|
105
|
+
self.api_path + "meals", data={"amount": amount, "slow_feed": slow_feed}
|
|
106
|
+
)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
|
|
109
|
+
if update_data:
|
|
110
|
+
await self.update_data()
|
|
111
|
+
|
|
112
|
+
async def repeat_feed(self) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Repeats the last feeding.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
last_feeding = await self.get_last_feeding()
|
|
118
|
+
await self.feed(last_feeding["amount"])
|
|
119
|
+
|
|
120
|
+
async def prime(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Feeds 5/8 cups to prime the feeder.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
await self.feed(5, False)
|
|
126
|
+
|
|
127
|
+
async def get_schedules(self):
|
|
128
|
+
"""
|
|
129
|
+
Requests all feeding schedules.
|
|
130
|
+
|
|
131
|
+
:return: the APIs response in JSON.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
response = await self.client.api_get(self.api_path + "schedules")
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
return json.loads(response.content.decode("UTF-8"))
|
|
137
|
+
|
|
138
|
+
async def schedule_feed(
|
|
139
|
+
self, time: str = "00:00", amount: int = 1, update_data: bool = True
|
|
140
|
+
):
|
|
141
|
+
"""
|
|
142
|
+
Adds time and feed amount to schedule.
|
|
143
|
+
|
|
144
|
+
:param time: the time to dispense the food in 24 hour notation with colon separation (e.g. 16:35 for 4:35PM)
|
|
145
|
+
:param amount: the amount to feed in 1/8 increments.
|
|
146
|
+
:param update_data: if True, will update the feeder's data after feeding. Defaults to True.
|
|
147
|
+
:return: the unique id of the scheduled feed in json
|
|
148
|
+
|
|
149
|
+
"""
|
|
150
|
+
response = await self.client.api_post(
|
|
151
|
+
self.api_path + "schedules", data={"time": time, "amount": amount}
|
|
152
|
+
)
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
|
|
155
|
+
if update_data:
|
|
156
|
+
await self.update_data()
|
|
157
|
+
|
|
158
|
+
return json.loads(response.content.decode("UTF-8"))
|
|
159
|
+
|
|
160
|
+
async def modify_schedule(
|
|
161
|
+
self,
|
|
162
|
+
time: str = "00:00",
|
|
163
|
+
amount: int = 1,
|
|
164
|
+
schedule_id: str = "",
|
|
165
|
+
update_data: bool = True,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Modifies the specified schedule.
|
|
169
|
+
|
|
170
|
+
:param time: the time to dispense the food in 24 hour notation with colon separation (e.g. 16:35 for 4:35PM)
|
|
171
|
+
:param amount: the amount to feed in 1/8 increments.
|
|
172
|
+
:param schedule_id: the id of the scheduled feed to delete (six digits as of writing)
|
|
173
|
+
:param update_data: if True, will update the feeder's data after feeding. Defaults to True.
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
response = await self.client.api_put(
|
|
177
|
+
self.api_path + "schedules/" + schedule_id,
|
|
178
|
+
data={"time": time, "amount": amount},
|
|
179
|
+
)
|
|
180
|
+
response.raise_for_status()
|
|
181
|
+
|
|
182
|
+
if update_data:
|
|
183
|
+
await self.update_data()
|
|
184
|
+
|
|
185
|
+
async def delete_schedule(
|
|
186
|
+
self, schedule_id: str = "", update_data: bool = True
|
|
187
|
+
) -> None:
|
|
188
|
+
"""
|
|
189
|
+
Deletes specified schedule.
|
|
190
|
+
|
|
191
|
+
:param schedule_id: the id of the scheduled feed to delete (six digits as of writing)
|
|
192
|
+
:param update_data: if True, will update the feeder's data after feeding. Defaults to True.
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
response = await self.client.api_delete(
|
|
196
|
+
self.api_path + "schedules/" + schedule_id
|
|
197
|
+
)
|
|
198
|
+
response.raise_for_status()
|
|
199
|
+
|
|
200
|
+
if update_data:
|
|
201
|
+
await self.update_data()
|
|
202
|
+
|
|
203
|
+
async def delete_all_schedules(self, update_data: bool = True) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Deletes all schedules.
|
|
206
|
+
|
|
207
|
+
:param update_data: if True, will update the feeder's data after feeding. Defaults to True.
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
response = await self.client.api_delete(self.api_path + "schedules")
|
|
211
|
+
response.raise_for_status()
|
|
212
|
+
|
|
213
|
+
if update_data:
|
|
214
|
+
await self.update_data()
|
|
215
|
+
|
|
216
|
+
async def pause_schedules(self, value: bool, update_data: bool = True) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Pauses all schedules.
|
|
219
|
+
|
|
220
|
+
:param update_data: if True, will update the feeder's data after feeding. Defaults to True.
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
response = await self.client.api_put(
|
|
224
|
+
self.api_path + "settings/paused", data={"value": value}
|
|
225
|
+
)
|
|
226
|
+
response.raise_for_status()
|
|
227
|
+
|
|
228
|
+
if update_data:
|
|
229
|
+
await self.update_data()
|
|
230
|
+
|
|
231
|
+
async def pause(self, value: bool = True) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Sets or unsets the pause feeding value
|
|
234
|
+
"""
|
|
235
|
+
await self.put_setting("paused", value)
|
|
236
|
+
|
|
237
|
+
async def lock(self, value: bool = True) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Sets or unsets the button lock value.
|
|
240
|
+
"""
|
|
241
|
+
await self.put_setting("child_lock", value)
|
|
242
|
+
|
|
243
|
+
async def slow_feed(self, value: bool = True) -> None:
|
|
244
|
+
"""Sets or unsets the slow feed setting."""
|
|
245
|
+
await self.put_setting("slow_feed", value)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def api_name(self) -> str:
|
|
249
|
+
"""The feeder's thing_name from the API."""
|
|
250
|
+
return self.data["thing_name"]
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def api_path(self) -> str:
|
|
254
|
+
"""The feeder's path on the API."""
|
|
255
|
+
return "smart-feed/feeders/" + self.api_name + "/"
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def id(self) -> str:
|
|
259
|
+
"""The feeder's ID."""
|
|
260
|
+
return self.data["id"]
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def battery_voltage(self) -> float:
|
|
264
|
+
"""The feeder's calculated current battery voltage."""
|
|
265
|
+
try:
|
|
266
|
+
return round(int(self.data["battery_voltage"]) / 32767 * 7.2, 3)
|
|
267
|
+
except ValueError:
|
|
268
|
+
return -1
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def battery_level(self) -> int:
|
|
272
|
+
"""
|
|
273
|
+
The feeder's current battery level on a scale of 0-100.
|
|
274
|
+
Returns 0 if no batteries installed.
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
if not self.data["is_batteries_installed"]:
|
|
278
|
+
return 0
|
|
279
|
+
minVoltage = 22755
|
|
280
|
+
maxVoltage = 29100
|
|
281
|
+
return round(
|
|
282
|
+
max(
|
|
283
|
+
(100 * (int(self.data["battery_voltage"]) - minVoltage))
|
|
284
|
+
/ (maxVoltage - minVoltage),
|
|
285
|
+
0,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def is_paused(self) -> bool:
|
|
291
|
+
"""If true, the feeder will not follow its scheduling."""
|
|
292
|
+
return self.data["settings"]["paused"]
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def is_slow_feed(self) -> bool:
|
|
296
|
+
"""If true, the feeder will dispense food slowly."""
|
|
297
|
+
return self.data["settings"]["slow_feed"]
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def is_locked(self) -> bool:
|
|
301
|
+
"""If true, the feeder's physical button is disabled."""
|
|
302
|
+
return self.data["settings"]["child_lock"]
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def friendly_name(self) -> str:
|
|
306
|
+
"""The feeder's display name."""
|
|
307
|
+
return self.data["settings"]["friendly_name"]
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def pet_type(self) -> str:
|
|
311
|
+
"""The feeder's pet type."""
|
|
312
|
+
return self.data["settings"]["pet_type"]
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def food_sensor_current(self) -> str:
|
|
316
|
+
"""The feeder's food sensor status."""
|
|
317
|
+
return self.data["food_sensor_current"]
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def food_low_status(self) -> int:
|
|
321
|
+
"""
|
|
322
|
+
The feeder's food low status.
|
|
323
|
+
|
|
324
|
+
:return: 0 if Full, 1 if Low, 2 if Empty
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
return int(self.data["is_food_low"])
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def firmware(self) -> str:
|
|
331
|
+
"""The feeder's firmware."""
|
|
332
|
+
return self.data["firmware_version"]
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def product_name(self) -> str:
|
|
336
|
+
"""The feeder's product name."""
|
|
337
|
+
return self.data["product_name"]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class DeviceScoopfree:
|
|
341
|
+
def __init__(self, client, data):
|
|
342
|
+
"""
|
|
343
|
+
PetSafe Scoopfree device.
|
|
344
|
+
|
|
345
|
+
:param client: PetSafeClient with authorization tokens
|
|
346
|
+
:param data: data regarding litterbox
|
|
347
|
+
"""
|
|
348
|
+
self.client = client
|
|
349
|
+
self.data = data
|
|
350
|
+
|
|
351
|
+
def __str__(self) -> str:
|
|
352
|
+
return self.to_json()
|
|
353
|
+
|
|
354
|
+
def to_json(self) -> str:
|
|
355
|
+
"""
|
|
356
|
+
All litterbox data formatted as JSON.
|
|
357
|
+
|
|
358
|
+
"""
|
|
359
|
+
return json.dumps(self.data, indent=2)
|
|
360
|
+
|
|
361
|
+
async def update_data(self) -> None:
|
|
362
|
+
"""
|
|
363
|
+
Updates self.data to the litterbox's current online state.
|
|
364
|
+
"""
|
|
365
|
+
response = await self.client.api_get(self.api_path)
|
|
366
|
+
response.raise_for_status()
|
|
367
|
+
self.data = json.loads(response.content.decode("UTF-8"))
|
|
368
|
+
|
|
369
|
+
async def rake(self, update_data: bool = True):
|
|
370
|
+
"""
|
|
371
|
+
Triggers the rake to begin raking.
|
|
372
|
+
:param update_data: if True, will update the litterbox's data after raking. Defaults to True.
|
|
373
|
+
"""
|
|
374
|
+
response = await self.client.api_post(self.api_path + "rake-now", data={})
|
|
375
|
+
response.raise_for_status()
|
|
376
|
+
|
|
377
|
+
if update_data:
|
|
378
|
+
await self.update_data()
|
|
379
|
+
return self.data["data"]
|
|
380
|
+
|
|
381
|
+
async def reset(self, rakeCount: int = 0, update_data: bool = True):
|
|
382
|
+
"""
|
|
383
|
+
Resets the rake count to the specified value.
|
|
384
|
+
:param rakeCount: the value to set the rake count to.
|
|
385
|
+
:param update_data: if True, will update the litterbox's data after feeding. Defaults to True.
|
|
386
|
+
"""
|
|
387
|
+
response = await self.client.api_patch(
|
|
388
|
+
self.api_path + "shadow",
|
|
389
|
+
data={"rakeCount": rakeCount},
|
|
390
|
+
)
|
|
391
|
+
response.raise_for_status()
|
|
392
|
+
|
|
393
|
+
if update_data:
|
|
394
|
+
await self.update_data()
|
|
395
|
+
return self.data["data"]
|
|
396
|
+
|
|
397
|
+
async def modify_timer(self, rakeDelayTime: int = 15, update_data: bool = True):
|
|
398
|
+
"""
|
|
399
|
+
Modifies the rake timer.
|
|
400
|
+
:param rakeDelayTime: The amount of time for the rake delay in minutes.
|
|
401
|
+
:param update_data: if True, will update the litterbox's data after feeding. Defaults to True.
|
|
402
|
+
"""
|
|
403
|
+
response = await self.client.api_patch(
|
|
404
|
+
self.api_path + "shadow",
|
|
405
|
+
data={"rakeDelayTime": rakeDelayTime},
|
|
406
|
+
)
|
|
407
|
+
response.raise_for_status()
|
|
408
|
+
|
|
409
|
+
if update_data:
|
|
410
|
+
await self.update_data()
|
|
411
|
+
return self.data["data"]
|
|
412
|
+
|
|
413
|
+
async def get_activity(self):
|
|
414
|
+
"""
|
|
415
|
+
Requests all litterbox ativity.
|
|
416
|
+
|
|
417
|
+
:return: the APIs response in JSON.
|
|
418
|
+
|
|
419
|
+
"""
|
|
420
|
+
response = await self.client.api_get(self.api_path + "activity")
|
|
421
|
+
response.raise_for_status()
|
|
422
|
+
return json.loads(response.content.decode("UTF-8"))
|
|
423
|
+
|
|
424
|
+
async def patch_setting(
|
|
425
|
+
self, setting: str, value, force_update: bool = False
|
|
426
|
+
) -> None:
|
|
427
|
+
"""
|
|
428
|
+
Changes the value of a specified setting. Sends PATCH to API.
|
|
429
|
+
|
|
430
|
+
:param setting: the setting to change
|
|
431
|
+
:param value: the new value of that setting
|
|
432
|
+
:param force_update: if True, update ALL data after PATCH. Defaults to False.
|
|
433
|
+
|
|
434
|
+
"""
|
|
435
|
+
response = await self.client.api_patch(
|
|
436
|
+
self.api_path + "settings", data={setting: value}
|
|
437
|
+
)
|
|
438
|
+
response.raise_for_status()
|
|
439
|
+
|
|
440
|
+
if force_update:
|
|
441
|
+
await self.update_data()
|
|
442
|
+
else:
|
|
443
|
+
self.data[setting] = value
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def api_name(self) -> str:
|
|
447
|
+
"""The litterbox's thingName from the API."""
|
|
448
|
+
return self.data["thingName"]
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def api_path(self) -> str:
|
|
452
|
+
"""The litterbox's path on the API."""
|
|
453
|
+
return "scoopfree/product/product/" + self.api_name + "/"
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def friendly_name(self) -> str:
|
|
457
|
+
"""The litterbox's display name."""
|
|
458
|
+
return self.data["friendlyName"]
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def firmware(self) -> str:
|
|
462
|
+
"""The litterbox firmware."""
|
|
463
|
+
return self.data["shadow"]["state"]["reported"]["firmware"]
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def product_name(self) -> str:
|
|
467
|
+
"""The litterbox product name."""
|
|
468
|
+
return self.data["productName"]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class DeviceSmartDoor:
|
|
472
|
+
"""Representation of a PetSafe SmartDoor device."""
|
|
473
|
+
|
|
474
|
+
def __init__(self, client, data: dict):
|
|
475
|
+
self.client = client
|
|
476
|
+
self.data = data
|
|
477
|
+
|
|
478
|
+
@classmethod
|
|
479
|
+
async def get_smartdoor(cls, client, thing_name: str) -> "DeviceSmartDoor":
|
|
480
|
+
"""Fetch the details for a single SmartDoor identified by ``thing_name``."""
|
|
481
|
+
|
|
482
|
+
if not thing_name:
|
|
483
|
+
raise ValueError("thing_name must be provided")
|
|
484
|
+
|
|
485
|
+
response = await client.api_get(
|
|
486
|
+
f"smartdoor/product/product/{thing_name}/"
|
|
487
|
+
)
|
|
488
|
+
content = response.content.decode("UTF-8")
|
|
489
|
+
data = json.loads(content)
|
|
490
|
+
payload = data.get("data", data)
|
|
491
|
+
return cls(client, payload)
|
|
492
|
+
|
|
493
|
+
@classmethod
|
|
494
|
+
async def set_smartdoor_mode(
|
|
495
|
+
cls, client, thing_name: str, mode: str, *, update_data: bool = True
|
|
496
|
+
) -> "DeviceSmartDoor":
|
|
497
|
+
"""Set the operating ``mode`` for the SmartDoor identified by ``thing_name``."""
|
|
498
|
+
|
|
499
|
+
if not thing_name:
|
|
500
|
+
raise ValueError("thing_name must be provided")
|
|
501
|
+
|
|
502
|
+
door = cls(client, {"thingName": thing_name})
|
|
503
|
+
await door.set_mode(mode, update_data=update_data)
|
|
504
|
+
return door
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
async def manual_lock_smartdoor(
|
|
508
|
+
cls, client, thing_name: str, *, update_data: bool = True
|
|
509
|
+
) -> "DeviceSmartDoor":
|
|
510
|
+
"""Lock the SmartDoor manually using the documented API."""
|
|
511
|
+
|
|
512
|
+
return await cls.set_smartdoor_mode(
|
|
513
|
+
client,
|
|
514
|
+
thing_name,
|
|
515
|
+
SMARTDOOR_MODE_MANUAL_LOCKED,
|
|
516
|
+
update_data=update_data,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
@classmethod
|
|
520
|
+
async def manual_unlock_smartdoor(
|
|
521
|
+
cls, client, thing_name: str, *, update_data: bool = True
|
|
522
|
+
) -> "DeviceSmartDoor":
|
|
523
|
+
"""Unlock the SmartDoor manually using the documented API."""
|
|
524
|
+
|
|
525
|
+
return await cls.set_smartdoor_mode(
|
|
526
|
+
client,
|
|
527
|
+
thing_name,
|
|
528
|
+
SMARTDOOR_MODE_MANUAL_UNLOCKED,
|
|
529
|
+
update_data=update_data,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
@classmethod
|
|
533
|
+
async def smart_mode_smartdoor(
|
|
534
|
+
cls, client, thing_name: str, *, update_data: bool = True
|
|
535
|
+
) -> "DeviceSmartDoor":
|
|
536
|
+
"""Enable Smart mode on the SmartDoor using the documented API."""
|
|
537
|
+
|
|
538
|
+
return await cls.set_smartdoor_mode(
|
|
539
|
+
client,
|
|
540
|
+
thing_name,
|
|
541
|
+
SMARTDOOR_MODE_SMART,
|
|
542
|
+
update_data=update_data,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def __str__(self) -> str:
|
|
546
|
+
return self.to_json()
|
|
547
|
+
|
|
548
|
+
def to_json(self) -> str:
|
|
549
|
+
"""Return the SmartDoor payload encoded as JSON."""
|
|
550
|
+
|
|
551
|
+
return json.dumps(self.data, indent=2)
|
|
552
|
+
|
|
553
|
+
async def update_data(self) -> None:
|
|
554
|
+
"""Refresh the SmartDoor data from the API."""
|
|
555
|
+
|
|
556
|
+
response = await self.client.api_get(self.api_path)
|
|
557
|
+
response.raise_for_status()
|
|
558
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
559
|
+
self.data = payload.get("data", payload)
|
|
560
|
+
|
|
561
|
+
async def get_preferences(self) -> dict:
|
|
562
|
+
"""Return the SmartDoor preference data."""
|
|
563
|
+
|
|
564
|
+
response = await self.client.api_get(self.preferences_api_path)
|
|
565
|
+
response.raise_for_status()
|
|
566
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
567
|
+
data = payload.get("data", payload)
|
|
568
|
+
if isinstance(data, dict):
|
|
569
|
+
return self._normalize_preferences(data)
|
|
570
|
+
raise ValueError("Unexpected response payload for SmartDoor preferences")
|
|
571
|
+
|
|
572
|
+
async def update_friendly_name(
|
|
573
|
+
self, friendly_name: str, *, update_data: bool = True
|
|
574
|
+
) -> dict:
|
|
575
|
+
"""Update the SmartDoor friendly name preference."""
|
|
576
|
+
|
|
577
|
+
if not friendly_name:
|
|
578
|
+
raise ValueError("friendly_name must be provided")
|
|
579
|
+
return await self._patch_preferences(
|
|
580
|
+
{"friendlyName": friendly_name}, update_data=update_data
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
async def update_timezone(
|
|
584
|
+
self, timezone: str, *, update_data: bool = True
|
|
585
|
+
) -> dict:
|
|
586
|
+
"""Update the SmartDoor timezone preference."""
|
|
587
|
+
|
|
588
|
+
if not timezone:
|
|
589
|
+
raise ValueError("timezone must be provided")
|
|
590
|
+
return await self._patch_preferences({"tz": timezone}, update_data=update_data)
|
|
591
|
+
|
|
592
|
+
async def get_activity(
|
|
593
|
+
self, *, limit: Optional[int] = None, since: Optional[str] = None
|
|
594
|
+
) -> list[dict]:
|
|
595
|
+
"""Return SmartDoor activity entries.
|
|
596
|
+
|
|
597
|
+
:param limit: Optional maximum number of entries to return.
|
|
598
|
+
:param since: Optional ISO timestamp filter understood by the API.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
path = self.api_path + "activity"
|
|
602
|
+
query: list[str] = []
|
|
603
|
+
if limit is not None:
|
|
604
|
+
limit_value = int(limit)
|
|
605
|
+
if limit_value <= 0:
|
|
606
|
+
raise ValueError("limit must be a positive integer")
|
|
607
|
+
query.append(f"limit={limit_value}")
|
|
608
|
+
if since is not None:
|
|
609
|
+
from urllib.parse import quote_plus
|
|
610
|
+
|
|
611
|
+
query.append(f"since={quote_plus(since)}")
|
|
612
|
+
if query:
|
|
613
|
+
path += "?" + "&".join(query)
|
|
614
|
+
|
|
615
|
+
response = await self.client.api_get(path)
|
|
616
|
+
response.raise_for_status()
|
|
617
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
618
|
+
data = payload.get("data", payload)
|
|
619
|
+
if isinstance(data, list):
|
|
620
|
+
return data
|
|
621
|
+
if data is None:
|
|
622
|
+
return []
|
|
623
|
+
return [data]
|
|
624
|
+
|
|
625
|
+
async def set_mode(self, mode: str, update_data: bool = True) -> None:
|
|
626
|
+
"""Set the SmartDoor operating mode.
|
|
627
|
+
|
|
628
|
+
Valid modes include :data:`petsafe.const.SMARTDOOR_MODE_MANUAL_LOCKED`,
|
|
629
|
+
:data:`petsafe.const.SMARTDOOR_MODE_MANUAL_UNLOCKED`, and
|
|
630
|
+
:data:`petsafe.const.SMARTDOOR_MODE_SMART`.
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
await self.client.api_patch(
|
|
634
|
+
self.api_path + "shadow", data={"door": {"mode": mode}}
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if update_data:
|
|
638
|
+
await self.update_data()
|
|
639
|
+
else:
|
|
640
|
+
door_state = self._ensure_door_state()
|
|
641
|
+
door_state["mode"] = mode
|
|
642
|
+
|
|
643
|
+
async def lock(self, update_data: bool = True) -> None:
|
|
644
|
+
"""Lock the SmartDoor manually."""
|
|
645
|
+
|
|
646
|
+
await self.set_mode(
|
|
647
|
+
SMARTDOOR_MODE_MANUAL_LOCKED, update_data=update_data
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
async def unlock(self, update_data: bool = True) -> None:
|
|
651
|
+
"""Unlock the SmartDoor manually."""
|
|
652
|
+
|
|
653
|
+
await self.set_mode(
|
|
654
|
+
SMARTDOOR_MODE_MANUAL_UNLOCKED, update_data=update_data
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
async def smart_mode(self, update_data: bool = True) -> None:
|
|
658
|
+
"""Enable Smart mode on the SmartDoor."""
|
|
659
|
+
|
|
660
|
+
await self.set_mode(SMARTDOOR_MODE_SMART, update_data=update_data)
|
|
661
|
+
|
|
662
|
+
async def set_final_act(self, final_act: str, update_data: bool = True) -> None:
|
|
663
|
+
"""Set the SmartDoor final act state when power is lost."""
|
|
664
|
+
|
|
665
|
+
if final_act not in {
|
|
666
|
+
SMARTDOOR_FINAL_ACT_LOCKED,
|
|
667
|
+
SMARTDOOR_FINAL_ACT_UNLOCKED,
|
|
668
|
+
}:
|
|
669
|
+
raise ValueError("Unsupported final_act value")
|
|
670
|
+
|
|
671
|
+
await self.client.api_patch(
|
|
672
|
+
self.api_path + "shadow", data={"power": {"finalAct": final_act}}
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
if update_data:
|
|
676
|
+
await self.update_data()
|
|
677
|
+
else:
|
|
678
|
+
power_state = self._ensure_power_state()
|
|
679
|
+
power_state["finalAct"] = final_act
|
|
680
|
+
|
|
681
|
+
async def set_final_act_locked(self, update_data: bool = True) -> None:
|
|
682
|
+
"""Configure the SmartDoor to lock when power is lost."""
|
|
683
|
+
|
|
684
|
+
await self.set_final_act(SMARTDOOR_FINAL_ACT_LOCKED, update_data=update_data)
|
|
685
|
+
|
|
686
|
+
async def set_final_act_unlocked(self, update_data: bool = True) -> None:
|
|
687
|
+
"""Configure the SmartDoor to unlock when power is lost."""
|
|
688
|
+
|
|
689
|
+
await self.set_final_act(SMARTDOOR_FINAL_ACT_UNLOCKED, update_data=update_data)
|
|
690
|
+
|
|
691
|
+
async def get_schedules(self, *, update_data: bool = False) -> list[dict]:
|
|
692
|
+
"""Return the SmartDoor schedules configured for this device."""
|
|
693
|
+
|
|
694
|
+
response = await self.client.api_get(
|
|
695
|
+
f"smartdoor/product/schedules?thingName={self.api_name}"
|
|
696
|
+
)
|
|
697
|
+
response.raise_for_status()
|
|
698
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
699
|
+
data = payload.get("data", payload)
|
|
700
|
+
schedules = data if isinstance(data, list) else []
|
|
701
|
+
if update_data:
|
|
702
|
+
self.data["schedules"] = schedules
|
|
703
|
+
return schedules
|
|
704
|
+
|
|
705
|
+
async def get_override_schedules(self) -> list[dict]:
|
|
706
|
+
"""Return override schedules configured for the SmartDoor."""
|
|
707
|
+
|
|
708
|
+
response = await self.client.api_get(
|
|
709
|
+
f"smartdoor/product/override/schedules/{self.api_name}"
|
|
710
|
+
)
|
|
711
|
+
response.raise_for_status()
|
|
712
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
713
|
+
data = payload.get("data", payload)
|
|
714
|
+
if data is None:
|
|
715
|
+
return []
|
|
716
|
+
if isinstance(data, list):
|
|
717
|
+
return data
|
|
718
|
+
return [data]
|
|
719
|
+
|
|
720
|
+
async def override_schedule(
|
|
721
|
+
self, access: int, *, update_data: bool = True
|
|
722
|
+
) -> dict:
|
|
723
|
+
"""Apply a temporary override schedule to the SmartDoor."""
|
|
724
|
+
|
|
725
|
+
response = await self.client.api_patch(
|
|
726
|
+
"smartdoor/product/override/schedules",
|
|
727
|
+
data={"thingName": self.api_name, "access": int(access)},
|
|
728
|
+
)
|
|
729
|
+
response.raise_for_status()
|
|
730
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
731
|
+
data = payload.get("data", payload)
|
|
732
|
+
if update_data:
|
|
733
|
+
await self.update_data()
|
|
734
|
+
return data
|
|
735
|
+
|
|
736
|
+
async def save_schedule(
|
|
737
|
+
self, schedule: dict, *, update_data: bool = True
|
|
738
|
+
) -> dict:
|
|
739
|
+
"""Create or update a SmartDoor schedule."""
|
|
740
|
+
|
|
741
|
+
if not isinstance(schedule, dict):
|
|
742
|
+
raise TypeError("schedule must be a dict")
|
|
743
|
+
payload = dict(schedule)
|
|
744
|
+
payload.setdefault("thingName", self.api_name)
|
|
745
|
+
response = await self.client.api_post(
|
|
746
|
+
"smartdoor/product/schedules", data=payload
|
|
747
|
+
)
|
|
748
|
+
response.raise_for_status()
|
|
749
|
+
response_payload = json.loads(response.content.decode("UTF-8"))
|
|
750
|
+
data = response_payload.get("data", response_payload)
|
|
751
|
+
if update_data:
|
|
752
|
+
await self.update_data()
|
|
753
|
+
return data
|
|
754
|
+
|
|
755
|
+
async def delete_schedule(
|
|
756
|
+
self, schedule_id: str, *, update_data: bool = True
|
|
757
|
+
) -> None:
|
|
758
|
+
"""Delete a SmartDoor schedule by identifier."""
|
|
759
|
+
|
|
760
|
+
if not schedule_id:
|
|
761
|
+
raise ValueError("schedule_id must be provided")
|
|
762
|
+
await self.client.api_delete(
|
|
763
|
+
f"smartdoor/product/schedules/{schedule_id}"
|
|
764
|
+
)
|
|
765
|
+
if update_data:
|
|
766
|
+
await self.update_data()
|
|
767
|
+
|
|
768
|
+
@property
|
|
769
|
+
def api_name(self) -> str:
|
|
770
|
+
"""Return the SmartDoor thing name used by the API."""
|
|
771
|
+
|
|
772
|
+
name = self.data.get("thingName") or self.data.get("thing_name")
|
|
773
|
+
if not name:
|
|
774
|
+
raise KeyError("SmartDoor thing name not found in data")
|
|
775
|
+
return name
|
|
776
|
+
|
|
777
|
+
@property
|
|
778
|
+
def api_path(self) -> str:
|
|
779
|
+
"""Return the API path for the SmartDoor."""
|
|
780
|
+
|
|
781
|
+
return f"smartdoor/product/product/{self.api_name}/"
|
|
782
|
+
|
|
783
|
+
@property
|
|
784
|
+
def preferences_api_path(self) -> str:
|
|
785
|
+
"""Return the API path for SmartDoor preferences."""
|
|
786
|
+
|
|
787
|
+
return f"preferences/product/smartdoor/{self.api_name}"
|
|
788
|
+
|
|
789
|
+
@property
|
|
790
|
+
def schedules(self) -> list[dict]:
|
|
791
|
+
"""Return the configured SmartDoor schedules."""
|
|
792
|
+
|
|
793
|
+
return self.data.get("schedules", [])
|
|
794
|
+
|
|
795
|
+
@property
|
|
796
|
+
def friendly_name(self) -> Optional[str]:
|
|
797
|
+
"""Return the SmartDoor friendly name preference."""
|
|
798
|
+
|
|
799
|
+
return self.data.get("friendlyName")
|
|
800
|
+
|
|
801
|
+
@property
|
|
802
|
+
def timezone(self) -> Optional[str]:
|
|
803
|
+
"""Return the SmartDoor timezone preference."""
|
|
804
|
+
|
|
805
|
+
return self.data.get("tz")
|
|
806
|
+
|
|
807
|
+
@property
|
|
808
|
+
def mode(self) -> Optional[str]:
|
|
809
|
+
"""Return the currently reported operating mode."""
|
|
810
|
+
|
|
811
|
+
return self._reported_door_state().get("mode")
|
|
812
|
+
|
|
813
|
+
@property
|
|
814
|
+
def latch_state(self) -> Optional[str]:
|
|
815
|
+
"""Return the latch state of the SmartDoor."""
|
|
816
|
+
|
|
817
|
+
return self._reported_door_state().get("latchState")
|
|
818
|
+
|
|
819
|
+
@property
|
|
820
|
+
def error_state(self) -> Optional[str]:
|
|
821
|
+
"""Return the SmartDoor error state, if any."""
|
|
822
|
+
|
|
823
|
+
return self._reported_door_state().get("errorState")
|
|
824
|
+
|
|
825
|
+
@property
|
|
826
|
+
def battery_level(self) -> Optional[int]:
|
|
827
|
+
"""Return the SmartDoor battery percentage."""
|
|
828
|
+
|
|
829
|
+
return self._reported_power_state().get("batteryLevel")
|
|
830
|
+
|
|
831
|
+
@property
|
|
832
|
+
def battery_voltage(self) -> Optional[int]:
|
|
833
|
+
"""Return the SmartDoor battery voltage."""
|
|
834
|
+
|
|
835
|
+
return self._reported_power_state().get("batteryVoltage")
|
|
836
|
+
|
|
837
|
+
@property
|
|
838
|
+
def has_adapter(self) -> Optional[bool]:
|
|
839
|
+
"""Return whether the SmartDoor is on AC power."""
|
|
840
|
+
|
|
841
|
+
return self._reported_power_state().get("hasAdapter")
|
|
842
|
+
|
|
843
|
+
@property
|
|
844
|
+
def firmware(self) -> Optional[str]:
|
|
845
|
+
"""Return the firmware version reported by the SmartDoor."""
|
|
846
|
+
|
|
847
|
+
return self._reported_system_status().get("firmware")
|
|
848
|
+
|
|
849
|
+
@property
|
|
850
|
+
def rssi(self) -> Optional[int]:
|
|
851
|
+
"""Return the Wi-Fi RSSI reported by the SmartDoor."""
|
|
852
|
+
|
|
853
|
+
return self._reported_system_status().get("rssi")
|
|
854
|
+
|
|
855
|
+
@property
|
|
856
|
+
def connection_status(self) -> Optional[str]:
|
|
857
|
+
"""Return the SmartDoor connection status."""
|
|
858
|
+
|
|
859
|
+
return self._reported_state().get("connectionStatus")
|
|
860
|
+
|
|
861
|
+
def _reported_state(self) -> dict:
|
|
862
|
+
return (
|
|
863
|
+
self.data.get("shadow", {})
|
|
864
|
+
.get("state", {})
|
|
865
|
+
.get("reported", {})
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
def _reported_door_state(self) -> dict:
|
|
869
|
+
return self._reported_state().get("door", {})
|
|
870
|
+
|
|
871
|
+
def _reported_power_state(self) -> dict:
|
|
872
|
+
return self._reported_state().get("power", {})
|
|
873
|
+
|
|
874
|
+
def _reported_system_status(self) -> dict:
|
|
875
|
+
return self._reported_state().get("systemStatus", {})
|
|
876
|
+
|
|
877
|
+
def _ensure_door_state(self) -> dict:
|
|
878
|
+
shadow = self.data.setdefault("shadow", {})
|
|
879
|
+
state = shadow.setdefault("state", {})
|
|
880
|
+
reported = state.setdefault("reported", {})
|
|
881
|
+
return reported.setdefault("door", {})
|
|
882
|
+
|
|
883
|
+
def _ensure_power_state(self) -> dict:
|
|
884
|
+
shadow = self.data.setdefault("shadow", {})
|
|
885
|
+
state = shadow.setdefault("state", {})
|
|
886
|
+
reported = state.setdefault("reported", {})
|
|
887
|
+
return reported.setdefault("power", {})
|
|
888
|
+
|
|
889
|
+
async def _patch_preferences(
|
|
890
|
+
self, preferences: dict, *, update_data: bool
|
|
891
|
+
) -> dict:
|
|
892
|
+
response = await self.client.api_patch(
|
|
893
|
+
self.preferences_api_path, data=preferences
|
|
894
|
+
)
|
|
895
|
+
response.raise_for_status()
|
|
896
|
+
if update_data:
|
|
897
|
+
await self.update_data()
|
|
898
|
+
if not response.content:
|
|
899
|
+
return {}
|
|
900
|
+
payload = json.loads(response.content.decode("UTF-8"))
|
|
901
|
+
data = payload.get("data", payload)
|
|
902
|
+
if isinstance(data, dict):
|
|
903
|
+
return self._normalize_preferences(data)
|
|
904
|
+
raise ValueError("Unexpected response payload for SmartDoor preferences")
|
|
905
|
+
|
|
906
|
+
@staticmethod
|
|
907
|
+
def _normalize_preferences(data: dict) -> dict:
|
|
908
|
+
"""Flatten preference data returned by the SmartDoor API."""
|
|
909
|
+
|
|
910
|
+
normalized = dict(data)
|
|
911
|
+
preference_data = normalized.pop("preferenceData", None)
|
|
912
|
+
if isinstance(preference_data, dict):
|
|
913
|
+
normalized.update(preference_data)
|
|
914
|
+
return normalized
|