pypetkitapi 1.9.4__py3-none-any.whl → 1.10.2__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.
- pypetkitapi/__init__.py +24 -4
- pypetkitapi/bluetooth.py +174 -0
- pypetkitapi/client.py +52 -201
- pypetkitapi/const.py +6 -0
- pypetkitapi/containers.py +9 -0
- pypetkitapi/exceptions.py +1 -1
- pypetkitapi/feeder_container.py +9 -1
- pypetkitapi/litter_container.py +9 -1
- pypetkitapi/media.py +229 -54
- {pypetkitapi-1.9.4.dist-info → pypetkitapi-1.10.2.dist-info}/METADATA +1 -1
- pypetkitapi-1.10.2.dist-info/RECORD +19 -0
- pypetkitapi-1.9.4.dist-info/RECORD +0 -18
- {pypetkitapi-1.9.4.dist-info → pypetkitapi-1.10.2.dist-info}/LICENSE +0 -0
- {pypetkitapi-1.9.4.dist-info → pypetkitapi-1.10.2.dist-info}/WHEEL +0 -0
pypetkitapi/__init__.py
CHANGED
@@ -23,24 +23,35 @@ from .const import (
|
|
23
23
|
DEVICES_WATER_FOUNTAIN,
|
24
24
|
FEEDER,
|
25
25
|
FEEDER_MINI,
|
26
|
+
FEEDER_WITH_CAMERA,
|
26
27
|
K2,
|
27
28
|
K3,
|
29
|
+
LITTER_NO_CAMERA,
|
30
|
+
LITTER_WITH_CAMERA,
|
28
31
|
T3,
|
29
32
|
T4,
|
30
33
|
T5,
|
31
34
|
T6,
|
32
35
|
W5,
|
36
|
+
MediaType,
|
33
37
|
RecordType,
|
34
38
|
)
|
35
39
|
from .containers import Pet
|
36
|
-
from .exceptions import
|
40
|
+
from .exceptions import (
|
41
|
+
PetkitAuthenticationUnregisteredEmailError,
|
42
|
+
PetkitRegionalServerNotFoundError,
|
43
|
+
PetkitSessionError,
|
44
|
+
PetkitSessionExpiredError,
|
45
|
+
PetkitTimeoutError,
|
46
|
+
PypetkitError,
|
47
|
+
)
|
37
48
|
from .feeder_container import Feeder, RecordsItems
|
38
49
|
from .litter_container import Litter, LitterRecord, WorkState
|
39
|
-
from .media import DownloadDecryptMedia, MediaFile, MediaManager
|
50
|
+
from .media import DownloadDecryptMedia, MediaCloud, MediaFile, MediaManager
|
40
51
|
from .purifier_container import Purifier
|
41
52
|
from .water_fountain_container import WaterFountain
|
42
53
|
|
43
|
-
__version__ = "1.
|
54
|
+
__version__ = "1.10.2"
|
44
55
|
|
45
56
|
__all__ = [
|
46
57
|
"CTW3",
|
@@ -53,6 +64,9 @@ __all__ = [
|
|
53
64
|
"DEVICES_LITTER_BOX",
|
54
65
|
"DEVICES_PURIFIER",
|
55
66
|
"DEVICES_WATER_FOUNTAIN",
|
67
|
+
"FEEDER_WITH_CAMERA",
|
68
|
+
"LITTER_WITH_CAMERA",
|
69
|
+
"LITTER_NO_CAMERA",
|
56
70
|
"DeviceAction",
|
57
71
|
"DeviceCommand",
|
58
72
|
"FEEDER",
|
@@ -67,11 +81,17 @@ __all__ = [
|
|
67
81
|
"LitterRecord",
|
68
82
|
"MediaManager",
|
69
83
|
"DownloadDecryptMedia",
|
84
|
+
"MediaCloud",
|
70
85
|
"MediaFile",
|
86
|
+
"MediaType",
|
71
87
|
"Pet",
|
72
88
|
"PetCommand",
|
73
89
|
"PetKitClient",
|
74
|
-
"
|
90
|
+
"PetkitAuthenticationUnregisteredEmailError",
|
91
|
+
"PetkitRegionalServerNotFoundError",
|
92
|
+
"PetkitSessionError",
|
93
|
+
"PetkitSessionExpiredError",
|
94
|
+
"PetkitTimeoutError",
|
75
95
|
"PurMode",
|
76
96
|
"Purifier",
|
77
97
|
"PypetkitError",
|
pypetkitapi/bluetooth.py
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
"""Module for handling Bluetooth communication with PetKit devices."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import base64
|
5
|
+
from datetime import datetime
|
6
|
+
from http import HTTPMethod
|
7
|
+
import logging
|
8
|
+
from typing import TYPE_CHECKING
|
9
|
+
import urllib.parse
|
10
|
+
|
11
|
+
from pypetkitapi.command import FOUNTAIN_COMMAND, FountainAction
|
12
|
+
from pypetkitapi.const import (
|
13
|
+
BLE_CONNECT_ATTEMPT,
|
14
|
+
BLE_END_TRAME,
|
15
|
+
BLE_START_TRAME,
|
16
|
+
PetkitEndpoint,
|
17
|
+
)
|
18
|
+
from pypetkitapi.containers import BleRelay
|
19
|
+
|
20
|
+
if TYPE_CHECKING:
|
21
|
+
from pypetkitapi import PetKitClient, WaterFountain
|
22
|
+
|
23
|
+
_LOGGER = logging.getLogger(__name__)
|
24
|
+
|
25
|
+
|
26
|
+
class BluetoothManager:
|
27
|
+
"""Class for handling Bluetooth communication with PetKit devices."""
|
28
|
+
|
29
|
+
def __init__(self, client: "PetKitClient"):
|
30
|
+
"""Initialize the BluetoothManager class."""
|
31
|
+
self.client = client
|
32
|
+
|
33
|
+
async def _get_fountain_instance(self, fountain_id: int) -> "WaterFountain":
|
34
|
+
"""Get the WaterFountain instance for the given fountain_id."""
|
35
|
+
from pypetkitapi.water_fountain_container import WaterFountain
|
36
|
+
|
37
|
+
water_fountain = self.client.petkit_entities.get(fountain_id)
|
38
|
+
if not isinstance(water_fountain, WaterFountain):
|
39
|
+
_LOGGER.error("Water fountain with ID %s not found.", fountain_id)
|
40
|
+
raise TypeError(f"Water fountain with ID {fountain_id} not found.")
|
41
|
+
return water_fountain
|
42
|
+
|
43
|
+
async def check_relay_availability(self, fountain_id: int) -> bool:
|
44
|
+
"""Check if BLE relay is available for the given fountain_id."""
|
45
|
+
fountain = None
|
46
|
+
for account in self.client.account_data:
|
47
|
+
if account.device_list:
|
48
|
+
fountain = next(
|
49
|
+
(
|
50
|
+
device
|
51
|
+
for device in account.device_list
|
52
|
+
if device.device_id == fountain_id
|
53
|
+
),
|
54
|
+
None,
|
55
|
+
)
|
56
|
+
if fountain:
|
57
|
+
break
|
58
|
+
if not fountain:
|
59
|
+
raise ValueError(
|
60
|
+
f"Fountain with device_id {fountain_id} not found for the current account"
|
61
|
+
)
|
62
|
+
group_id = fountain.group_id
|
63
|
+
response = await self.client.req.request(
|
64
|
+
method=HTTPMethod.POST,
|
65
|
+
url=f"{PetkitEndpoint.BLE_AS_RELAY}",
|
66
|
+
params={"groupId": group_id},
|
67
|
+
headers=await self.client.get_session_id(),
|
68
|
+
)
|
69
|
+
ble_relays = [BleRelay(**relay) for relay in response]
|
70
|
+
if len(ble_relays) == 0:
|
71
|
+
_LOGGER.warning("No BLE relay devices found.")
|
72
|
+
return False
|
73
|
+
return True
|
74
|
+
|
75
|
+
async def open_ble_connection(self, fountain_id: int) -> bool:
|
76
|
+
"""Open a BLE connection to the given fountain_id."""
|
77
|
+
_LOGGER.info("Opening BLE connection to fountain %s", fountain_id)
|
78
|
+
water_fountain = await self._get_fountain_instance(fountain_id)
|
79
|
+
if await self.check_relay_availability(fountain_id) is False:
|
80
|
+
_LOGGER.error("BLE relay not available.")
|
81
|
+
return False
|
82
|
+
if water_fountain.is_connected is True:
|
83
|
+
_LOGGER.error("BLE connection already established.")
|
84
|
+
return True
|
85
|
+
response = await self.client.req.request(
|
86
|
+
method=HTTPMethod.POST,
|
87
|
+
url=PetkitEndpoint.BLE_CONNECT,
|
88
|
+
data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
|
89
|
+
headers=await self.client.get_session_id(),
|
90
|
+
)
|
91
|
+
if response != {"state": 1}:
|
92
|
+
_LOGGER.error("Failed to establish BLE connection.")
|
93
|
+
water_fountain.is_connected = False
|
94
|
+
return False
|
95
|
+
for attempt in range(BLE_CONNECT_ATTEMPT):
|
96
|
+
_LOGGER.warning("BLE connection attempt n%s", attempt)
|
97
|
+
response = await self.client.req.request(
|
98
|
+
method=HTTPMethod.POST,
|
99
|
+
url=PetkitEndpoint.BLE_POLL,
|
100
|
+
data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
|
101
|
+
headers=await self.client.get_session_id(),
|
102
|
+
)
|
103
|
+
if response == 1:
|
104
|
+
_LOGGER.info("BLE connection established successfully.")
|
105
|
+
water_fountain.is_connected = True
|
106
|
+
water_fountain.last_ble_poll = datetime.now().strftime(
|
107
|
+
"%Y-%m-%dT%H:%M:%S.%f"
|
108
|
+
)
|
109
|
+
return True
|
110
|
+
await asyncio.sleep(4)
|
111
|
+
_LOGGER.error("Failed to establish BLE connection after multiple attempts.")
|
112
|
+
water_fountain.is_connected = False
|
113
|
+
return False
|
114
|
+
|
115
|
+
async def close_ble_connection(self, fountain_id: int) -> None:
|
116
|
+
"""Close the BLE connection to the given fountain_id."""
|
117
|
+
_LOGGER.info("Closing BLE connection to fountain %s", fountain_id)
|
118
|
+
water_fountain = await self._get_fountain_instance(fountain_id)
|
119
|
+
await self.client.req.request(
|
120
|
+
method=HTTPMethod.POST,
|
121
|
+
url=PetkitEndpoint.BLE_CANCEL,
|
122
|
+
data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
|
123
|
+
headers=await self.client.get_session_id(),
|
124
|
+
)
|
125
|
+
_LOGGER.info("BLE connection closed successfully.")
|
126
|
+
|
127
|
+
async def get_ble_cmd_data(
|
128
|
+
self, fountain_command: list, counter: int
|
129
|
+
) -> tuple[int, str]:
|
130
|
+
"""Get the BLE command data for the given fountain_command."""
|
131
|
+
cmd_code = fountain_command[0]
|
132
|
+
modified_command = fountain_command[:2] + [counter] + fountain_command[2:]
|
133
|
+
ble_data = [*BLE_START_TRAME, *modified_command, *BLE_END_TRAME]
|
134
|
+
encoded_data = await self._encode_ble_data(ble_data)
|
135
|
+
return cmd_code, encoded_data
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
async def _encode_ble_data(byte_list: list) -> str:
|
139
|
+
"""Encode the given byte_list to a base64 encoded string."""
|
140
|
+
byte_array = bytearray(byte_list)
|
141
|
+
b64_encoded = base64.b64encode(byte_array)
|
142
|
+
return urllib.parse.quote(b64_encoded)
|
143
|
+
|
144
|
+
async def send_ble_command(self, fountain_id: int, command: FountainAction) -> bool:
|
145
|
+
"""Send the given BLE command to the fountain_id."""
|
146
|
+
_LOGGER.info("Sending BLE command to fountain %s", fountain_id)
|
147
|
+
water_fountain = await self._get_fountain_instance(fountain_id)
|
148
|
+
if water_fountain.is_connected is False:
|
149
|
+
_LOGGER.error("BLE connection not established.")
|
150
|
+
return False
|
151
|
+
command_data = FOUNTAIN_COMMAND.get(command)
|
152
|
+
if command_data is None:
|
153
|
+
_LOGGER.error("Command not found.")
|
154
|
+
return False
|
155
|
+
cmd_code, cmd_data = await self.get_ble_cmd_data(
|
156
|
+
list(command_data), water_fountain.ble_counter
|
157
|
+
)
|
158
|
+
response = await self.client.req.request(
|
159
|
+
method=HTTPMethod.POST,
|
160
|
+
url=PetkitEndpoint.BLE_CONTROL_DEVICE,
|
161
|
+
data={
|
162
|
+
"bleId": water_fountain.id,
|
163
|
+
"cmd": cmd_code,
|
164
|
+
"data": cmd_data,
|
165
|
+
"mac": water_fountain.mac,
|
166
|
+
"type": 24,
|
167
|
+
},
|
168
|
+
headers=await self.client.get_session_id(),
|
169
|
+
)
|
170
|
+
if response != 1:
|
171
|
+
_LOGGER.error("Failed to send BLE command.")
|
172
|
+
return False
|
173
|
+
_LOGGER.info("BLE command sent successfully.")
|
174
|
+
return True
|
pypetkitapi/client.py
CHANGED
@@ -1,23 +1,19 @@
|
|
1
1
|
"""Pypetkit Client: A Python library for interfacing with PetKit"""
|
2
2
|
|
3
3
|
import asyncio
|
4
|
-
import base64
|
5
4
|
from datetime import datetime, timedelta
|
6
5
|
from enum import StrEnum
|
7
6
|
import hashlib
|
8
7
|
from http import HTTPMethod
|
9
8
|
import logging
|
10
|
-
import urllib.parse
|
11
9
|
|
12
10
|
import aiohttp
|
13
11
|
from aiohttp import ContentTypeError
|
14
12
|
import m3u8
|
15
13
|
|
16
|
-
from pypetkitapi.
|
14
|
+
from pypetkitapi.bluetooth import BluetoothManager
|
15
|
+
from pypetkitapi.command import ACTIONS_MAP
|
17
16
|
from pypetkitapi.const import (
|
18
|
-
BLE_CONNECT_ATTEMPT,
|
19
|
-
BLE_END_TRAME,
|
20
|
-
BLE_START_TRAME,
|
21
17
|
CLIENT_NFO,
|
22
18
|
DEVICE_DATA,
|
23
19
|
DEVICE_RECORDS,
|
@@ -27,6 +23,7 @@ from pypetkitapi.const import (
|
|
27
23
|
DEVICES_PURIFIER,
|
28
24
|
DEVICES_WATER_FOUNTAIN,
|
29
25
|
ERR_KEY,
|
26
|
+
FEEDER_WITH_CAMERA,
|
30
27
|
LITTER_NO_CAMERA,
|
31
28
|
LITTER_WITH_CAMERA,
|
32
29
|
LOGIN_DATA,
|
@@ -40,14 +37,7 @@ from pypetkitapi.const import (
|
|
40
37
|
PetkitDomain,
|
41
38
|
PetkitEndpoint,
|
42
39
|
)
|
43
|
-
from pypetkitapi.containers import
|
44
|
-
AccountData,
|
45
|
-
BleRelay,
|
46
|
-
Device,
|
47
|
-
Pet,
|
48
|
-
RegionInfo,
|
49
|
-
SessionInfo,
|
50
|
-
)
|
40
|
+
from pypetkitapi.containers import AccountData, Device, Pet, RegionInfo, SessionInfo
|
51
41
|
from pypetkitapi.exceptions import (
|
52
42
|
PetkitAuthenticationError,
|
53
43
|
PetkitAuthenticationUnregisteredEmailError,
|
@@ -84,10 +74,6 @@ _LOGGER = logging.getLogger(__name__)
|
|
84
74
|
class PetKitClient:
|
85
75
|
"""Petkit Client"""
|
86
76
|
|
87
|
-
_session: SessionInfo | None = None
|
88
|
-
account_data: list[AccountData] = []
|
89
|
-
petkit_entities: dict[int, Feeder | Litter | WaterFountain | Purifier | Pet] = {}
|
90
|
-
|
91
77
|
def __init__(
|
92
78
|
self,
|
93
79
|
username: str,
|
@@ -101,14 +87,21 @@ class PetKitClient:
|
|
101
87
|
self.password = password
|
102
88
|
self.region = region.lower()
|
103
89
|
self.timezone = timezone
|
104
|
-
self._session = None
|
105
|
-
self.
|
90
|
+
self._session: SessionInfo | None = None
|
91
|
+
self.account_data: list[AccountData] = []
|
92
|
+
self.petkit_entities: dict[
|
93
|
+
int, Feeder | Litter | WaterFountain | Purifier | Pet
|
94
|
+
] = {}
|
106
95
|
self.aiohttp_session = session or aiohttp.ClientSession()
|
107
96
|
self.req = PrepReq(
|
108
97
|
base_url=PetkitDomain.PASSPORT_PETKIT,
|
109
98
|
session=self.aiohttp_session,
|
110
99
|
timezone=self.timezone,
|
111
100
|
)
|
101
|
+
self.bluetooth_manager = BluetoothManager(self)
|
102
|
+
from pypetkitapi import MediaManager
|
103
|
+
|
104
|
+
self.media_manager = MediaManager()
|
112
105
|
|
113
106
|
async def _get_base_url(self) -> None:
|
114
107
|
"""Get the list of API servers, filter by region, and return the matching server."""
|
@@ -259,10 +252,11 @@ class PetKitClient:
|
|
259
252
|
await self._get_account_data()
|
260
253
|
|
261
254
|
device_list = self._collect_devices()
|
262
|
-
main_tasks, record_tasks = self._prepare_tasks(device_list)
|
255
|
+
main_tasks, record_tasks, media_tasks = self._prepare_tasks(device_list)
|
263
256
|
|
264
257
|
await asyncio.gather(*main_tasks)
|
265
258
|
await asyncio.gather(*record_tasks)
|
259
|
+
await asyncio.gather(*media_tasks)
|
266
260
|
await self._execute_stats_tasks()
|
267
261
|
|
268
262
|
end_time = datetime.now()
|
@@ -279,10 +273,11 @@ class PetKitClient:
|
|
279
273
|
_LOGGER.debug("Found %s devices", len(account.device_list))
|
280
274
|
return device_list
|
281
275
|
|
282
|
-
def _prepare_tasks(self, device_list: list[Device]) -> tuple[list, list]:
|
276
|
+
def _prepare_tasks(self, device_list: list[Device]) -> tuple[list, list, list]:
|
283
277
|
"""Prepare main and record tasks based on device types."""
|
284
|
-
main_tasks = []
|
285
|
-
record_tasks = []
|
278
|
+
main_tasks: list = []
|
279
|
+
record_tasks: list = []
|
280
|
+
media_tasks: list = []
|
286
281
|
|
287
282
|
for device in device_list:
|
288
283
|
device_type = device.device_type
|
@@ -290,11 +285,14 @@ class PetKitClient:
|
|
290
285
|
if device_type in DEVICES_FEEDER:
|
291
286
|
main_tasks.append(self._fetch_device_data(device, Feeder))
|
292
287
|
record_tasks.append(self._fetch_device_data(device, FeederRecord))
|
288
|
+
self._add_feeder_task_by_type(media_tasks, device_type, device)
|
293
289
|
|
294
290
|
elif device_type in DEVICES_LITTER_BOX:
|
295
291
|
main_tasks.append(self._fetch_device_data(device, Litter))
|
296
292
|
record_tasks.append(self._fetch_device_data(device, LitterRecord))
|
297
|
-
self.
|
293
|
+
self._add_lb_task_by_type(
|
294
|
+
record_tasks, media_tasks, device_type, device
|
295
|
+
)
|
298
296
|
|
299
297
|
elif device_type in DEVICES_WATER_FOUNTAIN:
|
300
298
|
main_tasks.append(self._fetch_device_data(device, WaterFountain))
|
@@ -305,16 +303,24 @@ class PetKitClient:
|
|
305
303
|
elif device_type in DEVICES_PURIFIER:
|
306
304
|
main_tasks.append(self._fetch_device_data(device, Purifier))
|
307
305
|
|
308
|
-
return main_tasks, record_tasks
|
306
|
+
return main_tasks, record_tasks, media_tasks
|
309
307
|
|
310
|
-
def
|
311
|
-
self, record_tasks: list, device_type: str, device: Device
|
308
|
+
def _add_lb_task_by_type(
|
309
|
+
self, record_tasks: list, media_tasks: list, device_type: str, device: Device
|
312
310
|
):
|
313
311
|
"""Add specific tasks for litter box devices."""
|
314
|
-
if device_type in
|
312
|
+
if device_type in LITTER_NO_CAMERA:
|
315
313
|
record_tasks.append(self._fetch_device_data(device, LitterStats))
|
316
|
-
if device_type in
|
314
|
+
if device_type in LITTER_WITH_CAMERA:
|
317
315
|
record_tasks.append(self._fetch_device_data(device, PetOutGraph))
|
316
|
+
media_tasks.append(self._fetch_media(device))
|
317
|
+
|
318
|
+
def _add_feeder_task_by_type(
|
319
|
+
self, media_tasks: list, device_type: str, device: Device
|
320
|
+
):
|
321
|
+
"""Add specific tasks for feeder box devices."""
|
322
|
+
if device_type in FEEDER_WITH_CAMERA:
|
323
|
+
media_tasks.append(self._fetch_media(device))
|
318
324
|
|
319
325
|
async def _execute_stats_tasks(self) -> None:
|
320
326
|
"""Execute tasks to populate pet stats."""
|
@@ -325,6 +331,15 @@ class PetKitClient:
|
|
325
331
|
]
|
326
332
|
await asyncio.gather(*stats_tasks)
|
327
333
|
|
334
|
+
async def _fetch_media(self, device: Device) -> None:
|
335
|
+
"""Fetch media data from the PetKit servers."""
|
336
|
+
_LOGGER.debug("Fetching media data for device: %s", device.device_id)
|
337
|
+
|
338
|
+
device_entity = self.petkit_entities[device.device_id]
|
339
|
+
device_entity.medias = await self.media_manager.get_all_media_files(
|
340
|
+
[device_entity]
|
341
|
+
)
|
342
|
+
|
328
343
|
async def _fetch_device_data(
|
329
344
|
self,
|
330
345
|
device: Device,
|
@@ -494,180 +509,18 @@ class PetKitClient:
|
|
494
509
|
pet.last_duration_usage = self.get_safe_value(graph.toilet_time)
|
495
510
|
pet.last_device_used = pet_graphs.device_nfo.device_name
|
496
511
|
|
497
|
-
async def
|
498
|
-
# Retrieve the water fountain object
|
499
|
-
water_fountain = self.petkit_entities.get(fountain_id)
|
500
|
-
if not isinstance(water_fountain, WaterFountain):
|
501
|
-
_LOGGER.error("Water fountain with ID %s not found.", fountain_id)
|
502
|
-
raise TypeError(f"Water fountain with ID {fountain_id} not found.")
|
503
|
-
return water_fountain
|
504
|
-
|
505
|
-
async def check_relay_availability(self, fountain_id: int) -> bool:
|
506
|
-
"""Check if BLE relay is available for the account."""
|
507
|
-
fountain = None
|
508
|
-
|
509
|
-
for account in self.account_data:
|
510
|
-
if account.device_list:
|
511
|
-
fountain = next(
|
512
|
-
(
|
513
|
-
device
|
514
|
-
for device in account.device_list
|
515
|
-
if device.device_id == fountain_id
|
516
|
-
),
|
517
|
-
None,
|
518
|
-
)
|
519
|
-
if fountain:
|
520
|
-
break
|
521
|
-
|
522
|
-
if not fountain:
|
523
|
-
raise ValueError(
|
524
|
-
f"Fountain with device_id {fountain_id} not found for the current account"
|
525
|
-
)
|
526
|
-
|
527
|
-
group_id = fountain.group_id
|
528
|
-
|
529
|
-
response = await self.req.request(
|
530
|
-
method=HTTPMethod.POST,
|
531
|
-
url=f"{PetkitEndpoint.BLE_AS_RELAY}",
|
532
|
-
params={"groupId": group_id},
|
533
|
-
headers=await self.get_session_id(),
|
534
|
-
)
|
535
|
-
ble_relays = [BleRelay(**relay) for relay in response]
|
536
|
-
|
537
|
-
if len(ble_relays) == 0:
|
538
|
-
_LOGGER.warning("No BLE relay devices found.")
|
539
|
-
return False
|
540
|
-
return True
|
541
|
-
|
542
|
-
async def open_ble_connection(self, fountain_id: int) -> bool:
|
543
|
-
"""Open a BLE connection to a PetKit device."""
|
544
|
-
_LOGGER.info("Opening BLE connection to fountain %s", fountain_id)
|
545
|
-
water_fountain = await self._get_fountain_instance(fountain_id)
|
546
|
-
|
547
|
-
if await self.check_relay_availability(fountain_id) is False:
|
548
|
-
_LOGGER.error("BLE relay not available.")
|
549
|
-
return False
|
550
|
-
|
551
|
-
if water_fountain.is_connected is True:
|
552
|
-
_LOGGER.error("BLE connection already established.")
|
553
|
-
return True
|
554
|
-
|
555
|
-
response = await self.req.request(
|
556
|
-
method=HTTPMethod.POST,
|
557
|
-
url=PetkitEndpoint.BLE_CONNECT,
|
558
|
-
data={
|
559
|
-
"bleId": fountain_id,
|
560
|
-
"type": 24,
|
561
|
-
"mac": water_fountain.mac,
|
562
|
-
},
|
563
|
-
headers=await self.get_session_id(),
|
564
|
-
)
|
565
|
-
if response != {"state": 1}:
|
566
|
-
_LOGGER.error("Failed to establish BLE connection.")
|
567
|
-
water_fountain.is_connected = False
|
568
|
-
return False
|
569
|
-
|
570
|
-
for attempt in range(BLE_CONNECT_ATTEMPT):
|
571
|
-
_LOGGER.warning("BLE connection attempt n%s", attempt)
|
572
|
-
response = await self.req.request(
|
573
|
-
method=HTTPMethod.POST,
|
574
|
-
url=PetkitEndpoint.BLE_POLL,
|
575
|
-
data={
|
576
|
-
"bleId": fountain_id,
|
577
|
-
"type": 24,
|
578
|
-
"mac": water_fountain.mac,
|
579
|
-
},
|
580
|
-
headers=await self.get_session_id(),
|
581
|
-
)
|
582
|
-
if response == 1:
|
583
|
-
_LOGGER.info("BLE connection established successfully.")
|
584
|
-
water_fountain.is_connected = True
|
585
|
-
water_fountain.last_ble_poll = datetime.now().strftime(
|
586
|
-
"%Y-%m-%dT%H:%M:%S.%f"
|
587
|
-
)
|
588
|
-
return True
|
589
|
-
await asyncio.sleep(4)
|
590
|
-
_LOGGER.error("Failed to establish BLE connection after multiple attempts.")
|
591
|
-
water_fountain.is_connected = False
|
592
|
-
return False
|
593
|
-
|
594
|
-
async def close_ble_connection(self, fountain_id: int) -> None:
|
595
|
-
"""Close the BLE connection to a PetKit device."""
|
596
|
-
_LOGGER.info("Closing BLE connection to fountain %s", fountain_id)
|
597
|
-
water_fountain = await self._get_fountain_instance(fountain_id)
|
598
|
-
|
599
|
-
await self.req.request(
|
600
|
-
method=HTTPMethod.POST,
|
601
|
-
url=PetkitEndpoint.BLE_CANCEL,
|
602
|
-
data={
|
603
|
-
"bleId": fountain_id,
|
604
|
-
"type": 24,
|
605
|
-
"mac": water_fountain.mac,
|
606
|
-
},
|
607
|
-
headers=await self.get_session_id(),
|
608
|
-
)
|
609
|
-
_LOGGER.info("BLE connection closed successfully.")
|
610
|
-
|
611
|
-
async def get_ble_cmd_data(
|
612
|
-
self, fountain_command: list, counter: int
|
613
|
-
) -> tuple[int, str]:
|
614
|
-
"""Prepare BLE data by adding start and end trame to the command and extracting the first number."""
|
615
|
-
cmd_code = fountain_command[0]
|
616
|
-
modified_command = fountain_command[:2] + [counter] + fountain_command[2:]
|
617
|
-
ble_data = [*BLE_START_TRAME, *modified_command, *BLE_END_TRAME]
|
618
|
-
encoded_data = await self._encode_ble_data(ble_data)
|
619
|
-
return cmd_code, encoded_data
|
620
|
-
|
621
|
-
@staticmethod
|
622
|
-
async def _encode_ble_data(byte_list: list) -> str:
|
623
|
-
"""Encode a list of bytes to a URL encoded base64 string."""
|
624
|
-
byte_array = bytearray(byte_list)
|
625
|
-
b64_encoded = base64.b64encode(byte_array)
|
626
|
-
return urllib.parse.quote(b64_encoded)
|
627
|
-
|
628
|
-
async def send_ble_command(self, fountain_id: int, command: FountainAction) -> bool:
|
629
|
-
"""BLE command to a PetKit device."""
|
630
|
-
_LOGGER.info("Sending BLE command to fountain %s", fountain_id)
|
631
|
-
water_fountain = await self._get_fountain_instance(fountain_id)
|
632
|
-
|
633
|
-
if water_fountain.is_connected is False:
|
634
|
-
_LOGGER.error("BLE connection not established.")
|
635
|
-
return False
|
636
|
-
|
637
|
-
command_data = FOUNTAIN_COMMAND.get(command)
|
638
|
-
if command_data is None:
|
639
|
-
_LOGGER.error("Command not found.")
|
640
|
-
return False
|
641
|
-
|
642
|
-
cmd_code, cmd_data = await self.get_ble_cmd_data(
|
643
|
-
list(command_data), water_fountain.ble_counter
|
644
|
-
)
|
645
|
-
|
646
|
-
response = await self.req.request(
|
647
|
-
method=HTTPMethod.POST,
|
648
|
-
url=PetkitEndpoint.BLE_CONTROL_DEVICE,
|
649
|
-
data={
|
650
|
-
"bleId": water_fountain.id,
|
651
|
-
"cmd": cmd_code,
|
652
|
-
"data": cmd_data,
|
653
|
-
"mac": water_fountain.mac,
|
654
|
-
"type": 24,
|
655
|
-
},
|
656
|
-
headers=await self.get_session_id(),
|
657
|
-
)
|
658
|
-
if response != 1:
|
659
|
-
_LOGGER.error("Failed to send BLE command.")
|
660
|
-
return False
|
661
|
-
_LOGGER.info("BLE command sent successfully.")
|
662
|
-
return True
|
663
|
-
|
664
|
-
async def get_cloud_video(self, video_url: str) -> dict[str, str | int]:
|
512
|
+
async def get_cloud_video(self, video_url: str) -> dict[str, str | int] | None:
|
665
513
|
"""Get the video m3u8 link from the cloud."""
|
666
514
|
response = await self.req.request(
|
667
515
|
method=HTTPMethod.POST,
|
668
516
|
url=video_url,
|
669
517
|
headers=await self.get_session_id(),
|
670
518
|
)
|
519
|
+
if not isinstance(response, list) or not response:
|
520
|
+
_LOGGER.debug(
|
521
|
+
"No video data found from cloud, looks like you don't have a care+ subscription ?"
|
522
|
+
)
|
523
|
+
return None
|
671
524
|
return response[0]
|
672
525
|
|
673
526
|
async def extract_segments_m3u8(self, m3u8_url: str) -> tuple[str, str, list[str]]:
|
@@ -858,9 +711,7 @@ class PrepReq:
|
|
858
711
|
f"Authentication failed: {error_msg}"
|
859
712
|
)
|
860
713
|
case 125:
|
861
|
-
raise PetkitAuthenticationUnregisteredEmailError
|
862
|
-
f"Authentication failed: {error_msg}"
|
863
|
-
)
|
714
|
+
raise PetkitAuthenticationUnregisteredEmailError
|
864
715
|
case _:
|
865
716
|
raise PypetkitError(
|
866
717
|
f"Request failed code : {error_code}, details : {error_msg} url : {url}"
|
pypetkitapi/const.py
CHANGED
@@ -34,12 +34,18 @@ K2 = "k2"
|
|
34
34
|
K3 = "k3"
|
35
35
|
PET = "pet"
|
36
36
|
|
37
|
+
# Litter
|
37
38
|
DEVICES_LITTER_BOX = [T3, T4, T5, T6]
|
38
39
|
LITTER_WITH_CAMERA = [T5, T6]
|
39
40
|
LITTER_NO_CAMERA = [T3, T4]
|
41
|
+
# Feeder
|
42
|
+
FEEDER_WITH_CAMERA = [D4H, D4SH]
|
40
43
|
DEVICES_FEEDER = [FEEDER, FEEDER_MINI, D3, D4, D4S, D4H, D4SH]
|
44
|
+
# Water Fountain
|
41
45
|
DEVICES_WATER_FOUNTAIN = [W5, CTW3]
|
46
|
+
# Purifier
|
42
47
|
DEVICES_PURIFIER = [K2]
|
48
|
+
# All devices
|
43
49
|
ALL_DEVICES = [
|
44
50
|
*DEVICES_LITTER_BOX,
|
45
51
|
*DEVICES_FEEDER,
|
pypetkitapi/containers.py
CHANGED
@@ -99,6 +99,15 @@ class Pet(BaseModel):
|
|
99
99
|
self.name = self.name or self.pet_name
|
100
100
|
|
101
101
|
|
102
|
+
class UserDevice(BaseModel):
|
103
|
+
"""Dataclass for user data.
|
104
|
+
Subclass of Devices.
|
105
|
+
"""
|
106
|
+
|
107
|
+
id: int | None = None
|
108
|
+
nick: str | None = None
|
109
|
+
|
110
|
+
|
102
111
|
class User(BaseModel):
|
103
112
|
"""Dataclass for user data.
|
104
113
|
Subclass of AccountData.
|
pypetkitapi/exceptions.py
CHANGED
@@ -22,7 +22,7 @@ class PetkitSessionExpiredError(PypetkitError):
|
|
22
22
|
class PetkitAuthenticationUnregisteredEmailError(PypetkitError):
|
23
23
|
"""Exception raised when the email is not registered with Petkit."""
|
24
24
|
|
25
|
-
def __init__(self
|
25
|
+
def __init__(self):
|
26
26
|
"""Initialize the exception."""
|
27
27
|
self.message = "The email you provided is not registered on Petkit's servers. Please check your email, or you are using the correct region."
|
28
28
|
super().__init__(self.message)
|
pypetkitapi/feeder_container.py
CHANGED
@@ -14,7 +14,13 @@ from pypetkitapi.const import (
|
|
14
14
|
FEEDER_MINI,
|
15
15
|
PetkitEndpoint,
|
16
16
|
)
|
17
|
-
from pypetkitapi.containers import
|
17
|
+
from pypetkitapi.containers import (
|
18
|
+
CloudProduct,
|
19
|
+
Device,
|
20
|
+
FirmwareDetail,
|
21
|
+
UserDevice,
|
22
|
+
Wifi,
|
23
|
+
)
|
18
24
|
|
19
25
|
|
20
26
|
class FeedItem(BaseModel):
|
@@ -335,7 +341,9 @@ class Feeder(BaseModel):
|
|
335
341
|
sn: str
|
336
342
|
state: StateFeeder | None = None
|
337
343
|
timezone: float | None = None
|
344
|
+
user: UserDevice | None = None
|
338
345
|
device_nfo: Device | None = None
|
346
|
+
medias: list | None = None
|
339
347
|
|
340
348
|
@classmethod
|
341
349
|
def get_endpoint(cls, device_type: str) -> str:
|
pypetkitapi/litter_container.py
CHANGED
@@ -14,7 +14,13 @@ from pypetkitapi.const import (
|
|
14
14
|
T3,
|
15
15
|
PetkitEndpoint,
|
16
16
|
)
|
17
|
-
from pypetkitapi.containers import
|
17
|
+
from pypetkitapi.containers import (
|
18
|
+
CloudProduct,
|
19
|
+
Device,
|
20
|
+
FirmwareDetail,
|
21
|
+
UserDevice,
|
22
|
+
Wifi,
|
23
|
+
)
|
18
24
|
|
19
25
|
|
20
26
|
class SettingsLitter(BaseModel):
|
@@ -445,10 +451,12 @@ class Litter(BaseModel):
|
|
445
451
|
service_status: int | None = Field(None, alias="serviceStatus")
|
446
452
|
total_time: int | None = Field(None, alias="totalTime")
|
447
453
|
with_k3: int | None = Field(None, alias="withK3")
|
454
|
+
user: UserDevice | None = None
|
448
455
|
device_records: list[LitterRecord] | None = None
|
449
456
|
device_stats: LitterStats | None = None
|
450
457
|
device_pet_graph_out: list[PetOutGraph] | None = None
|
451
458
|
device_nfo: Device | None = None
|
459
|
+
medias: list | None = None
|
452
460
|
|
453
461
|
@classmethod
|
454
462
|
def get_endpoint(cls, device_type: str) -> str:
|
pypetkitapi/media.py
CHANGED
@@ -7,22 +7,30 @@ from dataclasses import dataclass
|
|
7
7
|
from datetime import datetime
|
8
8
|
import logging
|
9
9
|
from pathlib import Path
|
10
|
+
import re
|
10
11
|
from typing import Any
|
11
12
|
from urllib.parse import parse_qs, urlparse
|
12
13
|
|
14
|
+
import aiofiles
|
13
15
|
from aiofiles import open as aio_open
|
16
|
+
import aiofiles.os
|
14
17
|
import aiohttp
|
15
18
|
from Crypto.Cipher import AES
|
16
19
|
from Crypto.Util.Padding import unpad
|
17
20
|
|
18
21
|
from pypetkitapi import Feeder, Litter, PetKitClient, RecordType
|
19
|
-
from pypetkitapi.const import
|
22
|
+
from pypetkitapi.const import (
|
23
|
+
FEEDER_WITH_CAMERA,
|
24
|
+
LITTER_WITH_CAMERA,
|
25
|
+
MediaType,
|
26
|
+
RecordTypeLST,
|
27
|
+
)
|
20
28
|
|
21
29
|
_LOGGER = logging.getLogger(__name__)
|
22
30
|
|
23
31
|
|
24
32
|
@dataclass
|
25
|
-
class
|
33
|
+
class MediaCloud:
|
26
34
|
"""Dataclass MediaFile.
|
27
35
|
Represents a media file from a PetKit device.
|
28
36
|
"""
|
@@ -30,27 +38,46 @@ class MediaFile:
|
|
30
38
|
event_id: str
|
31
39
|
event_type: RecordType
|
32
40
|
device_id: int
|
33
|
-
user_id:
|
41
|
+
user_id: int
|
34
42
|
image: str | None
|
35
43
|
video: str | None
|
36
44
|
filepath: str
|
37
45
|
aes_key: str
|
38
46
|
timestamp: int
|
39
|
-
|
47
|
+
|
48
|
+
|
49
|
+
@dataclass
|
50
|
+
class MediaFile:
|
51
|
+
"""Dataclass MediaFile.
|
52
|
+
Represents a media file into disk.
|
53
|
+
"""
|
54
|
+
|
55
|
+
event_id: str
|
56
|
+
device_id: int
|
57
|
+
timestamp: int
|
58
|
+
media_type: MediaType
|
59
|
+
event_type: RecordType
|
60
|
+
full_file_path: Path
|
40
61
|
|
41
62
|
|
42
63
|
class MediaManager:
|
43
64
|
"""Class to manage media files from PetKit devices."""
|
44
65
|
|
66
|
+
media_table: list[MediaFile] = []
|
67
|
+
|
45
68
|
async def get_all_media_files(
|
46
69
|
self, devices: list[Feeder | Litter]
|
47
|
-
) -> list[
|
70
|
+
) -> list[MediaCloud]:
|
48
71
|
"""Get all media files from all devices and return a list of MediaFile."""
|
49
|
-
media_files: list[
|
72
|
+
media_files: list[MediaCloud] = []
|
73
|
+
_LOGGER.debug("Processing media files for %s devices", len(devices))
|
50
74
|
|
51
75
|
for device in devices:
|
52
76
|
if isinstance(device, Feeder):
|
53
|
-
if
|
77
|
+
if (
|
78
|
+
device.device_nfo
|
79
|
+
and device.device_nfo.device_type in FEEDER_WITH_CAMERA
|
80
|
+
):
|
54
81
|
media_files.extend(self._process_feeder(device))
|
55
82
|
else:
|
56
83
|
_LOGGER.debug(
|
@@ -58,7 +85,10 @@ class MediaManager:
|
|
58
85
|
device.name,
|
59
86
|
)
|
60
87
|
elif isinstance(device, Litter):
|
61
|
-
if
|
88
|
+
if (
|
89
|
+
device.device_nfo
|
90
|
+
and device.device_nfo.device_type in LITTER_WITH_CAMERA
|
91
|
+
):
|
62
92
|
media_files.extend(self._process_litter(device))
|
63
93
|
else:
|
64
94
|
_LOGGER.debug(
|
@@ -68,39 +98,147 @@ class MediaManager:
|
|
68
98
|
|
69
99
|
return media_files
|
70
100
|
|
71
|
-
def
|
101
|
+
async def get_all_media_files_disk(
|
102
|
+
self, storage_path: Path, device_id: int
|
103
|
+
) -> None:
|
104
|
+
"""Construct the media file table for disk storage."""
|
105
|
+
self.media_table.clear()
|
106
|
+
|
107
|
+
today_str = datetime.now().strftime("%Y%m%d")
|
108
|
+
base_path = storage_path / str(device_id) / today_str
|
109
|
+
|
110
|
+
for record_type in RecordType:
|
111
|
+
record_path = base_path / record_type
|
112
|
+
snapshot_path = record_path / "snapshot"
|
113
|
+
video_path = record_path / "video"
|
114
|
+
|
115
|
+
# Regex pattern to match valid filenames
|
116
|
+
valid_pattern = re.compile(rf"^(?:\d+_)?{device_id}_\d+\.(jpg|avi)$")
|
117
|
+
|
118
|
+
# Populate the media table with event_id from filenames
|
119
|
+
for subdir in [snapshot_path, video_path]:
|
120
|
+
|
121
|
+
# Ensure the directories exist
|
122
|
+
if not await aiofiles.os.path.exists(subdir):
|
123
|
+
_LOGGER.debug("Skip, path does not exist, %s", subdir)
|
124
|
+
continue
|
125
|
+
|
126
|
+
_LOGGER.debug("Scanning directory %s", subdir)
|
127
|
+
entries = await aiofiles.os.scandir(subdir)
|
128
|
+
for entry in entries:
|
129
|
+
if entry.is_file() and valid_pattern.match(entry.name):
|
130
|
+
_LOGGER.debug("Entries found: %s", entry.name)
|
131
|
+
event_id = Path(entry.name).stem
|
132
|
+
timestamp = self._extraire_timestamp(str(entry.name))
|
133
|
+
media_type_str = Path(entry.name).suffix.lstrip(".")
|
134
|
+
try:
|
135
|
+
media_type = MediaType(media_type_str)
|
136
|
+
except ValueError:
|
137
|
+
_LOGGER.warning("Unknown media type: %s", media_type_str)
|
138
|
+
continue
|
139
|
+
self.media_table.append(
|
140
|
+
MediaFile(
|
141
|
+
event_id=event_id,
|
142
|
+
device_id=device_id,
|
143
|
+
timestamp=int(timestamp),
|
144
|
+
event_type=RecordType(record_type),
|
145
|
+
full_file_path=subdir / entry.name,
|
146
|
+
media_type=MediaType(media_type),
|
147
|
+
)
|
148
|
+
)
|
149
|
+
|
150
|
+
@staticmethod
|
151
|
+
def _extraire_timestamp(nom_fichier: str) -> int:
|
152
|
+
match = re.search(r"_(\d+)\.[a-zA-Z0-9]+$", nom_fichier)
|
153
|
+
if match:
|
154
|
+
return int(match.group(1))
|
155
|
+
return 0
|
156
|
+
|
157
|
+
async def prepare_missing_files(
|
158
|
+
self,
|
159
|
+
media_cloud_list: list[MediaCloud],
|
160
|
+
dl_type: list[MediaType] | None = None,
|
161
|
+
event_type: list[RecordType] | None = None,
|
162
|
+
) -> list[MediaCloud]:
|
163
|
+
"""Compare MediaCloud objects with MediaFile objects and return a list of missing MediaCloud objects."""
|
164
|
+
missing_media = []
|
165
|
+
existing_event_ids = {media_file.event_id for media_file in self.media_table}
|
166
|
+
|
167
|
+
for media_cloud in media_cloud_list:
|
168
|
+
# Skip if event type is not in the filter
|
169
|
+
if event_type and media_cloud.event_type not in event_type:
|
170
|
+
continue
|
171
|
+
|
172
|
+
# Check if the media file is missing
|
173
|
+
is_missing = False
|
174
|
+
if media_cloud.event_id not in existing_event_ids:
|
175
|
+
is_missing = True # Both image and video are missing
|
176
|
+
else:
|
177
|
+
# Check for missing image
|
178
|
+
if (
|
179
|
+
media_cloud.image
|
180
|
+
and MediaType.IMAGE
|
181
|
+
in (dl_type or [MediaType.IMAGE, MediaType.VIDEO])
|
182
|
+
and not any(
|
183
|
+
media_file.event_id == media_cloud.event_id
|
184
|
+
and media_file.media_type == MediaType.IMAGE
|
185
|
+
for media_file in self.media_table
|
186
|
+
)
|
187
|
+
):
|
188
|
+
is_missing = True
|
189
|
+
# Check for missing video
|
190
|
+
if (
|
191
|
+
media_cloud.video
|
192
|
+
and MediaType.VIDEO
|
193
|
+
in (dl_type or [MediaType.IMAGE, MediaType.VIDEO])
|
194
|
+
and not any(
|
195
|
+
media_file.event_id == media_cloud.event_id
|
196
|
+
and media_file.media_type == MediaType.VIDEO
|
197
|
+
for media_file in self.media_table
|
198
|
+
)
|
199
|
+
):
|
200
|
+
is_missing = True
|
201
|
+
|
202
|
+
if is_missing:
|
203
|
+
missing_media.append(media_cloud)
|
204
|
+
|
205
|
+
return missing_media
|
206
|
+
|
207
|
+
def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
|
72
208
|
"""Process media files for a Feeder device."""
|
73
|
-
media_files: list[
|
209
|
+
media_files: list[MediaCloud] = []
|
74
210
|
records = feeder.device_records
|
75
211
|
|
76
|
-
device_id = (
|
77
|
-
feeder.device_nfo.device_id
|
78
|
-
if feeder.device_nfo and feeder.device_nfo.device_type
|
79
|
-
else None
|
80
|
-
)
|
81
|
-
if device_id is None:
|
82
|
-
raise ValueError("Missing device ID for feeder")
|
83
|
-
|
84
212
|
if not records:
|
213
|
+
_LOGGER.debug("No records found for %s", feeder.name)
|
85
214
|
return media_files
|
86
215
|
|
87
216
|
for record_type in RecordTypeLST:
|
88
217
|
record_list = getattr(records, record_type, [])
|
89
218
|
for record in record_list:
|
90
219
|
media_files.extend(
|
91
|
-
self._process_feeder_record(
|
92
|
-
record, RecordType(record_type), device_id
|
93
|
-
)
|
220
|
+
self._process_feeder_record(record, RecordType(record_type), feeder)
|
94
221
|
)
|
95
222
|
|
96
223
|
return media_files
|
97
224
|
|
98
225
|
def _process_feeder_record(
|
99
|
-
self, record, record_type: RecordType,
|
100
|
-
) -> list[
|
226
|
+
self, record, record_type: RecordType, device_obj: Feeder
|
227
|
+
) -> list[MediaCloud]:
|
101
228
|
"""Process individual feeder records."""
|
102
|
-
media_files: list[
|
103
|
-
user_id =
|
229
|
+
media_files: list[MediaCloud] = []
|
230
|
+
user_id = device_obj.user.id if device_obj.user else None
|
231
|
+
feeder_id = device_obj.device_nfo.device_id if device_obj.device_nfo else None
|
232
|
+
device_type = (
|
233
|
+
device_obj.device_nfo.device_type if device_obj.device_nfo else None
|
234
|
+
)
|
235
|
+
cp_sub = (
|
236
|
+
device_obj.cloud_product.subscribe if device_obj.cloud_product else None
|
237
|
+
)
|
238
|
+
|
239
|
+
if not feeder_id:
|
240
|
+
_LOGGER.error("Missing feeder_id for record")
|
241
|
+
return media_files
|
104
242
|
|
105
243
|
if not record.items:
|
106
244
|
return media_files
|
@@ -113,7 +251,8 @@ class MediaManager:
|
|
113
251
|
else "unknown"
|
114
252
|
)
|
115
253
|
if not item.event_id:
|
116
|
-
|
254
|
+
# Skip feed event in the future
|
255
|
+
_LOGGER.debug("Missing event_id for record item")
|
117
256
|
continue
|
118
257
|
if not user_id:
|
119
258
|
_LOGGER.error("Missing user_id for record item")
|
@@ -125,15 +264,17 @@ class MediaManager:
|
|
125
264
|
_LOGGER.error("Missing timestamp for record item")
|
126
265
|
continue
|
127
266
|
|
128
|
-
filepath = f"{
|
267
|
+
filepath = f"{feeder_id}/{date_str}/{record_type.name.lower()}"
|
129
268
|
media_files.append(
|
130
|
-
|
269
|
+
MediaCloud(
|
131
270
|
event_id=item.event_id,
|
132
271
|
event_type=record_type,
|
133
|
-
device_id=
|
272
|
+
device_id=feeder_id,
|
134
273
|
user_id=user_id,
|
135
274
|
image=item.preview,
|
136
|
-
video=self.construct_video_url(
|
275
|
+
video=self.construct_video_url(
|
276
|
+
device_type, item.media_api, user_id, cp_sub
|
277
|
+
),
|
137
278
|
filepath=filepath,
|
138
279
|
aes_key=item.aes_key,
|
139
280
|
timestamp=self._get_timestamp(item),
|
@@ -141,10 +282,26 @@ class MediaManager:
|
|
141
282
|
)
|
142
283
|
return media_files
|
143
284
|
|
144
|
-
def _process_litter(self, litter: Litter) -> list[
|
285
|
+
def _process_litter(self, litter: Litter) -> list[MediaCloud]:
|
145
286
|
"""Process media files for a Litter device."""
|
146
|
-
media_files: list[
|
287
|
+
media_files: list[MediaCloud] = []
|
147
288
|
records = litter.device_records
|
289
|
+
litter_id = litter.device_nfo.device_id if litter.device_nfo else None
|
290
|
+
device_type = litter.device_nfo.device_type if litter.device_nfo else None
|
291
|
+
user_id = litter.user.id if litter.user else None
|
292
|
+
cp_sub = litter.cloud_product.subscribe if litter.cloud_product else None
|
293
|
+
|
294
|
+
if not litter_id:
|
295
|
+
_LOGGER.error("Missing litter_id for record")
|
296
|
+
return media_files
|
297
|
+
|
298
|
+
if not device_type:
|
299
|
+
_LOGGER.error("Missing device_type for record")
|
300
|
+
return media_files
|
301
|
+
|
302
|
+
if not user_id:
|
303
|
+
_LOGGER.error("Missing user_id for record")
|
304
|
+
return media_files
|
148
305
|
|
149
306
|
if not records:
|
150
307
|
return media_files
|
@@ -159,12 +316,6 @@ class MediaManager:
|
|
159
316
|
if not record.event_id:
|
160
317
|
_LOGGER.error("Missing event_id for record item")
|
161
318
|
continue
|
162
|
-
if not record.device_id:
|
163
|
-
_LOGGER.error("Missing event_id for record item")
|
164
|
-
continue
|
165
|
-
if not record.user_id:
|
166
|
-
_LOGGER.error("Missing user_id for record item")
|
167
|
-
continue
|
168
319
|
if not record.aes_key:
|
169
320
|
_LOGGER.error("Missing aes_key for record item")
|
170
321
|
continue
|
@@ -172,15 +323,17 @@ class MediaManager:
|
|
172
323
|
_LOGGER.error("Missing timestamp for record item")
|
173
324
|
continue
|
174
325
|
|
175
|
-
filepath = f"{
|
326
|
+
filepath = f"{litter_id}/{date_str}/toileting"
|
176
327
|
media_files.append(
|
177
|
-
|
328
|
+
MediaCloud(
|
178
329
|
event_id=record.event_id,
|
179
330
|
event_type=RecordType.TOILETING,
|
180
|
-
device_id=
|
181
|
-
user_id=
|
331
|
+
device_id=litter_id,
|
332
|
+
user_id=user_id,
|
182
333
|
image=record.preview,
|
183
|
-
video=self.construct_video_url(
|
334
|
+
video=self.construct_video_url(
|
335
|
+
device_type, record.media_api, user_id, cp_sub
|
336
|
+
),
|
184
337
|
filepath=filepath,
|
185
338
|
aes_key=record.aes_key,
|
186
339
|
timestamp=record.timestamp,
|
@@ -189,13 +342,15 @@ class MediaManager:
|
|
189
342
|
return media_files
|
190
343
|
|
191
344
|
@staticmethod
|
192
|
-
def construct_video_url(
|
345
|
+
def construct_video_url(
|
346
|
+
device_type: str | None, media_url: str | None, user_id: int, cp_sub: int | None
|
347
|
+
) -> str | None:
|
193
348
|
"""Construct the video URL."""
|
194
|
-
if not media_url or not user_id:
|
349
|
+
if not media_url or not user_id or cp_sub != 1:
|
195
350
|
return None
|
196
351
|
params = parse_qs(urlparse(media_url).query)
|
197
352
|
param_dict = {k: v[0] for k, v in params.items()}
|
198
|
-
return f"/
|
353
|
+
return f"/{device_type}/cloud/video?startTime={param_dict.get("startTime")}&deviceId={param_dict.get("deviceId")}&userId={user_id}&mark={param_dict.get("mark")}"
|
199
354
|
|
200
355
|
@staticmethod
|
201
356
|
def _get_timestamp(item) -> int:
|
@@ -218,7 +373,7 @@ class MediaManager:
|
|
218
373
|
class DownloadDecryptMedia:
|
219
374
|
"""Class to download and decrypt media files from PetKit devices."""
|
220
375
|
|
221
|
-
file_data:
|
376
|
+
file_data: MediaCloud
|
222
377
|
|
223
378
|
def __init__(self, download_path: Path, client: PetKitClient):
|
224
379
|
"""Initialize the class."""
|
@@ -234,11 +389,14 @@ class DownloadDecryptMedia:
|
|
234
389
|
subdir = "video"
|
235
390
|
return Path(self.download_path / self.file_data.filepath / subdir / file_name)
|
236
391
|
|
237
|
-
async def download_file(
|
392
|
+
async def download_file(
|
393
|
+
self, file_data: MediaCloud, file_type: MediaType | None
|
394
|
+
) -> None:
|
238
395
|
"""Get image and video file"""
|
396
|
+
_LOGGER.debug("Downloading media file %s", file_data.event_id)
|
239
397
|
self.file_data = file_data
|
240
398
|
|
241
|
-
if self.file_data.image:
|
399
|
+
if self.file_data.image and (file_type is None or file_type == MediaType.IMAGE):
|
242
400
|
# Download image file
|
243
401
|
await self._get_file(
|
244
402
|
self.file_data.image,
|
@@ -246,7 +404,7 @@ class DownloadDecryptMedia:
|
|
246
404
|
f"{self.file_data.event_id}.jpg",
|
247
405
|
)
|
248
406
|
|
249
|
-
if self.file_data.video:
|
407
|
+
if self.file_data.video and (file_type is None or file_type == MediaType.VIDEO):
|
250
408
|
# Download video file
|
251
409
|
await self._get_video_m3u8()
|
252
410
|
|
@@ -254,7 +412,18 @@ class DownloadDecryptMedia:
|
|
254
412
|
"""Iterate through m3u8 file and return all the ts file urls"""
|
255
413
|
aes_key, iv_key, segments_lst = await self._get_m3u8_segments()
|
256
414
|
|
415
|
+
if aes_key is None or iv_key is None or not segments_lst:
|
416
|
+
_LOGGER.debug("Can't download video file %s", self.file_data.event_id)
|
417
|
+
return
|
418
|
+
|
257
419
|
segment_files = []
|
420
|
+
|
421
|
+
if len(segments_lst) == 1:
|
422
|
+
await self._get_file(
|
423
|
+
segments_lst[0], aes_key, f"{self.file_data.event_id}.avi"
|
424
|
+
)
|
425
|
+
return
|
426
|
+
|
258
427
|
for index, segment in enumerate(segments_lst, start=1):
|
259
428
|
segment_file = await self._get_file(
|
260
429
|
segment, aes_key, f"{index}_{self.file_data.event_id}.avi"
|
@@ -264,13 +433,15 @@ class DownloadDecryptMedia:
|
|
264
433
|
await self.get_fpath(f"{index}_{self.file_data.event_id}.avi")
|
265
434
|
)
|
266
435
|
|
267
|
-
if
|
268
|
-
_LOGGER.
|
269
|
-
await self._concat_segments(segment_files, f"{self.file_data.event_id}.avi")
|
436
|
+
if not segment_files:
|
437
|
+
_LOGGER.error("No segment files found")
|
270
438
|
elif len(segment_files) == 1:
|
271
439
|
_LOGGER.debug("Single file segment, no need to concatenate")
|
440
|
+
elif len(segment_files) > 1:
|
441
|
+
_LOGGER.debug("Concatenating segments %s", len(segment_files))
|
442
|
+
await self._concat_segments(segment_files, f"{self.file_data.event_id}.avi")
|
272
443
|
|
273
|
-
async def _get_m3u8_segments(self) -> tuple[str, str, list[str]]:
|
444
|
+
async def _get_m3u8_segments(self) -> tuple[str | None, str | None, list[str]]:
|
274
445
|
"""Extract the segments from a m3u8 file.
|
275
446
|
:return: Tuple of AES key, IV key, and list of segment URLs
|
276
447
|
"""
|
@@ -278,6 +449,9 @@ class DownloadDecryptMedia:
|
|
278
449
|
raise ValueError("Missing video URL")
|
279
450
|
video_data = await self.client.get_cloud_video(self.file_data.video)
|
280
451
|
|
452
|
+
if not video_data:
|
453
|
+
return None, None, []
|
454
|
+
|
281
455
|
media_api = video_data.get("mediaApi", None)
|
282
456
|
if not media_api:
|
283
457
|
_LOGGER.error("Missing mediaApi in video data")
|
@@ -371,6 +545,7 @@ class DownloadDecryptMedia:
|
|
371
545
|
_LOGGER.debug(
|
372
546
|
"Output file already exists: %s, skipping concatenation.", output_file
|
373
547
|
)
|
548
|
+
await self._delete_segments(ts_files)
|
374
549
|
return
|
375
550
|
|
376
551
|
# Build the argument for `ffmpeg` with the files formatted for the command line
|
@@ -0,0 +1,19 @@
|
|
1
|
+
pypetkitapi/__init__.py,sha256=mdImqvI5uFp8gupd6pf9IACfLFVEXEfEO_8PitRyzUo,2107
|
2
|
+
pypetkitapi/bluetooth.py,sha256=u_xGp701WnrroTOt_KuIVUCZ3kRQ7BJeoMR8b9RpJ54,7176
|
3
|
+
pypetkitapi/client.py,sha256=6HdTx4Bj8zwHzSzvQz1acdRzGLCX8nETsTeIv-BVc9M,26921
|
4
|
+
pypetkitapi/command.py,sha256=cMCUutZCQo9Ddvjl_FYR5UjU_CqFz1iyetMznYwjpzM,7500
|
5
|
+
pypetkitapi/const.py,sha256=US5QihmBYvlm8hIHX0PORPUnMmDW3nmLzwLWTepkkGg,4609
|
6
|
+
pypetkitapi/containers.py,sha256=F_uyDBD0a5QD4s_ArjYiKTAAg1XHYBvmV_lEnO9RQ-U,4786
|
7
|
+
pypetkitapi/exceptions.py,sha256=4BXUyYXLfZjNxdnOGJPjyE9ASIl7JmQphjws87jvHtE,1631
|
8
|
+
pypetkitapi/feeder_container.py,sha256=PhidWd5WpsZqtdKZy60PzE67YXgQfApjm8CqvMCHK3U,14743
|
9
|
+
pypetkitapi/litter_container.py,sha256=KWvHNAOJ6hDSeJ_55tqtzY9GxHtd9gAntPkbnVbdb-I,19275
|
10
|
+
pypetkitapi/media.py,sha256=AWkFMNxDOk2CUE0XJLTZZIRDMvBBZTcR1lLKCtKB7jM,22339
|
11
|
+
pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
|
12
|
+
pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
+
pypetkitapi/schedule_container.py,sha256=OjLAY6FY-g14JNJJnYMNFV5ZtdkjUzNBit1VUiiZKnQ,2053
|
14
|
+
pypetkitapi/utils.py,sha256=z7325kcJQUburnF28HSXrJMvY_gY9007K73Zwxp-4DQ,743
|
15
|
+
pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
|
16
|
+
pypetkitapi-1.10.2.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
|
17
|
+
pypetkitapi-1.10.2.dist-info/METADATA,sha256=OdpyJTrBznYfoFqdoLEYFVJ_Z4QVjaKoZtTiQNdTTmo,6256
|
18
|
+
pypetkitapi-1.10.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
19
|
+
pypetkitapi-1.10.2.dist-info/RECORD,,
|
@@ -1,18 +0,0 @@
|
|
1
|
-
pypetkitapi/__init__.py,sha256=kp58MpP6LwBcz2o1IpOjgN7c8bm4qtH361l4A0tWA4M,1607
|
2
|
-
pypetkitapi/client.py,sha256=0vM-fsu_cGE2_XKd8kJFQdJTXyBEGvSt3zHjFU13dns,32169
|
3
|
-
pypetkitapi/command.py,sha256=cMCUutZCQo9Ddvjl_FYR5UjU_CqFz1iyetMznYwjpzM,7500
|
4
|
-
pypetkitapi/const.py,sha256=g_oz73Emiw7nMYi3ANaUkUVLNtdacST7weyui5FviYg,4516
|
5
|
-
pypetkitapi/containers.py,sha256=oJR22ZruMr-0IRgiucdnj_nutOH59MKvmaFTwLJNiJI,4635
|
6
|
-
pypetkitapi/exceptions.py,sha256=cBLj2kP70yd6rfWnOXTCXo1a2TXca8QtxiRMa1UrttU,1644
|
7
|
-
pypetkitapi/feeder_container.py,sha256=ZGJhgqP-gjTFB2q91XoyZQ_G1S5cAY37JoqqHbzoanU,14640
|
8
|
-
pypetkitapi/litter_container.py,sha256=-z2BtdtRg8RyLJzJYY3AIACs9GGZ0C64hVhW4do6yQo,19172
|
9
|
-
pypetkitapi/media.py,sha256=a-ZhM7khu50Htn1jYNnsQNmwhpG0CnT_QW752Is-J4Q,15611
|
10
|
-
pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
|
11
|
-
pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
-
pypetkitapi/schedule_container.py,sha256=OjLAY6FY-g14JNJJnYMNFV5ZtdkjUzNBit1VUiiZKnQ,2053
|
13
|
-
pypetkitapi/utils.py,sha256=z7325kcJQUburnF28HSXrJMvY_gY9007K73Zwxp-4DQ,743
|
14
|
-
pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
|
15
|
-
pypetkitapi-1.9.4.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
|
16
|
-
pypetkitapi-1.9.4.dist-info/METADATA,sha256=1OcbAB1WBj1uCCVYeqLSOPY7Rj_uVBeivBAPm7sTQNk,6255
|
17
|
-
pypetkitapi-1.9.4.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
18
|
-
pypetkitapi-1.9.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|