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/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