pypetkitapi 1.11.3__tar.gz → 1.12.0__tar.gz
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.
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/PKG-INFO +18 -9
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/README.md +17 -8
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/__init__.py +1 -1
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/bluetooth.py +42 -19
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/client.py +114 -36
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/litter_container.py +1 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/media.py +170 -130
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/schedule_container.py +3 -3
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pyproject.toml +2 -2
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/LICENSE +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/command.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/const.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/containers.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/exceptions.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/feeder_container.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/purifier_container.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/py.typed +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/utils.py +0 -0
- {pypetkitapi-1.11.3 → pypetkitapi-1.12.0}/pypetkitapi/water_fountain_container.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: pypetkitapi
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.12.0
|
4
4
|
Summary: Python client for PetKit API
|
5
5
|
License: MIT
|
6
6
|
Author: Jezza34000
|
@@ -23,12 +23,14 @@ Description-Content-Type: text/markdown
|
|
23
23
|
|
24
24
|
---
|
25
25
|
|
26
|
-
[](https://github.com/Jezza34000/py-petkit-api/)
|
27
27
|
[][python version] [](https://github.com/Jezza34000/py-petkit-api/actions)
|
28
28
|
|
29
|
+
[][pypi_] [](https://pepy.tech/projects/pypetkitapi)
|
30
|
+
|
29
31
|
---
|
30
32
|
|
31
|
-
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api) [](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
33
|
+
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api) [](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api) [](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
32
34
|
|
33
35
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
34
36
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
@@ -38,8 +40,6 @@ Description-Content-Type: text/markdown
|
|
38
40
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
39
41
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
40
42
|
|
41
|
-
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
42
|
-
|
43
43
|
[][pre-commit]
|
44
44
|
[][black]
|
45
45
|
[](https://mypy.readthedocs.io/en/stable/)
|
@@ -58,9 +58,10 @@ PetKit Client is a Python library for interacting with the PetKit API. It allows
|
|
58
58
|
|
59
59
|
## Features
|
60
60
|
|
61
|
-
Login and session management
|
62
|
-
Fetch account and device data
|
63
|
-
Control PetKit devices (Feeder, Litter Box, Water Fountain)
|
61
|
+
- Login and session management
|
62
|
+
- Fetch account and device data
|
63
|
+
- Control PetKit devices (Feeder, Litter Box, Water Fountain, Purifiers)
|
64
|
+
- Fetch images & videos produced by devices
|
64
65
|
|
65
66
|
## Installation
|
66
67
|
|
@@ -72,6 +73,9 @@ pip install pypetkitapi
|
|
72
73
|
|
73
74
|
## Usage Example:
|
74
75
|
|
76
|
+
Here is a simple example of how to use the library to interact with the PetKit API \
|
77
|
+
This example is not an exhaustive list of all the features available in the library.
|
78
|
+
|
75
79
|
```python
|
76
80
|
import asyncio
|
77
81
|
import logging
|
@@ -130,9 +134,14 @@ if __name__ == "__main__":
|
|
130
134
|
|
131
135
|
Check at the usage in the Home Assistant integration : [here](https://github.com/Jezza34000/homeassistant_petkit)
|
132
136
|
|
137
|
+
## Help and Support
|
138
|
+
|
139
|
+
A discord server is available for support and help, check here: [here](https://github.com/Jezza34000/homeassistant_petkit)
|
140
|
+
|
133
141
|
## Contributing
|
134
142
|
|
135
|
-
Contributions are welcome
|
143
|
+
Contributions are welcome!\
|
144
|
+
Please open an issue or submit a pull request.
|
136
145
|
|
137
146
|
## License
|
138
147
|
|
@@ -2,12 +2,14 @@
|
|
2
2
|
|
3
3
|
---
|
4
4
|
|
5
|
-
[](https://github.com/Jezza34000/py-petkit-api/)
|
6
6
|
[][python version] [](https://github.com/Jezza34000/py-petkit-api/actions)
|
7
7
|
|
8
|
+
[][pypi_] [](https://pepy.tech/projects/pypetkitapi)
|
9
|
+
|
8
10
|
---
|
9
11
|
|
10
|
-
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api) [](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
12
|
+
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api) [](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api) [](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
11
13
|
|
12
14
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
13
15
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
@@ -17,8 +19,6 @@
|
|
17
19
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
18
20
|
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
19
21
|
|
20
|
-
[](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
|
21
|
-
|
22
22
|
[][pre-commit]
|
23
23
|
[][black]
|
24
24
|
[](https://mypy.readthedocs.io/en/stable/)
|
@@ -37,9 +37,10 @@ PetKit Client is a Python library for interacting with the PetKit API. It allows
|
|
37
37
|
|
38
38
|
## Features
|
39
39
|
|
40
|
-
Login and session management
|
41
|
-
Fetch account and device data
|
42
|
-
Control PetKit devices (Feeder, Litter Box, Water Fountain)
|
40
|
+
- Login and session management
|
41
|
+
- Fetch account and device data
|
42
|
+
- Control PetKit devices (Feeder, Litter Box, Water Fountain, Purifiers)
|
43
|
+
- Fetch images & videos produced by devices
|
43
44
|
|
44
45
|
## Installation
|
45
46
|
|
@@ -51,6 +52,9 @@ pip install pypetkitapi
|
|
51
52
|
|
52
53
|
## Usage Example:
|
53
54
|
|
55
|
+
Here is a simple example of how to use the library to interact with the PetKit API \
|
56
|
+
This example is not an exhaustive list of all the features available in the library.
|
57
|
+
|
54
58
|
```python
|
55
59
|
import asyncio
|
56
60
|
import logging
|
@@ -109,9 +113,14 @@ if __name__ == "__main__":
|
|
109
113
|
|
110
114
|
Check at the usage in the Home Assistant integration : [here](https://github.com/Jezza34000/homeassistant_petkit)
|
111
115
|
|
116
|
+
## Help and Support
|
117
|
+
|
118
|
+
A discord server is available for support and help, check here: [here](https://github.com/Jezza34000/homeassistant_petkit)
|
119
|
+
|
112
120
|
## Contributing
|
113
121
|
|
114
|
-
Contributions are welcome
|
122
|
+
Contributions are welcome!\
|
123
|
+
Please open an issue or submit a pull request.
|
115
124
|
|
116
125
|
## License
|
117
126
|
|
@@ -31,7 +31,10 @@ class BluetoothManager:
|
|
31
31
|
self.client = client
|
32
32
|
|
33
33
|
async def _get_fountain_instance(self, fountain_id: int) -> "WaterFountain":
|
34
|
-
"""Get the WaterFountain instance for the given fountain_id.
|
34
|
+
"""Get the WaterFountain instance for the given fountain_id.
|
35
|
+
:param fountain_id: The ID of the fountain to get the instance for.
|
36
|
+
:return: The WaterFountain instance for the given fountain_id.
|
37
|
+
"""
|
35
38
|
from pypetkitapi.water_fountain_container import WaterFountain
|
36
39
|
|
37
40
|
water_fountain = self.client.petkit_entities.get(fountain_id)
|
@@ -41,7 +44,10 @@ class BluetoothManager:
|
|
41
44
|
return water_fountain
|
42
45
|
|
43
46
|
async def check_relay_availability(self, fountain_id: int) -> bool:
|
44
|
-
"""Check if BLE relay is available for the given fountain_id.
|
47
|
+
"""Check if BLE relay is available for the given fountain_id.
|
48
|
+
:param fountain_id: The ID of the fountain to check the relay for.
|
49
|
+
:return: True if the relay is available, False otherwise.
|
50
|
+
"""
|
45
51
|
fountain = None
|
46
52
|
for account in self.client.account_data:
|
47
53
|
if account.device_list:
|
@@ -73,14 +79,17 @@ class BluetoothManager:
|
|
73
79
|
return True
|
74
80
|
|
75
81
|
async def open_ble_connection(self, fountain_id: int) -> bool:
|
76
|
-
"""Open a BLE connection to the given fountain_id.
|
77
|
-
|
82
|
+
"""Open a BLE connection to the given fountain_id.
|
83
|
+
:param fountain_id: The ID of the fountain to open the BLE connection for.
|
84
|
+
:return: True if the BLE connection was established, False otherwise.
|
85
|
+
"""
|
86
|
+
_LOGGER.debug("Opening BLE connection to fountain %s", fountain_id)
|
78
87
|
water_fountain = await self._get_fountain_instance(fountain_id)
|
79
88
|
if await self.check_relay_availability(fountain_id) is False:
|
80
|
-
_LOGGER.
|
89
|
+
_LOGGER.debug("BLE relay not available (id: %s).", fountain_id)
|
81
90
|
return False
|
82
91
|
if water_fountain.is_connected is True:
|
83
|
-
_LOGGER.
|
92
|
+
_LOGGER.debug("BLE connection already established (id %s)", fountain_id)
|
84
93
|
return True
|
85
94
|
response = await self.client.req.request(
|
86
95
|
method=HTTPMethod.POST,
|
@@ -89,7 +98,7 @@ class BluetoothManager:
|
|
89
98
|
headers=await self.client.get_session_id(),
|
90
99
|
)
|
91
100
|
if response != {"state": 1}:
|
92
|
-
_LOGGER.
|
101
|
+
_LOGGER.debug("Unable to open a BLE connection (id %s)", fountain_id)
|
93
102
|
water_fountain.is_connected = False
|
94
103
|
return False
|
95
104
|
for attempt in range(BLE_CONNECT_ATTEMPT):
|
@@ -106,7 +115,7 @@ class BluetoothManager:
|
|
106
115
|
headers=await self.client.get_session_id(),
|
107
116
|
)
|
108
117
|
if response == 1:
|
109
|
-
_LOGGER.
|
118
|
+
_LOGGER.debug(
|
110
119
|
"BLE connection established successfully (id %s)", fountain_id
|
111
120
|
)
|
112
121
|
water_fountain.is_connected = True
|
@@ -115,7 +124,7 @@ class BluetoothManager:
|
|
115
124
|
)
|
116
125
|
return True
|
117
126
|
await asyncio.sleep(4)
|
118
|
-
_LOGGER.
|
127
|
+
_LOGGER.debug(
|
119
128
|
"Failed to establish BLE connection after %s attempts (id %s)",
|
120
129
|
BLE_CONNECT_ATTEMPT,
|
121
130
|
fountain_id,
|
@@ -124,12 +133,15 @@ class BluetoothManager:
|
|
124
133
|
return False
|
125
134
|
|
126
135
|
async def close_ble_connection(self, fountain_id: int) -> None:
|
127
|
-
"""Close the BLE connection to the given fountain_id.
|
128
|
-
|
136
|
+
"""Close the BLE connection to the given fountain_id.
|
137
|
+
:param fountain_id: The ID of the fountain to close the BLE connection for.
|
138
|
+
:return: None
|
139
|
+
"""
|
140
|
+
_LOGGER.debug("Closing BLE connection to fountain %s", fountain_id)
|
129
141
|
water_fountain = await self._get_fountain_instance(fountain_id)
|
130
142
|
|
131
143
|
if water_fountain.is_connected is False:
|
132
|
-
_LOGGER.
|
144
|
+
_LOGGER.debug(
|
133
145
|
"BLE connection not established. Cannot close (id %s)", fountain_id
|
134
146
|
)
|
135
147
|
return
|
@@ -140,12 +152,16 @@ class BluetoothManager:
|
|
140
152
|
data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
|
141
153
|
headers=await self.client.get_session_id(),
|
142
154
|
)
|
143
|
-
_LOGGER.
|
155
|
+
_LOGGER.debug("BLE connection closed successfully (id %s)", fountain_id)
|
144
156
|
|
145
157
|
async def get_ble_cmd_data(
|
146
158
|
self, fountain_command: list, counter: int
|
147
159
|
) -> tuple[int, str]:
|
148
|
-
"""Get the BLE command data for the given fountain_command.
|
160
|
+
"""Get the BLE command data for the given fountain_command.
|
161
|
+
:param fountain_command: The fountain command to get the BLE data for.
|
162
|
+
:param counter: The BLE counter for the fountain.
|
163
|
+
:return: The BLE command code and the encoded BLE data.
|
164
|
+
"""
|
149
165
|
cmd_code = fountain_command[0]
|
150
166
|
modified_command = fountain_command[:2] + [counter] + fountain_command[2:]
|
151
167
|
ble_data = [*BLE_START_TRAME, *modified_command, *BLE_END_TRAME]
|
@@ -154,17 +170,24 @@ class BluetoothManager:
|
|
154
170
|
|
155
171
|
@staticmethod
|
156
172
|
async def _encode_ble_data(byte_list: list) -> str:
|
157
|
-
"""Encode the given byte_list to a base64 encoded string.
|
173
|
+
"""Encode the given byte_list to a base64 encoded string.
|
174
|
+
:param byte_list: The byte list to encode.
|
175
|
+
:return: The base64 encoded string.
|
176
|
+
"""
|
158
177
|
byte_array = bytearray(byte_list)
|
159
178
|
b64_encoded = base64.b64encode(byte_array)
|
160
179
|
return urllib.parse.quote(b64_encoded)
|
161
180
|
|
162
181
|
async def send_ble_command(self, fountain_id: int, command: FountainAction) -> bool:
|
163
|
-
"""Send the given BLE command to the fountain_id.
|
164
|
-
|
182
|
+
"""Send the given BLE command to the fountain_id.
|
183
|
+
:param fountain_id: The ID of the fountain to send the command to.
|
184
|
+
:param command: The command to send to the fountain.
|
185
|
+
:return: True if the command was sent successfully, False otherwise.
|
186
|
+
"""
|
187
|
+
_LOGGER.debug("Sending BLE command to fountain %s", fountain_id)
|
165
188
|
water_fountain = await self._get_fountain_instance(fountain_id)
|
166
189
|
if water_fountain.is_connected is False:
|
167
|
-
_LOGGER.
|
190
|
+
_LOGGER.debug("BLE connection not established (id %s)", fountain_id)
|
168
191
|
return False
|
169
192
|
command_data = FOUNTAIN_COMMAND.get(command)
|
170
193
|
if command_data is None:
|
@@ -190,5 +213,5 @@ class BluetoothManager:
|
|
190
213
|
if response != 1:
|
191
214
|
_LOGGER.error("Failed to send BLE command (id %s)", fountain_id)
|
192
215
|
return False
|
193
|
-
_LOGGER.
|
216
|
+
_LOGGER.debug("BLE command sent successfully (id %s)", fountain_id)
|
194
217
|
return True
|
@@ -6,6 +6,7 @@ from enum import StrEnum
|
|
6
6
|
import hashlib
|
7
7
|
from http import HTTPMethod
|
8
8
|
import logging
|
9
|
+
from typing import Any
|
9
10
|
|
10
11
|
import aiohttp
|
11
12
|
from aiohttp import ContentTypeError
|
@@ -146,7 +147,9 @@ class PetKitClient:
|
|
146
147
|
return False
|
147
148
|
|
148
149
|
async def login(self, valid_code: str | None = None) -> None:
|
149
|
-
"""Login to the PetKit service and retrieve the appropriate server.
|
150
|
+
"""Login to the PetKit service and retrieve the appropriate server.
|
151
|
+
:param valid_code: The valid code sent to the user's email.
|
152
|
+
"""
|
150
153
|
# Retrieve the list of servers
|
151
154
|
self._session = None
|
152
155
|
await self._get_base_url()
|
@@ -268,7 +271,9 @@ class PetKitClient:
|
|
268
271
|
_LOGGER.debug("Petkit data fetched successfully in: %s", end_time - start_time)
|
269
272
|
|
270
273
|
def _collect_devices(self) -> list[Device]:
|
271
|
-
"""Collect all devices from account data.
|
274
|
+
"""Collect all devices from account data.
|
275
|
+
:return: List of devices.
|
276
|
+
"""
|
272
277
|
device_list = []
|
273
278
|
for account in self.account_data:
|
274
279
|
_LOGGER.debug("List devices data for account: %s", account)
|
@@ -279,7 +284,10 @@ class PetKitClient:
|
|
279
284
|
return device_list
|
280
285
|
|
281
286
|
def _prepare_tasks(self, device_list: list[Device]) -> tuple[list, list, list]:
|
282
|
-
"""Prepare main and record tasks based on device types.
|
287
|
+
"""Prepare main and record tasks based on device types.
|
288
|
+
:param device_list: List of devices.
|
289
|
+
:return: Tuple of main tasks, record tasks and media tasks.
|
290
|
+
"""
|
283
291
|
main_tasks: list = []
|
284
292
|
record_tasks: list = []
|
285
293
|
media_tasks: list = []
|
@@ -312,8 +320,13 @@ class PetKitClient:
|
|
312
320
|
|
313
321
|
def _add_lb_task_by_type(
|
314
322
|
self, record_tasks: list, media_tasks: list, device_type: str, device: Device
|
315
|
-
):
|
316
|
-
"""Add specific tasks for litter box devices.
|
323
|
+
) -> None:
|
324
|
+
"""Add specific tasks for litter box devices.
|
325
|
+
:param record_tasks: List of record tasks.
|
326
|
+
:param media_tasks: List of media tasks.
|
327
|
+
:param device_type: Device type.
|
328
|
+
:param device: Device data.
|
329
|
+
"""
|
317
330
|
if device_type in LITTER_NO_CAMERA:
|
318
331
|
record_tasks.append(self._fetch_device_data(device, LitterStats))
|
319
332
|
if device_type in LITTER_WITH_CAMERA:
|
@@ -322,8 +335,12 @@ class PetKitClient:
|
|
322
335
|
|
323
336
|
def _add_feeder_task_by_type(
|
324
337
|
self, media_tasks: list, device_type: str, device: Device
|
325
|
-
):
|
326
|
-
"""Add specific tasks for feeder box devices.
|
338
|
+
) -> None:
|
339
|
+
"""Add specific tasks for feeder box devices.
|
340
|
+
:param media_tasks: List of media tasks.
|
341
|
+
:param device_type: Device type.
|
342
|
+
:param device: Device data.
|
343
|
+
"""
|
327
344
|
if device_type in FEEDER_WITH_CAMERA:
|
328
345
|
media_tasks.append(self._fetch_media(device))
|
329
346
|
|
@@ -337,7 +354,9 @@ class PetKitClient:
|
|
337
354
|
await asyncio.gather(*stats_tasks)
|
338
355
|
|
339
356
|
async def _fetch_media(self, device: Device) -> None:
|
340
|
-
"""Fetch media data from the PetKit servers.
|
357
|
+
"""Fetch media data from the PetKit servers.
|
358
|
+
:param device: Device data.
|
359
|
+
"""
|
341
360
|
_LOGGER.debug("Fetching media data for device: %s", device.device_id)
|
342
361
|
|
343
362
|
device_entity = self.petkit_entities[device.device_id]
|
@@ -360,7 +379,10 @@ class PetKitClient:
|
|
360
379
|
| LitterStats
|
361
380
|
],
|
362
381
|
) -> None:
|
363
|
-
"""Fetch the device data from the PetKit servers.
|
382
|
+
"""Fetch the device data from the PetKit servers.
|
383
|
+
:param device: Device data.
|
384
|
+
:param data_class: Data class
|
385
|
+
"""
|
364
386
|
device_type = device.device_type
|
365
387
|
|
366
388
|
_LOGGER.debug("Reading device type : %s (id=%s)", device_type, device.device_id)
|
@@ -398,7 +420,7 @@ class PetKitClient:
|
|
398
420
|
_LOGGER.error("Unexpected response type: %s", type(response))
|
399
421
|
return
|
400
422
|
|
401
|
-
# Dispatch to the appropriate handler
|
423
|
+
# Dispatch to the appropriate data handler
|
402
424
|
handler = data_handlers.get(data_class.data_type)
|
403
425
|
if handler:
|
404
426
|
await handler(self, device, device_data, device_type)
|
@@ -406,14 +428,21 @@ class PetKitClient:
|
|
406
428
|
_LOGGER.error("Unknown data type: %s", data_class.data_type)
|
407
429
|
|
408
430
|
@data_handler(DEVICE_DATA)
|
409
|
-
async def _handle_device_data(
|
431
|
+
async def _handle_device_data(
|
432
|
+
self,
|
433
|
+
device: Device,
|
434
|
+
device_data: Feeder | Litter | WaterFountain | Purifier,
|
435
|
+
device_type: str,
|
436
|
+
):
|
410
437
|
"""Handle device data."""
|
411
438
|
self.petkit_entities[device.device_id] = device_data
|
412
439
|
self.petkit_entities[device.device_id].device_nfo = device
|
413
440
|
_LOGGER.debug("Device data fetched OK for %s", device_type)
|
414
441
|
|
415
442
|
@data_handler(DEVICE_RECORDS)
|
416
|
-
async def _handle_device_records(
|
443
|
+
async def _handle_device_records(
|
444
|
+
self, device: Device, device_data, device_type: str
|
445
|
+
):
|
417
446
|
"""Handle device records."""
|
418
447
|
entity = self.petkit_entities.get(device.device_id)
|
419
448
|
if entity and isinstance(entity, (Feeder, Litter, WaterFountain)):
|
@@ -426,7 +455,7 @@ class PetKitClient:
|
|
426
455
|
)
|
427
456
|
|
428
457
|
@data_handler(DEVICE_STATS)
|
429
|
-
async def _handle_device_stats(self, device, device_data, device_type):
|
458
|
+
async def _handle_device_stats(self, device: Device, device_data, device_type: str):
|
430
459
|
"""Handle device stats."""
|
431
460
|
entity = self.petkit_entities.get(device.device_id)
|
432
461
|
if isinstance(entity, Litter):
|
@@ -442,7 +471,9 @@ class PetKitClient:
|
|
442
471
|
)
|
443
472
|
|
444
473
|
async def get_pets_list(self) -> list[Pet]:
|
445
|
-
"""Extract and return the list of pets.
|
474
|
+
"""Extract and return the list of pets.
|
475
|
+
:return: List of pets.
|
476
|
+
"""
|
446
477
|
return [
|
447
478
|
entity
|
448
479
|
for entity in self.petkit_entities.values()
|
@@ -451,18 +482,28 @@ class PetKitClient:
|
|
451
482
|
|
452
483
|
@staticmethod
|
453
484
|
def get_safe_value(value: int | None, default: int = 0) -> int:
|
454
|
-
"""Return the value if not None, otherwise return the default.
|
485
|
+
"""Return the value if not None, otherwise return the default.
|
486
|
+
:param value: Value to check.
|
487
|
+
:param default: Default value.
|
488
|
+
:return: Value or default.
|
489
|
+
"""
|
455
490
|
return value if value is not None else default
|
456
491
|
|
457
492
|
@staticmethod
|
458
493
|
def calculate_duration(start: int | None, end: int | None) -> int:
|
459
|
-
"""Calculate the duration, ensuring both start and end are not None.
|
494
|
+
"""Calculate the duration, ensuring both start and end are not None.
|
495
|
+
:param start: Start time.
|
496
|
+
:param end: End time.
|
497
|
+
:return: Duration.
|
498
|
+
"""
|
460
499
|
if start is None or end is None:
|
461
500
|
return 0
|
462
501
|
return end - start
|
463
502
|
|
464
503
|
async def populate_pet_stats(self, stats_data: Litter) -> None:
|
465
|
-
"""Collect data from litter data to populate pet stats.
|
504
|
+
"""Collect data from litter data to populate pet stats.
|
505
|
+
:param stats_data: Litter data.
|
506
|
+
"""
|
466
507
|
|
467
508
|
if not stats_data.device_nfo:
|
468
509
|
_LOGGER.warning(
|
@@ -476,16 +517,21 @@ class PetKitClient:
|
|
476
517
|
stats_data.device_nfo.device_type in [T3, T4]
|
477
518
|
and stats_data.device_records
|
478
519
|
):
|
479
|
-
await self.
|
520
|
+
await self._process_litter_no_camera(pet, stats_data)
|
480
521
|
elif (
|
481
522
|
stats_data.device_nfo.device_type in [T5, T6]
|
482
523
|
and stats_data.device_pet_graph_out
|
483
524
|
):
|
484
|
-
await self.
|
525
|
+
await self._process_litter_camera(pet, stats_data)
|
485
526
|
|
486
|
-
async def
|
487
|
-
"""Process T3/T4
|
488
|
-
|
527
|
+
async def _process_litter_no_camera(self, pet: Pet, device_records: Litter) -> None:
|
528
|
+
"""Process litter T3/T4 records (litter without camera).
|
529
|
+
:param pet: Pet data.
|
530
|
+
:param device_records: Litter data.
|
531
|
+
"""
|
532
|
+
for stat in (
|
533
|
+
s for s in device_records.device_records or [] if isinstance(s, LitterStats)
|
534
|
+
):
|
489
535
|
if stat.pet_id == pet.pet_id and (
|
490
536
|
pet.last_litter_usage is None
|
491
537
|
or self.get_safe_value(stat.timestamp) > pet.last_litter_usage
|
@@ -498,24 +544,34 @@ class PetKitClient:
|
|
498
544
|
stat.content.time_in if stat.content else None,
|
499
545
|
stat.content.time_out if stat.content else None,
|
500
546
|
)
|
501
|
-
pet.last_device_used =
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
547
|
+
pet.last_device_used = getattr(
|
548
|
+
device_records.device_nfo, "device_name", "Unknown"
|
549
|
+
).capitalize()
|
550
|
+
|
551
|
+
async def _process_litter_camera(self, pet: Pet, pet_graphs: Litter) -> None:
|
552
|
+
"""Process litter T5/T6 records (litter WITH camera).
|
553
|
+
:param pet: Pet data.
|
554
|
+
:param pet_graphs: Litter data.
|
555
|
+
"""
|
556
|
+
for graph in pet_graphs.device_pet_graph_out or []:
|
506
557
|
if graph.pet_id == pet.pet_id and (
|
507
558
|
pet.last_litter_usage is None
|
508
559
|
or self.get_safe_value(graph.time) > pet.last_litter_usage
|
509
560
|
):
|
510
|
-
pet.last_litter_usage = graph.time
|
561
|
+
pet.last_litter_usage = graph.time or 0
|
511
562
|
pet.last_measured_weight = self.get_safe_value(
|
512
563
|
graph.content.pet_weight if graph.content else None
|
513
564
|
)
|
514
|
-
pet.last_duration_usage = self.get_safe_value(graph.toilet_time)
|
515
|
-
pet.last_device_used =
|
565
|
+
pet.last_duration_usage = self.get_safe_value(graph.toilet_time) or 0
|
566
|
+
pet.last_device_used = getattr(
|
567
|
+
pet_graphs.device_nfo, "device_name", "Unknown"
|
568
|
+
).capitalize()
|
516
569
|
|
517
570
|
async def get_cloud_video(self, video_url: str) -> dict[str, str | int] | None:
|
518
|
-
"""Get the video m3u8 link from the cloud.
|
571
|
+
"""Get the video m3u8 link from the cloud.
|
572
|
+
:param video_url: URL of the video.
|
573
|
+
:return: Video data.
|
574
|
+
"""
|
519
575
|
response = await self.req.request(
|
520
576
|
method=HTTPMethod.POST,
|
521
577
|
url=video_url,
|
@@ -528,7 +584,9 @@ class PetKitClient:
|
|
528
584
|
return None
|
529
585
|
return response[0]
|
530
586
|
|
531
|
-
async def extract_segments_m3u8(
|
587
|
+
async def extract_segments_m3u8(
|
588
|
+
self, m3u8_url: str
|
589
|
+
) -> tuple[Any, str | None, list[str | None]]:
|
532
590
|
"""Extract segments from the m3u8 file.
|
533
591
|
:param: m3u8_url: URL of the m3u8 file
|
534
592
|
:return: aes_key, key_iv, segment_lst
|
@@ -542,7 +600,7 @@ class PetKitClient:
|
|
542
600
|
m3u8_obj = m3u8.loads(response[RES_KEY])
|
543
601
|
|
544
602
|
if not m3u8_obj.segments or not m3u8_obj.keys:
|
545
|
-
|
603
|
+
return None, None, []
|
546
604
|
|
547
605
|
# Extract segments from m3u8 file
|
548
606
|
segment_lst = [segment.uri for segment in m3u8_obj.segments]
|
@@ -550,6 +608,9 @@ class PetKitClient:
|
|
550
608
|
key_uri = m3u8_obj.keys[0].uri
|
551
609
|
key_iv = str(m3u8_obj.keys[0].iv)
|
552
610
|
|
611
|
+
if not key_uri or not key_iv:
|
612
|
+
return None, None, []
|
613
|
+
|
553
614
|
# Extract aes_key from video segments
|
554
615
|
response = await self.req.request(
|
555
616
|
method=HTTPMethod.GET,
|
@@ -565,7 +626,12 @@ class PetKitClient:
|
|
565
626
|
action: StrEnum,
|
566
627
|
setting: dict | None = None,
|
567
628
|
) -> bool:
|
568
|
-
"""Control the device using the PetKit API.
|
629
|
+
"""Control the device using the PetKit API.
|
630
|
+
:param device_id: ID of the device.
|
631
|
+
:param action: Action to perform.
|
632
|
+
:param setting: Setting to apply.
|
633
|
+
:return: True if the command was successful, False otherwise.
|
634
|
+
"""
|
569
635
|
device = self.petkit_entities.get(device_id, None)
|
570
636
|
if not device:
|
571
637
|
raise PypetkitError(f"Device with ID {device_id} not found.")
|
@@ -668,7 +734,15 @@ class PrepReq:
|
|
668
734
|
data=None,
|
669
735
|
headers=None,
|
670
736
|
) -> dict:
|
671
|
-
"""Make a request to the PetKit API.
|
737
|
+
"""Make a request to the PetKit API.
|
738
|
+
:param method: HTTP method.
|
739
|
+
:param url: URL of the API endpoint.
|
740
|
+
:param full_url: Use full URL.
|
741
|
+
:param params: Parameters to send.
|
742
|
+
:param data: Data to send.
|
743
|
+
:param headers: Headers to send.
|
744
|
+
:return: Response from the API.
|
745
|
+
"""
|
672
746
|
_url = url if full_url else "/".join(s.strip("/") for s in [self.base_url, url])
|
673
747
|
_headers = {**self.base_headers, **(headers or {})}
|
674
748
|
_LOGGER.debug("Request: %s %s", method, _url)
|
@@ -686,7 +760,11 @@ class PrepReq:
|
|
686
760
|
|
687
761
|
@staticmethod
|
688
762
|
async def _handle_response(response: aiohttp.ClientResponse, url: str) -> dict:
|
689
|
-
"""Handle the response from the PetKit API.
|
763
|
+
"""Handle the response from the PetKit API.
|
764
|
+
:param response: Response from the API.
|
765
|
+
:param url: URL of the API endpoint.
|
766
|
+
:return: Data from the API.
|
767
|
+
"""
|
690
768
|
try:
|
691
769
|
response.raise_for_status()
|
692
770
|
except aiohttp.ClientResponseError as e:
|
@@ -147,6 +147,7 @@ class StateLitter(BaseModel):
|
|
147
147
|
camera_status: int | None = Field(None, alias="cameraStatus")
|
148
148
|
dump_state: int | None = Field(None, alias="dumpState")
|
149
149
|
liquid: int | None = None
|
150
|
+
light_state: dict | None = Field(None, alias="lightState")
|
150
151
|
pack_state: int | None = Field(None, alias="packState")
|
151
152
|
package_install: int | None = Field(None, alias="packageInstall")
|
152
153
|
package_secret: str | None = Field(None, alias="packageSecret")
|
@@ -6,6 +6,7 @@ import asyncio
|
|
6
6
|
from dataclasses import dataclass
|
7
7
|
from datetime import datetime
|
8
8
|
import logging
|
9
|
+
import os
|
9
10
|
from pathlib import Path
|
10
11
|
import re
|
11
12
|
from typing import Any
|
@@ -105,10 +106,7 @@ class MediaManager:
|
|
105
106
|
async def gather_all_media_from_disk(
|
106
107
|
self, storage_path: Path, device_id: int
|
107
108
|
) -> list[MediaFile]:
|
108
|
-
"""Construct the media file table for disk storage.
|
109
|
-
:param storage_path: Path to the storage directory
|
110
|
-
:param device_id: Device ID
|
111
|
-
"""
|
109
|
+
"""Construct the media file table for disk storage."""
|
112
110
|
self.media_table.clear()
|
113
111
|
|
114
112
|
today_str = datetime.now().strftime("%Y%m%d")
|
@@ -116,121 +114,171 @@ class MediaManager:
|
|
116
114
|
|
117
115
|
_LOGGER.debug("Populating files from directory %s", base_path)
|
118
116
|
|
117
|
+
valid_pattern = re.compile(
|
118
|
+
rf"^{device_id}_\d+\.({MediaType.IMAGE}|{MediaType.VIDEO})$"
|
119
|
+
)
|
120
|
+
|
119
121
|
for record_type in RecordType:
|
120
122
|
record_path = base_path / record_type
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
rf"^{device_id}_\d+\.({MediaType.IMAGE}|{MediaType.VIDEO})$"
|
127
|
-
)
|
123
|
+
subdirs = [record_path / "snapshot", record_path / "video"]
|
124
|
+
for subdir in subdirs:
|
125
|
+
await self._process_subdir(
|
126
|
+
subdir, device_id, record_type, valid_pattern
|
127
|
+
)
|
128
128
|
|
129
|
-
# Populate the media table with event_id from filenames
|
130
|
-
for subdir in [snapshot_path, video_path]:
|
131
|
-
|
132
|
-
# Ensure the directories exist
|
133
|
-
if not await aiofiles.os.path.exists(subdir):
|
134
|
-
_LOGGER.debug("Path does not exist, skip : %s", subdir)
|
135
|
-
continue
|
136
|
-
|
137
|
-
_LOGGER.debug("Scanning files into : %s", subdir)
|
138
|
-
entries = await aiofiles.os.scandir(subdir)
|
139
|
-
for entry in entries:
|
140
|
-
if entry.is_file() and valid_pattern.match(entry.name):
|
141
|
-
_LOGGER.debug("Media found: %s", entry.name)
|
142
|
-
event_id = Path(entry.name).stem
|
143
|
-
timestamp = Path(entry.name).stem.split("_")[1]
|
144
|
-
media_type_str = Path(entry.name).suffix.lstrip(".")
|
145
|
-
try:
|
146
|
-
media_type = MediaType(media_type_str)
|
147
|
-
except ValueError:
|
148
|
-
_LOGGER.warning("Unknown media type: %s", media_type_str)
|
149
|
-
continue
|
150
|
-
self.media_table.append(
|
151
|
-
MediaFile(
|
152
|
-
event_id=event_id,
|
153
|
-
device_id=device_id,
|
154
|
-
timestamp=int(timestamp),
|
155
|
-
event_type=RecordType(record_type),
|
156
|
-
full_file_path=subdir / entry.name,
|
157
|
-
media_type=MediaType(media_type),
|
158
|
-
)
|
159
|
-
)
|
160
129
|
_LOGGER.debug("OK, Media table populated with %s files", len(self.media_table))
|
161
130
|
return self.media_table
|
162
131
|
|
132
|
+
async def _process_subdir(
|
133
|
+
self,
|
134
|
+
subdir: Path,
|
135
|
+
device_id: int,
|
136
|
+
record_type: RecordType,
|
137
|
+
valid_pattern: re.Pattern,
|
138
|
+
) -> None:
|
139
|
+
"""Process a subdirectory to collect media files."""
|
140
|
+
if not await aiofiles.os.path.exists(subdir):
|
141
|
+
_LOGGER.debug("Path does not exist, skip: %s", subdir)
|
142
|
+
return
|
143
|
+
|
144
|
+
_LOGGER.debug("Scanning files into: %s", subdir)
|
145
|
+
entries = await aiofiles.os.scandir(subdir)
|
146
|
+
for entry in entries:
|
147
|
+
media_file = await self._create_media_file(
|
148
|
+
entry, device_id, record_type, subdir, valid_pattern
|
149
|
+
)
|
150
|
+
if media_file:
|
151
|
+
self.media_table.append(media_file)
|
152
|
+
|
153
|
+
@staticmethod
|
154
|
+
async def _create_media_file(
|
155
|
+
entry: os.DirEntry,
|
156
|
+
device_id: int,
|
157
|
+
record_type: RecordType,
|
158
|
+
subdir: Path,
|
159
|
+
valid_pattern: re.Pattern,
|
160
|
+
) -> MediaFile | None:
|
161
|
+
"""Create a MediaFile from a directory entry if valid."""
|
162
|
+
if not entry.is_file() or not valid_pattern.match(entry.name):
|
163
|
+
return None
|
164
|
+
|
165
|
+
try:
|
166
|
+
stem = Path(entry.name).stem
|
167
|
+
parts = stem.split("_")
|
168
|
+
timestamp = int(parts[1])
|
169
|
+
media_type_str = Path(entry.name).suffix.lstrip(".")
|
170
|
+
media_type = MediaType(media_type_str)
|
171
|
+
except (ValueError, IndexError) as e:
|
172
|
+
_LOGGER.warning("Invalid file %s: %s", entry.name, str(e))
|
173
|
+
return None
|
174
|
+
|
175
|
+
return MediaFile(
|
176
|
+
event_id=stem,
|
177
|
+
device_id=device_id,
|
178
|
+
timestamp=timestamp,
|
179
|
+
media_type=media_type,
|
180
|
+
event_type=record_type,
|
181
|
+
full_file_path=subdir / entry.name,
|
182
|
+
)
|
183
|
+
|
163
184
|
async def list_missing_files(
|
164
185
|
self,
|
165
186
|
media_cloud_list: list[MediaCloud],
|
166
187
|
dl_type: list[MediaType] | None = None,
|
167
188
|
event_type: list[RecordType] | None = None,
|
168
189
|
) -> list[MediaCloud]:
|
169
|
-
"""Compare MediaCloud objects with MediaFile objects and return
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
"""
|
175
|
-
missing_media: list[MediaCloud] = []
|
190
|
+
"""Compare MediaCloud objects with MediaFile objects and return missing ones."""
|
191
|
+
if not dl_type or not event_type:
|
192
|
+
_LOGGER.debug("Missing dl_type or event_type, no downloads")
|
193
|
+
return []
|
194
|
+
|
176
195
|
existing_event_ids = {media_file.event_id for media_file in self.media_table}
|
196
|
+
return [
|
197
|
+
mc
|
198
|
+
for mc in media_cloud_list
|
199
|
+
if self._should_process_media(mc, event_type, dl_type, existing_event_ids)
|
200
|
+
]
|
177
201
|
|
178
|
-
|
179
|
-
|
180
|
-
|
202
|
+
def _should_process_media(
|
203
|
+
self,
|
204
|
+
media_cloud: MediaCloud,
|
205
|
+
event_filter: list[RecordType],
|
206
|
+
dl_types: list[MediaType],
|
207
|
+
existing_ids: set[str],
|
208
|
+
) -> bool:
|
209
|
+
"""Determine if a media should be processed as missing."""
|
210
|
+
if self._should_skip_event_type(media_cloud.event_type, event_filter):
|
211
|
+
return False
|
212
|
+
|
213
|
+
return self._is_media_missing(media_cloud, dl_types, existing_ids)
|
214
|
+
|
215
|
+
@staticmethod
|
216
|
+
def _should_skip_event_type(
|
217
|
+
event_type: RecordType, event_filter: list[RecordType]
|
218
|
+
) -> bool:
|
219
|
+
"""Check if event type should be skipped."""
|
220
|
+
if event_type not in event_filter:
|
221
|
+
_LOGGER.debug("Skipping filtered event type: %s", event_type)
|
222
|
+
return True
|
223
|
+
return False
|
224
|
+
|
225
|
+
def _is_media_missing(
|
226
|
+
self, media_cloud: MediaCloud, dl_types: list[MediaType], existing_ids: set[str]
|
227
|
+
) -> bool:
|
228
|
+
"""Check if any media type is missing."""
|
229
|
+
missing_image = self._is_image_missing(media_cloud, dl_types, existing_ids)
|
230
|
+
missing_video = self._is_video_missing(media_cloud, dl_types, existing_ids)
|
231
|
+
|
232
|
+
if missing_image or missing_video:
|
233
|
+
self._log_missing_details(media_cloud, missing_image, missing_video)
|
234
|
+
return True
|
235
|
+
return False
|
236
|
+
|
237
|
+
def _is_image_missing(
|
238
|
+
self, media_cloud: MediaCloud, dl_types: list[MediaType], existing_ids: set[str]
|
239
|
+
) -> bool:
|
240
|
+
"""Check if image is missing."""
|
241
|
+
return bool(
|
242
|
+
media_cloud.image
|
243
|
+
and MediaType.IMAGE in dl_types
|
244
|
+
and not self._media_exists(
|
245
|
+
media_cloud.event_id, MediaType.IMAGE, existing_ids
|
181
246
|
)
|
182
|
-
|
247
|
+
)
|
183
248
|
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
249
|
+
def _is_video_missing(
|
250
|
+
self, media_cloud: MediaCloud, dl_types: list[MediaType], existing_ids: set[str]
|
251
|
+
) -> bool:
|
252
|
+
"""Check if video is missing."""
|
253
|
+
return bool(
|
254
|
+
media_cloud.video
|
255
|
+
and MediaType.VIDEO in dl_types
|
256
|
+
and not self._media_exists(
|
257
|
+
media_cloud.event_id, MediaType.VIDEO, existing_ids
|
258
|
+
)
|
259
|
+
)
|
191
260
|
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
# Check for missing image
|
201
|
-
if (
|
202
|
-
media_cloud.image
|
203
|
-
and MediaType.IMAGE
|
204
|
-
in (dl_type or [MediaType.IMAGE, MediaType.VIDEO])
|
205
|
-
and not any(
|
206
|
-
media_file.event_id == media_cloud.event_id
|
207
|
-
and media_file.media_type == MediaType.IMAGE
|
208
|
-
for media_file in self.media_table
|
209
|
-
)
|
210
|
-
):
|
211
|
-
_LOGGER.debug(
|
212
|
-
"Media file IMG id : %s is missing", media_cloud.event_id
|
213
|
-
)
|
214
|
-
is_missing = True
|
215
|
-
# Check for missing video
|
216
|
-
if (
|
217
|
-
media_cloud.video
|
218
|
-
and MediaType.VIDEO
|
219
|
-
in (dl_type or [MediaType.IMAGE, MediaType.VIDEO])
|
220
|
-
and not any(
|
221
|
-
media_file.event_id == media_cloud.event_id
|
222
|
-
and media_file.media_type == MediaType.VIDEO
|
223
|
-
for media_file in self.media_table
|
224
|
-
)
|
225
|
-
):
|
226
|
-
_LOGGER.debug(
|
227
|
-
"Media file VIDEO id : %s is missing", media_cloud.event_id
|
228
|
-
)
|
229
|
-
is_missing = True
|
261
|
+
def _media_exists(
|
262
|
+
self, event_id: str, media_type: MediaType, existing_ids: set[str]
|
263
|
+
) -> bool:
|
264
|
+
"""Check if media exists in local storage."""
|
265
|
+
for mf in self.media_table:
|
266
|
+
if event_id in existing_ids and mf.media_type == media_type:
|
267
|
+
return True
|
268
|
+
return False
|
230
269
|
|
231
|
-
|
232
|
-
|
233
|
-
|
270
|
+
@staticmethod
|
271
|
+
def _log_missing_details(
|
272
|
+
media_cloud: MediaCloud, missing_image: bool, missing_video: bool
|
273
|
+
) -> None:
|
274
|
+
"""Log details about missing media."""
|
275
|
+
log_msg = "Media missing for event %s: "
|
276
|
+
details = []
|
277
|
+
if missing_image:
|
278
|
+
details.append("IMAGE")
|
279
|
+
if missing_video:
|
280
|
+
details.append("VIDEO")
|
281
|
+
_LOGGER.debug("%s %s %s", log_msg, media_cloud.event_id, " + ".join(details))
|
234
282
|
|
235
283
|
async def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
|
236
284
|
"""Process media files for a Feeder device.
|
@@ -272,11 +320,8 @@ class MediaManager:
|
|
272
320
|
)
|
273
321
|
cp_sub = self.is_subscription_active(device_obj)
|
274
322
|
|
275
|
-
if not feeder_id:
|
276
|
-
_LOGGER.warning("Missing feeder_id for record")
|
277
|
-
return media_files
|
278
|
-
|
279
|
-
if not record.items:
|
323
|
+
if not feeder_id or not record.items:
|
324
|
+
_LOGGER.warning("Missing feeder_id or items for record")
|
280
325
|
return media_files
|
281
326
|
|
282
327
|
for item in record.items:
|
@@ -287,18 +332,15 @@ class MediaManager:
|
|
287
332
|
if timestamp is None:
|
288
333
|
_LOGGER.warning("Missing timestamp for record item")
|
289
334
|
continue
|
290
|
-
if not item.event_id:
|
335
|
+
if not item.event_id or not item.aes_key:
|
291
336
|
# Skip feed event in the future
|
292
337
|
_LOGGER.debug(
|
293
|
-
"Missing event_id for record item (probably a feed event not yet completed)"
|
338
|
+
"Missing event_id or aes_key for record item (probably a feed event not yet completed, or uploaded)"
|
294
339
|
)
|
295
340
|
continue
|
296
341
|
if not user_id:
|
297
342
|
_LOGGER.warning("Missing user_id for record item")
|
298
343
|
continue
|
299
|
-
if not item.aes_key:
|
300
|
-
_LOGGER.debug("Missing aes_key for record item")
|
301
|
-
continue
|
302
344
|
|
303
345
|
date_str = await self.get_date_from_ts(timestamp)
|
304
346
|
filepath = f"{feeder_id}/{date_str}/{record_type.name.lower()}"
|
@@ -331,30 +373,22 @@ class MediaManager:
|
|
331
373
|
user_id = litter.user.id if litter.user else None
|
332
374
|
cp_sub = self.is_subscription_active(litter)
|
333
375
|
|
334
|
-
if not litter_id:
|
335
|
-
_LOGGER.warning(
|
336
|
-
|
337
|
-
|
338
|
-
if not device_type:
|
339
|
-
_LOGGER.warning("Missing device_type for record")
|
340
|
-
return media_files
|
341
|
-
|
342
|
-
if not user_id:
|
343
|
-
_LOGGER.warning("Missing user_id for record")
|
376
|
+
if not litter_id or not device_type or not user_id:
|
377
|
+
_LOGGER.warning(
|
378
|
+
"Missing one or more of mandatory information : litter_id/device_id/user_id for record"
|
379
|
+
)
|
344
380
|
return media_files
|
345
381
|
|
346
382
|
if not records:
|
383
|
+
_LOGGER.debug("No records found for %s", litter.name)
|
347
384
|
return media_files
|
348
385
|
|
349
386
|
for record in records:
|
350
387
|
if not isinstance(record, LitterRecord):
|
351
388
|
_LOGGER.debug("Record is empty")
|
352
389
|
continue
|
353
|
-
if not record.event_id:
|
354
|
-
_LOGGER.debug("Missing event_id for record item")
|
355
|
-
continue
|
356
|
-
if not record.aes_key:
|
357
|
-
_LOGGER.debug("Missing aes_key for record item")
|
390
|
+
if not record.event_id or not record.aes_key:
|
391
|
+
_LOGGER.debug("Missing event_id or aes_key for record item")
|
358
392
|
continue
|
359
393
|
if record.timestamp is None:
|
360
394
|
_LOGGER.debug("Missing timestamp for record item")
|
@@ -537,10 +571,10 @@ class DownloadDecryptMedia:
|
|
537
571
|
elif len(segment_files) == 1:
|
538
572
|
_LOGGER.debug("Single file segment, no need to concatenate")
|
539
573
|
elif len(segment_files) > 1:
|
540
|
-
_LOGGER.debug("Concatenating
|
574
|
+
_LOGGER.debug("Concatenating video with %s segments", len(segment_files))
|
541
575
|
await self._concat_segments(segment_files, file_name)
|
542
576
|
|
543
|
-
async def _get_m3u8_segments(self) -> tuple[str | None, str | None
|
577
|
+
async def _get_m3u8_segments(self) -> tuple[Any, str | None, list[str | None]]:
|
544
578
|
"""Extract the segments from a m3u8 file.
|
545
579
|
:return: Tuple of AES key, IV key, and list of segment URLs
|
546
580
|
"""
|
@@ -557,13 +591,19 @@ class DownloadDecryptMedia:
|
|
557
591
|
raise ValueError("Missing mediaApi in video data")
|
558
592
|
return await self.client.extract_segments_m3u8(str(media_api))
|
559
593
|
|
560
|
-
async def _get_file(
|
594
|
+
async def _get_file(
|
595
|
+
self, url: str | None, aes_key: str | None, full_filename: str | None
|
596
|
+
) -> bool:
|
561
597
|
"""Download a file from a URL and decrypt it.
|
562
598
|
:param url: URL of the file to download.
|
563
599
|
:param aes_key: AES key used for decryption.
|
564
600
|
:param full_filename: Name of the file to save.
|
565
601
|
:return: True if the file was downloaded successfully, False otherwise.
|
566
602
|
"""
|
603
|
+
if not url or not aes_key or not full_filename:
|
604
|
+
_LOGGER.debug("Missing URL, AES key, or filename")
|
605
|
+
return False
|
606
|
+
|
567
607
|
# Download the file
|
568
608
|
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
569
609
|
if response.status != 200:
|
@@ -623,7 +663,7 @@ class DownloadDecryptMedia:
|
|
623
663
|
try:
|
624
664
|
decrypted_data = unpad(decrypted_data, AES.block_size)
|
625
665
|
except ValueError as e:
|
626
|
-
_LOGGER.debug("
|
666
|
+
_LOGGER.debug("Ignoring unpad warning : %s", e)
|
627
667
|
return decrypted_data
|
628
668
|
|
629
669
|
async def _concat_segments(self, ts_files: list[Path], output_file) -> None:
|
@@ -27,10 +27,10 @@ class Type(BaseModel):
|
|
27
27
|
is_custom: int | None = Field(0, alias="isCustom")
|
28
28
|
name: str | None = None
|
29
29
|
priority: int | None = None
|
30
|
-
repeat_option: str | None = Field(alias="repeatOption")
|
30
|
+
repeat_option: str | None = Field(None, alias="repeatOption")
|
31
31
|
rpt: str | None = None
|
32
|
-
schedule_appoint: str | None = Field(alias="scheduleAppoint")
|
33
|
-
with_device_type: str | None = Field(alias="withDeviceType")
|
32
|
+
schedule_appoint: str | None = Field(None, alias="scheduleAppoint")
|
33
|
+
with_device_type: str | None = Field(None, alias="withDeviceType")
|
34
34
|
with_pet: int | None = Field(0, alias="withPet")
|
35
35
|
|
36
36
|
|
@@ -187,7 +187,7 @@ build-backend = "poetry.core.masonry.api"
|
|
187
187
|
|
188
188
|
[tool.poetry]
|
189
189
|
name = "pypetkitapi"
|
190
|
-
version = "1.
|
190
|
+
version = "1.12.0"
|
191
191
|
description = "Python client for PetKit API"
|
192
192
|
authors = ["Jezza34000 <info@mail.com>"]
|
193
193
|
readme = "README.md"
|
@@ -209,7 +209,7 @@ ruff = "^0.8.1"
|
|
209
209
|
types-aiofiles = "^24.1.0.20240626"
|
210
210
|
|
211
211
|
[tool.bumpver]
|
212
|
-
current_version = "1.
|
212
|
+
current_version = "1.12.0"
|
213
213
|
version_pattern = "MAJOR.MINOR.PATCH"
|
214
214
|
commit_message = "bump version {old_version} -> {new_version}"
|
215
215
|
tag_message = "{new_version}"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|