pypetkitapi 1.11.2__tar.gz → 1.11.5__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.2 → pypetkitapi-1.11.5}/PKG-INFO +18 -9
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/README.md +17 -8
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/__init__.py +1 -1
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/bluetooth.py +42 -19
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/client.py +114 -36
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/media.py +191 -137
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/schedule_container.py +3 -3
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pyproject.toml +2 -2
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/LICENSE +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/command.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/const.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/containers.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/exceptions.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/feeder_container.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/litter_container.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/purifier_container.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/py.typed +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/utils.py +0 -0
- {pypetkitapi-1.11.2 → pypetkitapi-1.11.5}/pypetkitapi/water_fountain_container.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: pypetkitapi
|
3
|
-
Version: 1.11.
|
3
|
+
Version: 1.11.5
|
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:
|
@@ -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.
|
@@ -270,15 +318,10 @@ class MediaManager:
|
|
270
318
|
device_type = (
|
271
319
|
device_obj.device_nfo.device_type if device_obj.device_nfo else None
|
272
320
|
)
|
273
|
-
cp_sub = (
|
274
|
-
device_obj.cloud_product.subscribe if device_obj.cloud_product else None
|
275
|
-
)
|
321
|
+
cp_sub = self.is_subscription_active(device_obj)
|
276
322
|
|
277
|
-
if not feeder_id:
|
278
|
-
_LOGGER.warning("Missing feeder_id for record")
|
279
|
-
return media_files
|
280
|
-
|
281
|
-
if not record.items:
|
323
|
+
if not feeder_id or not record.items:
|
324
|
+
_LOGGER.warning("Missing feeder_id or items for record")
|
282
325
|
return media_files
|
283
326
|
|
284
327
|
for item in record.items:
|
@@ -289,18 +332,15 @@ class MediaManager:
|
|
289
332
|
if timestamp is None:
|
290
333
|
_LOGGER.warning("Missing timestamp for record item")
|
291
334
|
continue
|
292
|
-
if not item.event_id:
|
335
|
+
if not item.event_id or not item.aes_key:
|
293
336
|
# Skip feed event in the future
|
294
337
|
_LOGGER.debug(
|
295
|
-
"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)"
|
296
339
|
)
|
297
340
|
continue
|
298
341
|
if not user_id:
|
299
342
|
_LOGGER.warning("Missing user_id for record item")
|
300
343
|
continue
|
301
|
-
if not item.aes_key:
|
302
|
-
_LOGGER.warning("Missing aes_key for record item")
|
303
|
-
continue
|
304
344
|
|
305
345
|
date_str = await self.get_date_from_ts(timestamp)
|
306
346
|
filepath = f"{feeder_id}/{date_str}/{record_type.name.lower()}"
|
@@ -331,32 +371,24 @@ class MediaManager:
|
|
331
371
|
litter_id = litter.device_nfo.device_id if litter.device_nfo else None
|
332
372
|
device_type = litter.device_nfo.device_type if litter.device_nfo else None
|
333
373
|
user_id = litter.user.id if litter.user else None
|
334
|
-
cp_sub =
|
335
|
-
|
336
|
-
if not litter_id:
|
337
|
-
_LOGGER.warning("Missing litter_id for record")
|
338
|
-
return media_files
|
339
|
-
|
340
|
-
if not device_type:
|
341
|
-
_LOGGER.warning("Missing device_type for record")
|
342
|
-
return media_files
|
374
|
+
cp_sub = self.is_subscription_active(litter)
|
343
375
|
|
344
|
-
if not user_id:
|
345
|
-
_LOGGER.warning(
|
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
|
+
)
|
346
380
|
return media_files
|
347
381
|
|
348
382
|
if not records:
|
383
|
+
_LOGGER.debug("No records found for %s", litter.name)
|
349
384
|
return media_files
|
350
385
|
|
351
386
|
for record in records:
|
352
387
|
if not isinstance(record, LitterRecord):
|
353
388
|
_LOGGER.debug("Record is empty")
|
354
389
|
continue
|
355
|
-
if not record.event_id:
|
356
|
-
_LOGGER.debug("Missing event_id for record item")
|
357
|
-
continue
|
358
|
-
if not record.aes_key:
|
359
|
-
_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")
|
360
392
|
continue
|
361
393
|
if record.timestamp is None:
|
362
394
|
_LOGGER.debug("Missing timestamp for record item")
|
@@ -383,6 +415,19 @@ class MediaManager:
|
|
383
415
|
)
|
384
416
|
return media_files
|
385
417
|
|
418
|
+
@staticmethod
|
419
|
+
def is_subscription_active(device: Feeder | Litter) -> bool:
|
420
|
+
"""Check if the subscription is active based on the work_indate timestamp.
|
421
|
+
:param device: Device object
|
422
|
+
:return: True if the subscription is active, False otherwise
|
423
|
+
"""
|
424
|
+
if device.cloud_product and device.cloud_product.work_indate:
|
425
|
+
return (
|
426
|
+
datetime.fromtimestamp(device.cloud_product.work_indate)
|
427
|
+
> datetime.now()
|
428
|
+
)
|
429
|
+
return False
|
430
|
+
|
386
431
|
@staticmethod
|
387
432
|
async def get_date_from_ts(timestamp: int | None) -> str:
|
388
433
|
"""Get date from timestamp.
|
@@ -395,7 +440,10 @@ class MediaManager:
|
|
395
440
|
|
396
441
|
@staticmethod
|
397
442
|
async def construct_video_url(
|
398
|
-
device_type: str | None,
|
443
|
+
device_type: str | None,
|
444
|
+
media_url: str | None,
|
445
|
+
user_id: int,
|
446
|
+
cp_sub: bool | None,
|
399
447
|
) -> str | None:
|
400
448
|
"""Construct the video URL.
|
401
449
|
:param device_type: Device type
|
@@ -404,7 +452,7 @@ class MediaManager:
|
|
404
452
|
:param cp_sub: Cpsub value
|
405
453
|
:return: Constructed video URL
|
406
454
|
"""
|
407
|
-
if not media_url or not user_id or cp_sub
|
455
|
+
if not media_url or not user_id or not cp_sub:
|
408
456
|
return None
|
409
457
|
params = parse_qs(urlparse(media_url).query)
|
410
458
|
param_dict = {k: v[0] for k, v in params.items()}
|
@@ -523,10 +571,10 @@ class DownloadDecryptMedia:
|
|
523
571
|
elif len(segment_files) == 1:
|
524
572
|
_LOGGER.debug("Single file segment, no need to concatenate")
|
525
573
|
elif len(segment_files) > 1:
|
526
|
-
_LOGGER.debug("Concatenating
|
574
|
+
_LOGGER.debug("Concatenating video with %s segments", len(segment_files))
|
527
575
|
await self._concat_segments(segment_files, file_name)
|
528
576
|
|
529
|
-
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]]:
|
530
578
|
"""Extract the segments from a m3u8 file.
|
531
579
|
:return: Tuple of AES key, IV key, and list of segment URLs
|
532
580
|
"""
|
@@ -539,17 +587,23 @@ class DownloadDecryptMedia:
|
|
539
587
|
|
540
588
|
media_api = video_data.get("mediaApi", None)
|
541
589
|
if not media_api:
|
542
|
-
_LOGGER.
|
590
|
+
_LOGGER.debug("Missing mediaApi in video data")
|
543
591
|
raise ValueError("Missing mediaApi in video data")
|
544
592
|
return await self.client.extract_segments_m3u8(str(media_api))
|
545
593
|
|
546
|
-
async def _get_file(
|
594
|
+
async def _get_file(
|
595
|
+
self, url: str | None, aes_key: str | None, full_filename: str | None
|
596
|
+
) -> bool:
|
547
597
|
"""Download a file from a URL and decrypt it.
|
548
598
|
:param url: URL of the file to download.
|
549
599
|
:param aes_key: AES key used for decryption.
|
550
600
|
:param full_filename: Name of the file to save.
|
551
601
|
:return: True if the file was downloaded successfully, False otherwise.
|
552
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
|
+
|
553
607
|
# Download the file
|
554
608
|
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
555
609
|
if response.status != 200:
|
@@ -609,7 +663,7 @@ class DownloadDecryptMedia:
|
|
609
663
|
try:
|
610
664
|
decrypted_data = unpad(decrypted_data, AES.block_size)
|
611
665
|
except ValueError as e:
|
612
|
-
_LOGGER.debug("
|
666
|
+
_LOGGER.debug("Ignoring unpad warning : %s", e)
|
613
667
|
return decrypted_data
|
614
668
|
|
615
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.11.
|
190
|
+
version = "1.11.5"
|
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.11.
|
212
|
+
current_version = "1.11.5"
|
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
|
File without changes
|