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 CHANGED
@@ -51,7 +51,7 @@ from .media import DownloadDecryptMedia, MediaCloud, MediaFile, MediaManager
51
51
  from .purifier_container import Purifier
52
52
  from .water_fountain_container import WaterFountain
53
53
 
54
- __version__ = "1.10.3"
54
+ __version__ = "1.11.2"
55
55
 
56
56
  __all__ = [
57
57
  "CTW3",
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("Failed to establish BLE connection.")
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.warning("BLE connection attempt n%s", attempt)
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("BLE connection established successfully.")
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("Failed to establish BLE connection after multiple attempts.")
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("Command not found.")
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.get_all_media_files(
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.debug(
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
@@ -102,7 +102,7 @@ LOGIN_DATA = {
102
102
  class MediaType(StrEnum):
103
103
  """Record Type constants"""
104
104
 
105
- VIDEO = "avi"
105
+ VIDEO = "mp4"
106
106
  IMAGE = "jpg"
107
107
 
108
108
 
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 get_all_media_files(
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 get_all_media_files_disk(
105
+ async def gather_all_media_from_disk(
105
106
  self, storage_path: Path, device_id: int
106
- ) -> None:
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(rf"^(?:\d+_)?{device_id}_\d+\.(jpg|avi)$")
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("Skip, path does not exist, %s", subdir)
134
+ _LOGGER.debug("Path does not exist, skip : %s", subdir)
130
135
  continue
131
136
 
132
- _LOGGER.debug("Scanning directory %s", subdir)
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("Entries found: %s", entry.name)
141
+ _LOGGER.debug("Media found: %s", entry.name)
137
142
  event_id = Path(entry.name).stem
138
- timestamp = self._extract_timestamp(str(entry.name))
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
- @staticmethod
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(record, RecordType(record_type), feeder)
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.error("Missing feeder_id for record")
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
- timestamp = self._get_timestamp(item)
271
- date_str = (
272
- datetime.fromtimestamp(timestamp).strftime("%Y%m%d")
273
- if timestamp
274
- else "unknown"
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("Missing event_id for record item")
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.error("Missing user_id for record item")
299
+ _LOGGER.warning("Missing user_id for record item")
282
300
  continue
283
301
  if not item.aes_key:
284
- _LOGGER.error("Missing aes_key for record item")
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=self._get_timestamp(item),
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.error("Missing litter_id for record")
337
+ _LOGGER.warning("Missing litter_id for record")
322
338
  return media_files
323
339
 
324
340
  if not device_type:
325
- _LOGGER.error("Missing device_type for record")
341
+ _LOGGER.warning("Missing device_type for record")
326
342
  return media_files
327
343
 
328
344
  if not user_id:
329
- _LOGGER.error("Missing user_id for record")
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
- timestamp = record.timestamp or None
337
- date_str = (
338
- datetime.fromtimestamp(timestamp).strftime("%Y%m%d")
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.error("Missing event_id for record item")
356
+ _LOGGER.debug("Missing event_id for record item")
344
357
  continue
345
358
  if not record.aes_key:
346
- _LOGGER.error("Missing aes_key for record item")
359
+ _LOGGER.debug("Missing aes_key for record item")
347
360
  continue
348
361
  if record.timestamp is None:
349
- _LOGGER.error("Missing timestamp for record item")
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 construct_video_url(
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
- timestamp = (
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(".jpg"):
447
+ if file_name.endswith(MediaType.IMAGE):
425
448
  subdir = "snapshot"
426
- elif file_name.endswith(".avi"):
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 file
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.image and (file_type is None or file_type == MediaType.IMAGE):
441
- # Download image file
442
- await self._get_file(
443
- self.file_data.image,
444
- self.file_data.aes_key,
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
- if self.file_data.video and (file_type is None or file_type == MediaType.VIDEO):
449
- # Download video file
450
- await self._get_video_m3u8()
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 urls"""
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", self.file_data.event_id)
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
- for index, segment in enumerate(segments_lst, start=1):
469
- segment_file = await self._get_file(
470
- segment, aes_key, f"{index}_{self.file_data.event_id}.avi"
471
- )
472
- if segment_file:
473
- segment_files.append(
474
- await self.get_fpath(f"{index}_{self.file_data.event_id}.avi")
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.error("No segment files found")
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, f"{self.file_data.event_id}.avi")
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.error("Missing mediaApi in video data")
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
- content = await response.read()
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 _decrypt_file(file_path: Path, aes_key: str) -> bytes | None:
597
+ async def _decrypt_data(encrypted_data: bytes, aes_key: str) -> bytes | None:
563
598
  """Decrypt a file using AES encryption.
564
- :param file_path: Path to the encrypted file.
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 .avi segments into a single output file without using a temporary file.
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 .avi files
591
- :param output_file: Path of the output file (e.g., "output.avi")
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
- async def _delete_segments(self, ts_files: list[Path]) -> None:
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 .avi files
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.10.3
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, LBAction, LitterCommand
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[123456789])
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(123456789, DeviceCommand.UPDATE_SETTING, {"lightMode": 1})
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(123456789, FeederCommand.MANUAL_FEED, {"amount": 1})
114
+ await client.send_api_request(device_id, FeederCommand.MANUAL_FEED, {"amount": 1})
113
115
  # dual hopper :
114
- await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount1": 2})
116
+ await client.send_api_request(device_id, FeederCommand.MANUAL_FEED, {"amount1": 2})
115
117
  # or
116
- await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount2": 2})
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(123456789, LitterCommand.CONTROL_DEVICE, {LBAction.START: LBCommand.CLEANING})
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=qP06yQAfRHrJ7yiYlQgNDJD5cGUggj29WsKLhsR_P8M,2107
2
- pypetkitapi/bluetooth.py,sha256=u_xGp701WnrroTOt_KuIVUCZ3kRQ7BJeoMR8b9RpJ54,7176
3
- pypetkitapi/client.py,sha256=mbND1lzu1DQ2hHxcQnyk9-8ig1JJ5lAo8S-3lGKmYXs,27037
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=US5QihmBYvlm8hIHX0PORPUnMmDW3nmLzwLWTepkkGg,4609
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=OrtEN9LKe3Xy4AapvTUCj4LZ09ZAltGpRYEmD_xQwcA,24220
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.10.3.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
17
- pypetkitapi-1.10.3.dist-info/METADATA,sha256=OyJ5ainwvLr8ZSkZVM6Dt3cfLlqR1SRE2kXgiq5rBUs,6256
18
- pypetkitapi-1.10.3.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
19
- pypetkitapi-1.10.3.dist-info/RECORD,,
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,,