pypetkitapi 1.10.3__py3-none-any.whl → 1.11.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 +1 -1
- pypetkitapi/bluetooth.py +31 -11
- pypetkitapi/client.py +7 -3
- pypetkitapi/const.py +1 -1
- pypetkitapi/media.py +154 -125
- {pypetkitapi-1.10.3.dist-info → pypetkitapi-1.11.2.dist-info}/METADATA +12 -10
- {pypetkitapi-1.10.3.dist-info → pypetkitapi-1.11.2.dist-info}/RECORD +9 -9
- {pypetkitapi-1.10.3.dist-info → pypetkitapi-1.11.2.dist-info}/LICENSE +0 -0
- {pypetkitapi-1.10.3.dist-info → pypetkitapi-1.11.2.dist-info}/WHEEL +0 -0
pypetkitapi/__init__.py
CHANGED
pypetkitapi/bluetooth.py
CHANGED
@@ -77,10 +77,10 @@ class BluetoothManager:
|
|
77
77
|
_LOGGER.info("Opening BLE connection to fountain %s", fountain_id)
|
78
78
|
water_fountain = await self._get_fountain_instance(fountain_id)
|
79
79
|
if await self.check_relay_availability(fountain_id) is False:
|
80
|
-
_LOGGER.error("BLE relay not available.")
|
80
|
+
_LOGGER.error("BLE relay not available (id: %s).", fountain_id)
|
81
81
|
return False
|
82
82
|
if water_fountain.is_connected is True:
|
83
|
-
_LOGGER.error("BLE connection already established
|
83
|
+
_LOGGER.error("BLE connection already established (id %s)", fountain_id)
|
84
84
|
return True
|
85
85
|
response = await self.client.req.request(
|
86
86
|
method=HTTPMethod.POST,
|
@@ -89,11 +89,16 @@ class BluetoothManager:
|
|
89
89
|
headers=await self.client.get_session_id(),
|
90
90
|
)
|
91
91
|
if response != {"state": 1}:
|
92
|
-
_LOGGER.error("
|
92
|
+
_LOGGER.error("Unable to open a BLE connection (id %s)", fountain_id)
|
93
93
|
water_fountain.is_connected = False
|
94
94
|
return False
|
95
95
|
for attempt in range(BLE_CONNECT_ATTEMPT):
|
96
|
-
_LOGGER.
|
96
|
+
_LOGGER.debug(
|
97
|
+
"BLE connection... %s/%s (id %s)",
|
98
|
+
attempt,
|
99
|
+
BLE_CONNECT_ATTEMPT,
|
100
|
+
fountain_id,
|
101
|
+
)
|
97
102
|
response = await self.client.req.request(
|
98
103
|
method=HTTPMethod.POST,
|
99
104
|
url=PetkitEndpoint.BLE_POLL,
|
@@ -101,14 +106,20 @@ class BluetoothManager:
|
|
101
106
|
headers=await self.client.get_session_id(),
|
102
107
|
)
|
103
108
|
if response == 1:
|
104
|
-
_LOGGER.info(
|
109
|
+
_LOGGER.info(
|
110
|
+
"BLE connection established successfully (id %s)", fountain_id
|
111
|
+
)
|
105
112
|
water_fountain.is_connected = True
|
106
113
|
water_fountain.last_ble_poll = datetime.now().strftime(
|
107
114
|
"%Y-%m-%dT%H:%M:%S.%f"
|
108
115
|
)
|
109
116
|
return True
|
110
117
|
await asyncio.sleep(4)
|
111
|
-
_LOGGER.error(
|
118
|
+
_LOGGER.error(
|
119
|
+
"Failed to establish BLE connection after %s attempts (id %s)",
|
120
|
+
BLE_CONNECT_ATTEMPT,
|
121
|
+
fountain_id,
|
122
|
+
)
|
112
123
|
water_fountain.is_connected = False
|
113
124
|
return False
|
114
125
|
|
@@ -116,13 +127,20 @@ class BluetoothManager:
|
|
116
127
|
"""Close the BLE connection to the given fountain_id."""
|
117
128
|
_LOGGER.info("Closing BLE connection to fountain %s", fountain_id)
|
118
129
|
water_fountain = await self._get_fountain_instance(fountain_id)
|
130
|
+
|
131
|
+
if water_fountain.is_connected is False:
|
132
|
+
_LOGGER.error(
|
133
|
+
"BLE connection not established. Cannot close (id %s)", fountain_id
|
134
|
+
)
|
135
|
+
return
|
136
|
+
|
119
137
|
await self.client.req.request(
|
120
138
|
method=HTTPMethod.POST,
|
121
139
|
url=PetkitEndpoint.BLE_CANCEL,
|
122
140
|
data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
|
123
141
|
headers=await self.client.get_session_id(),
|
124
142
|
)
|
125
|
-
_LOGGER.info("BLE connection closed successfully
|
143
|
+
_LOGGER.info("BLE connection closed successfully (id %s)", fountain_id)
|
126
144
|
|
127
145
|
async def get_ble_cmd_data(
|
128
146
|
self, fountain_command: list, counter: int
|
@@ -146,11 +164,13 @@ class BluetoothManager:
|
|
146
164
|
_LOGGER.info("Sending BLE command to fountain %s", fountain_id)
|
147
165
|
water_fountain = await self._get_fountain_instance(fountain_id)
|
148
166
|
if water_fountain.is_connected is False:
|
149
|
-
_LOGGER.error("BLE connection not established
|
167
|
+
_LOGGER.error("BLE connection not established (id %s)", fountain_id)
|
150
168
|
return False
|
151
169
|
command_data = FOUNTAIN_COMMAND.get(command)
|
152
170
|
if command_data is None:
|
153
|
-
_LOGGER.error(
|
171
|
+
_LOGGER.error(
|
172
|
+
"BLE fountain command '%s' not found (id %s)", command, fountain_id
|
173
|
+
)
|
154
174
|
return False
|
155
175
|
cmd_code, cmd_data = await self.get_ble_cmd_data(
|
156
176
|
list(command_data), water_fountain.ble_counter
|
@@ -168,7 +188,7 @@ class BluetoothManager:
|
|
168
188
|
headers=await self.client.get_session_id(),
|
169
189
|
)
|
170
190
|
if response != 1:
|
171
|
-
_LOGGER.error("Failed to send BLE command
|
191
|
+
_LOGGER.error("Failed to send BLE command (id %s)", fountain_id)
|
172
192
|
return False
|
173
|
-
_LOGGER.info("BLE command sent successfully
|
193
|
+
_LOGGER.info("BLE command sent successfully (id %s)", fountain_id)
|
174
194
|
return True
|
pypetkitapi/client.py
CHANGED
@@ -101,8 +101,12 @@ class PetKitClient:
|
|
101
101
|
self.bluetooth_manager = BluetoothManager(self)
|
102
102
|
from pypetkitapi import MediaManager
|
103
103
|
|
104
|
+
from . import __version__
|
105
|
+
|
104
106
|
self.media_manager = MediaManager()
|
105
107
|
|
108
|
+
_LOGGER.debug("PetKit Client initialized (version %s)", __version__)
|
109
|
+
|
106
110
|
async def _get_base_url(self) -> None:
|
107
111
|
"""Get the list of API servers, filter by region, and return the matching server."""
|
108
112
|
_LOGGER.debug("Getting API server list")
|
@@ -337,7 +341,7 @@ class PetKitClient:
|
|
337
341
|
_LOGGER.debug("Fetching media data for device: %s", device.device_id)
|
338
342
|
|
339
343
|
device_entity = self.petkit_entities[device.device_id]
|
340
|
-
device_entity.medias = await self.media_manager.
|
344
|
+
device_entity.medias = await self.media_manager.gather_all_media_from_cloud(
|
341
345
|
[device_entity]
|
342
346
|
)
|
343
347
|
|
@@ -518,8 +522,8 @@ class PetKitClient:
|
|
518
522
|
headers=await self.get_session_id(),
|
519
523
|
)
|
520
524
|
if not isinstance(response, list) or not response:
|
521
|
-
_LOGGER.
|
522
|
-
"No video data found from cloud, looks like you don't have a care+ subscription ?"
|
525
|
+
_LOGGER.warning(
|
526
|
+
"No video data found from cloud, looks like you don't have a care+ subscription ? or video is not uploaded yet."
|
523
527
|
)
|
524
528
|
return None
|
525
529
|
return response[0]
|
pypetkitapi/const.py
CHANGED
pypetkitapi/media.py
CHANGED
@@ -18,13 +18,14 @@ import aiohttp
|
|
18
18
|
from Crypto.Cipher import AES
|
19
19
|
from Crypto.Util.Padding import unpad
|
20
20
|
|
21
|
-
from pypetkitapi import Feeder, Litter, PetKitClient, RecordType
|
21
|
+
from pypetkitapi import Feeder, Litter, PetKitClient, RecordsItems, RecordType
|
22
22
|
from pypetkitapi.const import (
|
23
23
|
FEEDER_WITH_CAMERA,
|
24
24
|
LITTER_WITH_CAMERA,
|
25
25
|
MediaType,
|
26
26
|
RecordTypeLST,
|
27
27
|
)
|
28
|
+
from pypetkitapi.litter_container import LitterRecord
|
28
29
|
|
29
30
|
_LOGGER = logging.getLogger(__name__)
|
30
31
|
|
@@ -65,7 +66,7 @@ class MediaManager:
|
|
65
66
|
|
66
67
|
media_table: list[MediaFile] = []
|
67
68
|
|
68
|
-
async def
|
69
|
+
async def gather_all_media_from_cloud(
|
69
70
|
self, devices: list[Feeder | Litter]
|
70
71
|
) -> list[MediaCloud]:
|
71
72
|
"""Get all media files from all devices and return a list of MediaCloud.
|
@@ -81,7 +82,7 @@ class MediaManager:
|
|
81
82
|
device.device_nfo
|
82
83
|
and device.device_nfo.device_type in FEEDER_WITH_CAMERA
|
83
84
|
):
|
84
|
-
media_files.extend(self._process_feeder(device))
|
85
|
+
media_files.extend(await self._process_feeder(device))
|
85
86
|
else:
|
86
87
|
_LOGGER.debug(
|
87
88
|
"Feeder %s does not support media file extraction",
|
@@ -92,7 +93,7 @@ class MediaManager:
|
|
92
93
|
device.device_nfo
|
93
94
|
and device.device_nfo.device_type in LITTER_WITH_CAMERA
|
94
95
|
):
|
95
|
-
media_files.extend(self._process_litter(device))
|
96
|
+
media_files.extend(await self._process_litter(device))
|
96
97
|
else:
|
97
98
|
_LOGGER.debug(
|
98
99
|
"Litter %s does not support media file extraction",
|
@@ -101,9 +102,9 @@ class MediaManager:
|
|
101
102
|
|
102
103
|
return media_files
|
103
104
|
|
104
|
-
async def
|
105
|
+
async def gather_all_media_from_disk(
|
105
106
|
self, storage_path: Path, device_id: int
|
106
|
-
) ->
|
107
|
+
) -> list[MediaFile]:
|
107
108
|
"""Construct the media file table for disk storage.
|
108
109
|
:param storage_path: Path to the storage directory
|
109
110
|
:param device_id: Device ID
|
@@ -113,29 +114,33 @@ class MediaManager:
|
|
113
114
|
today_str = datetime.now().strftime("%Y%m%d")
|
114
115
|
base_path = storage_path / str(device_id) / today_str
|
115
116
|
|
117
|
+
_LOGGER.debug("Populating files from directory %s", base_path)
|
118
|
+
|
116
119
|
for record_type in RecordType:
|
117
120
|
record_path = base_path / record_type
|
118
121
|
snapshot_path = record_path / "snapshot"
|
119
122
|
video_path = record_path / "video"
|
120
123
|
|
121
124
|
# Regex pattern to match valid filenames
|
122
|
-
valid_pattern = re.compile(
|
125
|
+
valid_pattern = re.compile(
|
126
|
+
rf"^{device_id}_\d+\.({MediaType.IMAGE}|{MediaType.VIDEO})$"
|
127
|
+
)
|
123
128
|
|
124
129
|
# Populate the media table with event_id from filenames
|
125
130
|
for subdir in [snapshot_path, video_path]:
|
126
131
|
|
127
132
|
# Ensure the directories exist
|
128
133
|
if not await aiofiles.os.path.exists(subdir):
|
129
|
-
_LOGGER.debug("
|
134
|
+
_LOGGER.debug("Path does not exist, skip : %s", subdir)
|
130
135
|
continue
|
131
136
|
|
132
|
-
_LOGGER.debug("Scanning
|
137
|
+
_LOGGER.debug("Scanning files into : %s", subdir)
|
133
138
|
entries = await aiofiles.os.scandir(subdir)
|
134
139
|
for entry in entries:
|
135
140
|
if entry.is_file() and valid_pattern.match(entry.name):
|
136
|
-
_LOGGER.debug("
|
141
|
+
_LOGGER.debug("Media found: %s", entry.name)
|
137
142
|
event_id = Path(entry.name).stem
|
138
|
-
timestamp =
|
143
|
+
timestamp = Path(entry.name).stem.split("_")[1]
|
139
144
|
media_type_str = Path(entry.name).suffix.lstrip(".")
|
140
145
|
try:
|
141
146
|
media_type = MediaType(media_type_str)
|
@@ -152,19 +157,10 @@ class MediaManager:
|
|
152
157
|
media_type=MediaType(media_type),
|
153
158
|
)
|
154
159
|
)
|
160
|
+
_LOGGER.debug("OK, Media table populated with %s files", len(self.media_table))
|
161
|
+
return self.media_table
|
155
162
|
|
156
|
-
|
157
|
-
def _extract_timestamp(file_name: str) -> int:
|
158
|
-
"""Extract timestamp from a filename.
|
159
|
-
:param file_name: Filename
|
160
|
-
:return: Timestamp
|
161
|
-
"""
|
162
|
-
match = re.search(r"_(\d+)\.[a-zA-Z0-9]+$", file_name)
|
163
|
-
if match:
|
164
|
-
return int(match.group(1))
|
165
|
-
return 0
|
166
|
-
|
167
|
-
async def prepare_missing_files(
|
163
|
+
async def list_missing_files(
|
168
164
|
self,
|
169
165
|
media_cloud_list: list[MediaCloud],
|
170
166
|
dl_type: list[MediaType] | None = None,
|
@@ -176,17 +172,29 @@ class MediaManager:
|
|
176
172
|
:param event_type: List of event types to filter
|
177
173
|
:return: List of missing MediaCloud objects
|
178
174
|
"""
|
179
|
-
missing_media = []
|
175
|
+
missing_media: list[MediaCloud] = []
|
180
176
|
existing_event_ids = {media_file.event_id for media_file in self.media_table}
|
181
177
|
|
178
|
+
if dl_type is None or event_type is None or not dl_type or not event_type:
|
179
|
+
_LOGGER.debug(
|
180
|
+
"Missing dl_type or event_type parameters, no media file will be downloaded"
|
181
|
+
)
|
182
|
+
return missing_media
|
183
|
+
|
182
184
|
for media_cloud in media_cloud_list:
|
183
|
-
# Skip if event type is not in the filter
|
185
|
+
# Skip if event type is not in the event filter
|
184
186
|
if event_type and media_cloud.event_type not in event_type:
|
187
|
+
_LOGGER.debug(
|
188
|
+
"Skipping event type %s, is filtered", media_cloud.event_type
|
189
|
+
)
|
185
190
|
continue
|
186
191
|
|
187
192
|
# Check if the media file is missing
|
188
193
|
is_missing = False
|
189
194
|
if media_cloud.event_id not in existing_event_ids:
|
195
|
+
_LOGGER.debug(
|
196
|
+
"Media file IMG/VIDEO id : %s are missing", media_cloud.event_id
|
197
|
+
)
|
190
198
|
is_missing = True # Both image and video are missing
|
191
199
|
else:
|
192
200
|
# Check for missing image
|
@@ -200,6 +208,9 @@ class MediaManager:
|
|
200
208
|
for media_file in self.media_table
|
201
209
|
)
|
202
210
|
):
|
211
|
+
_LOGGER.debug(
|
212
|
+
"Media file IMG id : %s is missing", media_cloud.event_id
|
213
|
+
)
|
203
214
|
is_missing = True
|
204
215
|
# Check for missing video
|
205
216
|
if (
|
@@ -212,14 +223,16 @@ class MediaManager:
|
|
212
223
|
for media_file in self.media_table
|
213
224
|
)
|
214
225
|
):
|
226
|
+
_LOGGER.debug(
|
227
|
+
"Media file VIDEO id : %s is missing", media_cloud.event_id
|
228
|
+
)
|
215
229
|
is_missing = True
|
216
230
|
|
217
231
|
if is_missing:
|
218
232
|
missing_media.append(media_cloud)
|
219
|
-
|
220
233
|
return missing_media
|
221
234
|
|
222
|
-
def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
|
235
|
+
async def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
|
223
236
|
"""Process media files for a Feeder device.
|
224
237
|
:param feeder: Feeder device object
|
225
238
|
:return: List of MediaCloud objects for the device
|
@@ -235,12 +248,14 @@ class MediaManager:
|
|
235
248
|
record_list = getattr(records, record_type, [])
|
236
249
|
for record in record_list:
|
237
250
|
media_files.extend(
|
238
|
-
self._process_feeder_record(
|
251
|
+
await self._process_feeder_record(
|
252
|
+
record, RecordType(record_type), feeder
|
253
|
+
)
|
239
254
|
)
|
240
255
|
|
241
256
|
return media_files
|
242
257
|
|
243
|
-
def _process_feeder_record(
|
258
|
+
async def _process_feeder_record(
|
244
259
|
self, record, record_type: RecordType, device_obj: Feeder
|
245
260
|
) -> list[MediaCloud]:
|
246
261
|
"""Process individual feeder records.
|
@@ -260,33 +275,34 @@ class MediaManager:
|
|
260
275
|
)
|
261
276
|
|
262
277
|
if not feeder_id:
|
263
|
-
_LOGGER.
|
278
|
+
_LOGGER.warning("Missing feeder_id for record")
|
264
279
|
return media_files
|
265
280
|
|
266
281
|
if not record.items:
|
267
282
|
return media_files
|
268
283
|
|
269
284
|
for item in record.items:
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
285
|
+
if not isinstance(item, RecordsItems):
|
286
|
+
_LOGGER.debug("Record is empty")
|
287
|
+
continue
|
288
|
+
timestamp = await self._get_timestamp(item)
|
289
|
+
if timestamp is None:
|
290
|
+
_LOGGER.warning("Missing timestamp for record item")
|
291
|
+
continue
|
276
292
|
if not item.event_id:
|
277
293
|
# Skip feed event in the future
|
278
|
-
_LOGGER.debug(
|
294
|
+
_LOGGER.debug(
|
295
|
+
"Missing event_id for record item (probably a feed event not yet completed)"
|
296
|
+
)
|
279
297
|
continue
|
280
298
|
if not user_id:
|
281
|
-
_LOGGER.
|
299
|
+
_LOGGER.warning("Missing user_id for record item")
|
282
300
|
continue
|
283
301
|
if not item.aes_key:
|
284
|
-
_LOGGER.
|
285
|
-
continue
|
286
|
-
if timestamp is None:
|
287
|
-
_LOGGER.error("Missing timestamp for record item")
|
302
|
+
_LOGGER.warning("Missing aes_key for record item")
|
288
303
|
continue
|
289
304
|
|
305
|
+
date_str = await self.get_date_from_ts(timestamp)
|
290
306
|
filepath = f"{feeder_id}/{date_str}/{record_type.name.lower()}"
|
291
307
|
media_files.append(
|
292
308
|
MediaCloud(
|
@@ -295,17 +311,17 @@ class MediaManager:
|
|
295
311
|
device_id=feeder_id,
|
296
312
|
user_id=user_id,
|
297
313
|
image=item.preview,
|
298
|
-
video=self.construct_video_url(
|
314
|
+
video=await self.construct_video_url(
|
299
315
|
device_type, item.media_api, user_id, cp_sub
|
300
316
|
),
|
301
317
|
filepath=filepath,
|
302
318
|
aes_key=item.aes_key,
|
303
|
-
timestamp=
|
319
|
+
timestamp=timestamp,
|
304
320
|
)
|
305
321
|
)
|
306
322
|
return media_files
|
307
323
|
|
308
|
-
def _process_litter(self, litter: Litter) -> list[MediaCloud]:
|
324
|
+
async def _process_litter(self, litter: Litter) -> list[MediaCloud]:
|
309
325
|
"""Process media files for a Litter device.
|
310
326
|
:param litter: Litter device object
|
311
327
|
:return: List of MediaCloud objects for the device
|
@@ -318,37 +334,37 @@ class MediaManager:
|
|
318
334
|
cp_sub = litter.cloud_product.subscribe if litter.cloud_product else None
|
319
335
|
|
320
336
|
if not litter_id:
|
321
|
-
_LOGGER.
|
337
|
+
_LOGGER.warning("Missing litter_id for record")
|
322
338
|
return media_files
|
323
339
|
|
324
340
|
if not device_type:
|
325
|
-
_LOGGER.
|
341
|
+
_LOGGER.warning("Missing device_type for record")
|
326
342
|
return media_files
|
327
343
|
|
328
344
|
if not user_id:
|
329
|
-
_LOGGER.
|
345
|
+
_LOGGER.warning("Missing user_id for record")
|
330
346
|
return media_files
|
331
347
|
|
332
348
|
if not records:
|
333
349
|
return media_files
|
334
350
|
|
335
351
|
for record in records:
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
if timestamp
|
340
|
-
else "unknown"
|
341
|
-
)
|
352
|
+
if not isinstance(record, LitterRecord):
|
353
|
+
_LOGGER.debug("Record is empty")
|
354
|
+
continue
|
342
355
|
if not record.event_id:
|
343
|
-
_LOGGER.
|
356
|
+
_LOGGER.debug("Missing event_id for record item")
|
344
357
|
continue
|
345
358
|
if not record.aes_key:
|
346
|
-
_LOGGER.
|
359
|
+
_LOGGER.debug("Missing aes_key for record item")
|
347
360
|
continue
|
348
361
|
if record.timestamp is None:
|
349
|
-
_LOGGER.
|
362
|
+
_LOGGER.debug("Missing timestamp for record item")
|
350
363
|
continue
|
351
364
|
|
365
|
+
timestamp = record.timestamp or None
|
366
|
+
date_str = await self.get_date_from_ts(timestamp)
|
367
|
+
|
352
368
|
filepath = f"{litter_id}/{date_str}/toileting"
|
353
369
|
media_files.append(
|
354
370
|
MediaCloud(
|
@@ -357,7 +373,7 @@ class MediaManager:
|
|
357
373
|
device_id=litter_id,
|
358
374
|
user_id=user_id,
|
359
375
|
image=record.preview,
|
360
|
-
video=self.construct_video_url(
|
376
|
+
video=await self.construct_video_url(
|
361
377
|
device_type, record.media_api, user_id, cp_sub
|
362
378
|
),
|
363
379
|
filepath=filepath,
|
@@ -368,7 +384,17 @@ class MediaManager:
|
|
368
384
|
return media_files
|
369
385
|
|
370
386
|
@staticmethod
|
371
|
-
def
|
387
|
+
async def get_date_from_ts(timestamp: int | None) -> str:
|
388
|
+
"""Get date from timestamp.
|
389
|
+
:param timestamp: Timestamp
|
390
|
+
:return: Date string
|
391
|
+
"""
|
392
|
+
if not timestamp:
|
393
|
+
return "unknown"
|
394
|
+
return datetime.fromtimestamp(timestamp).strftime("%Y%m%d")
|
395
|
+
|
396
|
+
@staticmethod
|
397
|
+
async def construct_video_url(
|
372
398
|
device_type: str | None, media_url: str | None, user_id: int, cp_sub: int | None
|
373
399
|
) -> str | None:
|
374
400
|
"""Construct the video URL.
|
@@ -385,12 +411,12 @@ class MediaManager:
|
|
385
411
|
return f"/{device_type}/cloud/video?startTime={param_dict.get("startTime")}&deviceId={param_dict.get("deviceId")}&userId={user_id}&mark={param_dict.get("mark")}"
|
386
412
|
|
387
413
|
@staticmethod
|
388
|
-
def _get_timestamp(item) -> int:
|
414
|
+
async def _get_timestamp(item) -> int | None:
|
389
415
|
"""Extract timestamp from a record item and raise an exception if it is None.
|
390
416
|
:param item: Record item
|
391
417
|
:return: Timestamp
|
392
418
|
"""
|
393
|
-
|
419
|
+
return (
|
394
420
|
item.timestamp
|
395
421
|
or item.completed_at
|
396
422
|
or item.eat_start_time
|
@@ -400,9 +426,6 @@ class MediaManager:
|
|
400
426
|
or item.time
|
401
427
|
or None
|
402
428
|
)
|
403
|
-
if timestamp is None:
|
404
|
-
raise ValueError("Can't find timestamp in record item")
|
405
|
-
return timestamp
|
406
429
|
|
407
430
|
|
408
431
|
class DownloadDecryptMedia:
|
@@ -421,66 +444,87 @@ class DownloadDecryptMedia:
|
|
421
444
|
:return: Full path of the file.
|
422
445
|
"""
|
423
446
|
subdir = ""
|
424
|
-
if file_name.endswith(
|
447
|
+
if file_name.endswith(MediaType.IMAGE):
|
425
448
|
subdir = "snapshot"
|
426
|
-
elif file_name.endswith(
|
449
|
+
elif file_name.endswith(MediaType.VIDEO):
|
427
450
|
subdir = "video"
|
428
451
|
return Path(self.download_path / self.file_data.filepath / subdir / file_name)
|
429
452
|
|
430
453
|
async def download_file(
|
431
|
-
self, file_data: MediaCloud, file_type: MediaType | None
|
454
|
+
self, file_data: MediaCloud, file_type: list[MediaType] | None
|
432
455
|
) -> None:
|
433
|
-
"""Get image and video
|
434
|
-
:param file_data: MediaCloud object
|
435
|
-
:param file_type: MediaType object
|
436
|
-
"""
|
437
|
-
_LOGGER.debug("Downloading media file %s", file_data.event_id)
|
456
|
+
"""Get image and video files."""
|
438
457
|
self.file_data = file_data
|
458
|
+
if not file_type:
|
459
|
+
file_type = []
|
460
|
+
filename = f"{self.file_data.device_id}_{self.file_data.timestamp}"
|
461
|
+
|
462
|
+
if self.file_data.image and MediaType.IMAGE in file_type:
|
463
|
+
full_filename = f"{filename}.{MediaType.IMAGE}"
|
464
|
+
if await self.not_existing_file(full_filename):
|
465
|
+
# Image download
|
466
|
+
_LOGGER.debug("Download image file (event id: %s)", file_data.event_id)
|
467
|
+
await self._get_file(
|
468
|
+
self.file_data.image,
|
469
|
+
self.file_data.aes_key,
|
470
|
+
f"{self.file_data.device_id}_{self.file_data.timestamp}.{MediaType.IMAGE}",
|
471
|
+
)
|
439
472
|
|
440
|
-
if self.file_data.
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
self.
|
445
|
-
f"{self.file_data.event_id}.jpg",
|
446
|
-
)
|
473
|
+
if self.file_data.video and MediaType.VIDEO in file_type:
|
474
|
+
if await self.not_existing_file(f"{filename}.{MediaType.VIDEO}"):
|
475
|
+
# Video download
|
476
|
+
_LOGGER.debug("Download video file (event id: %s)", file_data.event_id)
|
477
|
+
await self._get_video_m3u8()
|
447
478
|
|
448
|
-
|
449
|
-
|
450
|
-
|
479
|
+
async def not_existing_file(self, file_name: str) -> bool:
|
480
|
+
"""Check if the file already exists.
|
481
|
+
:param file_name: Filename
|
482
|
+
:return: True if the file exists, False otherwise.
|
483
|
+
"""
|
484
|
+
full_file_path = await self.get_fpath(file_name)
|
485
|
+
if full_file_path.exists():
|
486
|
+
_LOGGER.debug(
|
487
|
+
"File already exist : %s don't re-download it", full_file_path
|
488
|
+
)
|
489
|
+
return False
|
490
|
+
return True
|
451
491
|
|
452
492
|
async def _get_video_m3u8(self) -> None:
|
453
|
-
"""Iterate through m3u8 file and return all the ts file
|
493
|
+
"""Iterate through m3u8 file and return all the ts file URLs."""
|
454
494
|
aes_key, iv_key, segments_lst = await self._get_m3u8_segments()
|
495
|
+
file_name = (
|
496
|
+
f"{self.file_data.device_id}_{self.file_data.timestamp}.{MediaType.VIDEO}"
|
497
|
+
)
|
455
498
|
|
456
499
|
if aes_key is None or iv_key is None or not segments_lst:
|
457
|
-
_LOGGER.debug("Can't download video file %s",
|
500
|
+
_LOGGER.debug("Can't download video file %s", file_name)
|
458
501
|
return
|
459
502
|
|
460
|
-
segment_files = []
|
461
|
-
|
462
503
|
if len(segments_lst) == 1:
|
463
|
-
await self._get_file(
|
464
|
-
segments_lst[0], aes_key, f"{self.file_data.event_id}.avi"
|
465
|
-
)
|
504
|
+
await self._get_file(segments_lst[0], aes_key, file_name)
|
466
505
|
return
|
467
506
|
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
)
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
507
|
+
# Download segments in parallel
|
508
|
+
tasks = [
|
509
|
+
self._get_file(segment, aes_key, f"{index}_{file_name}")
|
510
|
+
for index, segment in enumerate(segments_lst, start=1)
|
511
|
+
]
|
512
|
+
results = await asyncio.gather(*tasks)
|
513
|
+
|
514
|
+
# Collect successful downloads
|
515
|
+
segment_files = [
|
516
|
+
await self.get_fpath(f"{index + 1}_{file_name}")
|
517
|
+
for index, success in enumerate(results)
|
518
|
+
if success
|
519
|
+
]
|
476
520
|
|
477
521
|
if not segment_files:
|
478
|
-
_LOGGER.
|
522
|
+
_LOGGER.warning("No segment files found")
|
479
523
|
elif len(segment_files) == 1:
|
480
524
|
_LOGGER.debug("Single file segment, no need to concatenate")
|
481
525
|
elif len(segment_files) > 1:
|
482
526
|
_LOGGER.debug("Concatenating segments %s", len(segment_files))
|
483
|
-
await self._concat_segments(segment_files,
|
527
|
+
await self._concat_segments(segment_files, file_name)
|
484
528
|
|
485
529
|
async def _get_m3u8_segments(self) -> tuple[str | None, str | None, list[str]]:
|
486
530
|
"""Extract the segments from a m3u8 file.
|
@@ -495,7 +539,7 @@ class DownloadDecryptMedia:
|
|
495
539
|
|
496
540
|
media_api = video_data.get("mediaApi", None)
|
497
541
|
if not media_api:
|
498
|
-
_LOGGER.
|
542
|
+
_LOGGER.warning("Missing mediaApi in video data")
|
499
543
|
raise ValueError("Missing mediaApi in video data")
|
500
544
|
return await self.client.extract_segments_m3u8(str(media_api))
|
501
545
|
|
@@ -506,12 +550,6 @@ class DownloadDecryptMedia:
|
|
506
550
|
:param full_filename: Name of the file to save.
|
507
551
|
:return: True if the file was downloaded successfully, False otherwise.
|
508
552
|
"""
|
509
|
-
|
510
|
-
full_file_path = await self.get_fpath(full_filename)
|
511
|
-
if full_file_path.exists():
|
512
|
-
_LOGGER.debug("File already exist : %s don't re-download it", full_filename)
|
513
|
-
return True
|
514
|
-
|
515
553
|
# Download the file
|
516
554
|
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
517
555
|
if response.status != 200:
|
@@ -520,11 +558,8 @@ class DownloadDecryptMedia:
|
|
520
558
|
)
|
521
559
|
return False
|
522
560
|
|
523
|
-
|
524
|
-
|
525
|
-
encrypted_file_path = await self._save_file(content, f"{full_filename}.enc")
|
526
|
-
# Decrypt the image
|
527
|
-
decrypted_data = await self._decrypt_file(encrypted_file_path, aes_key)
|
561
|
+
encrypted_data = await response.read()
|
562
|
+
decrypted_data = await self._decrypt_data(encrypted_data, aes_key)
|
528
563
|
|
529
564
|
if decrypted_data:
|
530
565
|
_LOGGER.debug("Decrypt was successful")
|
@@ -559,9 +594,9 @@ class DownloadDecryptMedia:
|
|
559
594
|
return file_path
|
560
595
|
|
561
596
|
@staticmethod
|
562
|
-
async def
|
597
|
+
async def _decrypt_data(encrypted_data: bytes, aes_key: str) -> bytes | None:
|
563
598
|
"""Decrypt a file using AES encryption.
|
564
|
-
:param
|
599
|
+
:param encrypted_data: Encrypted bytes data.
|
565
600
|
:param aes_key: AES key used for decryption.
|
566
601
|
:return: Decrypted bytes data.
|
567
602
|
"""
|
@@ -569,26 +604,19 @@ class DownloadDecryptMedia:
|
|
569
604
|
key_bytes: bytes = aes_key.encode("utf-8")
|
570
605
|
iv: bytes = b"\x61" * 16
|
571
606
|
cipher: Any = AES.new(key_bytes, AES.MODE_CBC, iv)
|
572
|
-
|
573
|
-
async with aio_open(file_path, "rb") as encrypted_file:
|
574
|
-
encrypted_data: bytes = await encrypted_file.read()
|
575
|
-
|
576
607
|
decrypted_data: bytes = cipher.decrypt(encrypted_data)
|
577
608
|
|
578
609
|
try:
|
579
610
|
decrypted_data = unpad(decrypted_data, AES.block_size)
|
580
611
|
except ValueError as e:
|
581
612
|
_LOGGER.debug("Warning: Padding error occurred, ignoring error: %s", e)
|
582
|
-
|
583
|
-
if Path(file_path).exists():
|
584
|
-
Path(file_path).unlink()
|
585
613
|
return decrypted_data
|
586
614
|
|
587
615
|
async def _concat_segments(self, ts_files: list[Path], output_file) -> None:
|
588
|
-
"""Concatenate a list of .
|
616
|
+
"""Concatenate a list of .mp4 segments into a single output file without using a temporary file.
|
589
617
|
|
590
|
-
:param ts_files: List of absolute paths of .
|
591
|
-
:param output_file: Path of the output file (e.g., "output.
|
618
|
+
:param ts_files: List of absolute paths of .mp4 files
|
619
|
+
:param output_file: Path of the output file (e.g., "output.mp4")
|
592
620
|
"""
|
593
621
|
full_output_file = await self.get_fpath(output_file)
|
594
622
|
if full_output_file.exists():
|
@@ -635,9 +663,10 @@ class DownloadDecryptMedia:
|
|
635
663
|
except OSError as e:
|
636
664
|
_LOGGER.error("OS error during concatenation: %s", e)
|
637
665
|
|
638
|
-
|
666
|
+
@staticmethod
|
667
|
+
async def _delete_segments(ts_files: list[Path]) -> None:
|
639
668
|
"""Delete all segment files after concatenation.
|
640
|
-
:param ts_files: List of absolute paths of .
|
669
|
+
:param ts_files: List of absolute paths of .mp4 files
|
641
670
|
"""
|
642
671
|
for file in ts_files:
|
643
672
|
if file.exists():
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: pypetkitapi
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.11.2
|
4
4
|
Summary: Python client for PetKit API
|
5
5
|
License: MIT
|
6
6
|
Author: Jezza34000
|
@@ -77,7 +77,7 @@ import asyncio
|
|
77
77
|
import logging
|
78
78
|
import aiohttp
|
79
79
|
from pypetkitapi.client import PetKitClient
|
80
|
-
from pypetkitapi.command import DeviceCommand, FeederCommand, LBCommand,
|
80
|
+
from pypetkitapi.command import DeviceCommand, FeederCommand, LBCommand, DeviceAction, LitterCommand
|
81
81
|
|
82
82
|
logging.basicConfig(level=logging.DEBUG)
|
83
83
|
|
@@ -86,8 +86,8 @@ async def main():
|
|
86
86
|
client = PetKitClient(
|
87
87
|
username="username", # Your PetKit account username or id
|
88
88
|
password="password", # Your PetKit account password
|
89
|
-
region="FR", # Your region or country code (e.g. FR, US, etc.)
|
90
|
-
timezone="Europe/Paris", # Your timezone
|
89
|
+
region="FR", # Your region or country code (e.g. FR, US,CN etc.)
|
90
|
+
timezone="Europe/Paris", # Your timezone(e.g. "Asia/Shanghai")
|
91
91
|
session=session,
|
92
92
|
)
|
93
93
|
|
@@ -98,26 +98,28 @@ async def main():
|
|
98
98
|
for key, value in client.petkit_entities.items():
|
99
99
|
print(f"{key}: {type(value).__name__} - {value.name}")
|
100
100
|
|
101
|
+
# Select a device
|
102
|
+
device_id = key
|
101
103
|
# Read devices or pet information
|
102
|
-
print(client.petkit_entities[
|
104
|
+
print(client.petkit_entities[device_id])
|
103
105
|
|
104
106
|
# Send command to the devices
|
105
107
|
### Example 1 : Turn on the indicator light
|
106
108
|
### Device_ID, Command, Payload
|
107
|
-
await client.send_api_request(
|
109
|
+
await client.send_api_request(device_id, DeviceCommand.UPDATE_SETTING, {"lightMode": 1})
|
108
110
|
|
109
111
|
### Example 2 : Feed the pet
|
110
112
|
### Device_ID, Command, Payload
|
111
113
|
# simple hopper :
|
112
|
-
await client.send_api_request(
|
114
|
+
await client.send_api_request(device_id, FeederCommand.MANUAL_FEED, {"amount": 1})
|
113
115
|
# dual hopper :
|
114
|
-
await client.send_api_request(
|
116
|
+
await client.send_api_request(device_id, FeederCommand.MANUAL_FEED, {"amount1": 2})
|
115
117
|
# or
|
116
|
-
await client.send_api_request(
|
118
|
+
await client.send_api_request(device_id, FeederCommand.MANUAL_FEED, {"amount2": 2})
|
117
119
|
|
118
120
|
### Example 3 : Start the cleaning process
|
119
121
|
### Device_ID, Command, Payload
|
120
|
-
await client.send_api_request(
|
122
|
+
await client.send_api_request(device_id, LitterCommand.CONTROL_DEVICE, {DeviceAction.START: LBCommand.CLEANING})
|
121
123
|
|
122
124
|
|
123
125
|
if __name__ == "__main__":
|
@@ -1,19 +1,19 @@
|
|
1
|
-
pypetkitapi/__init__.py,sha256=
|
2
|
-
pypetkitapi/bluetooth.py,sha256=
|
3
|
-
pypetkitapi/client.py,sha256=
|
1
|
+
pypetkitapi/__init__.py,sha256=qDhET_P65lnwbZgZnkYst3IgJxPlID7fLqYnfVSzJqM,2107
|
2
|
+
pypetkitapi/bluetooth.py,sha256=eu6c2h6YHBafAhhSSy4As6tn29i5WbOH9tZzRlMm44U,7843
|
3
|
+
pypetkitapi/client.py,sha256=wzZQUHg_ee6lmdAjli6zS7qw_sgPER9iLfcTZe4VTH4,27190
|
4
4
|
pypetkitapi/command.py,sha256=cMCUutZCQo9Ddvjl_FYR5UjU_CqFz1iyetMznYwjpzM,7500
|
5
|
-
pypetkitapi/const.py,sha256=
|
5
|
+
pypetkitapi/const.py,sha256=W0cWpBvOySEaPvVAnQHLeIWYEqKG051mVNv-qsfjo7I,4609
|
6
6
|
pypetkitapi/containers.py,sha256=F_uyDBD0a5QD4s_ArjYiKTAAg1XHYBvmV_lEnO9RQ-U,4786
|
7
7
|
pypetkitapi/exceptions.py,sha256=4BXUyYXLfZjNxdnOGJPjyE9ASIl7JmQphjws87jvHtE,1631
|
8
8
|
pypetkitapi/feeder_container.py,sha256=PhidWd5WpsZqtdKZy60PzE67YXgQfApjm8CqvMCHK3U,14743
|
9
9
|
pypetkitapi/litter_container.py,sha256=KWvHNAOJ6hDSeJ_55tqtzY9GxHtd9gAntPkbnVbdb-I,19275
|
10
|
-
pypetkitapi/media.py,sha256=
|
10
|
+
pypetkitapi/media.py,sha256=34q3pIfejO_z-Uk8ohQlOGaPOdaGtnI0zN1LtZ3k3F4,25904
|
11
11
|
pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
|
12
12
|
pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
13
|
pypetkitapi/schedule_container.py,sha256=OjLAY6FY-g14JNJJnYMNFV5ZtdkjUzNBit1VUiiZKnQ,2053
|
14
14
|
pypetkitapi/utils.py,sha256=z7325kcJQUburnF28HSXrJMvY_gY9007K73Zwxp-4DQ,743
|
15
15
|
pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
|
16
|
-
pypetkitapi-1.
|
17
|
-
pypetkitapi-1.
|
18
|
-
pypetkitapi-1.
|
19
|
-
pypetkitapi-1.
|
16
|
+
pypetkitapi-1.11.2.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
|
17
|
+
pypetkitapi-1.11.2.dist-info/METADATA,sha256=8HbQJ6UeGion-oO5Y5U0X7THfeDRYeP-EK-XVwF_Lb8,6338
|
18
|
+
pypetkitapi-1.11.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
19
|
+
pypetkitapi-1.11.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|