pypetkitapi 1.10.2__py3-none-any.whl → 1.11.0__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.2"
54
+ __version__ = "1.11.0"
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,15 +101,20 @@ 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")
109
113
  self.req.base_url = PetkitDomain.PASSPORT_PETKIT
110
114
 
111
- if self.region.lower() == "china":
115
+ if self.region.lower() == "china" or self.region.lower() == "cn":
112
116
  self.req.base_url = PetkitDomain.CHINA_SRV
117
+ _LOGGER.debug("Using specific China server: %s", PetkitDomain.CHINA_SRV)
113
118
  return
114
119
 
115
120
  response = await self.req.request(
@@ -336,7 +341,7 @@ class PetKitClient:
336
341
  _LOGGER.debug("Fetching media data for device: %s", device.device_id)
337
342
 
338
343
  device_entity = self.petkit_entities[device.device_id]
339
- device_entity.medias = await self.media_manager.get_all_media_files(
344
+ device_entity.medias = await self.media_manager.gather_all_media_from_cloud(
340
345
  [device_entity]
341
346
  )
342
347
 
@@ -517,8 +522,8 @@ class PetKitClient:
517
522
  headers=await self.get_session_id(),
518
523
  )
519
524
  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 ?"
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."
522
527
  )
523
528
  return None
524
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
@@ -31,8 +31,8 @@ _LOGGER = logging.getLogger(__name__)
31
31
 
32
32
  @dataclass
33
33
  class MediaCloud:
34
- """Dataclass MediaFile.
35
- Represents a media file from a PetKit device.
34
+ """Dataclass MediaCloud.
35
+ Represents a media file from Petkit API.
36
36
  """
37
37
 
38
38
  event_id: str
@@ -65,10 +65,13 @@ class MediaManager:
65
65
 
66
66
  media_table: list[MediaFile] = []
67
67
 
68
- async def get_all_media_files(
68
+ async def gather_all_media_from_cloud(
69
69
  self, devices: list[Feeder | Litter]
70
70
  ) -> list[MediaCloud]:
71
- """Get all media files from all devices and return a list of MediaFile."""
71
+ """Get all media files from all devices and return a list of MediaCloud.
72
+ :param devices: List of devices
73
+ :return: List of MediaCloud objects
74
+ """
72
75
  media_files: list[MediaCloud] = []
73
76
  _LOGGER.debug("Processing media files for %s devices", len(devices))
74
77
 
@@ -78,7 +81,7 @@ class MediaManager:
78
81
  device.device_nfo
79
82
  and device.device_nfo.device_type in FEEDER_WITH_CAMERA
80
83
  ):
81
- media_files.extend(self._process_feeder(device))
84
+ media_files.extend(await self._process_feeder(device))
82
85
  else:
83
86
  _LOGGER.debug(
84
87
  "Feeder %s does not support media file extraction",
@@ -89,7 +92,7 @@ class MediaManager:
89
92
  device.device_nfo
90
93
  and device.device_nfo.device_type in LITTER_WITH_CAMERA
91
94
  ):
92
- media_files.extend(self._process_litter(device))
95
+ media_files.extend(await self._process_litter(device))
93
96
  else:
94
97
  _LOGGER.debug(
95
98
  "Litter %s does not support media file extraction",
@@ -98,38 +101,45 @@ class MediaManager:
98
101
 
99
102
  return media_files
100
103
 
101
- async def get_all_media_files_disk(
104
+ async def gather_all_media_from_disk(
102
105
  self, storage_path: Path, device_id: int
103
- ) -> None:
104
- """Construct the media file table for disk storage."""
106
+ ) -> list[MediaFile]:
107
+ """Construct the media file table for disk storage.
108
+ :param storage_path: Path to the storage directory
109
+ :param device_id: Device ID
110
+ """
105
111
  self.media_table.clear()
106
112
 
107
113
  today_str = datetime.now().strftime("%Y%m%d")
108
114
  base_path = storage_path / str(device_id) / today_str
109
115
 
116
+ _LOGGER.debug("Populating files from directory %s", base_path)
117
+
110
118
  for record_type in RecordType:
111
119
  record_path = base_path / record_type
112
120
  snapshot_path = record_path / "snapshot"
113
121
  video_path = record_path / "video"
114
122
 
115
123
  # Regex pattern to match valid filenames
116
- valid_pattern = re.compile(rf"^(?:\d+_)?{device_id}_\d+\.(jpg|avi)$")
124
+ valid_pattern = re.compile(
125
+ rf"^{device_id}_\d+\.({MediaType.IMAGE}|{MediaType.VIDEO})$"
126
+ )
117
127
 
118
128
  # Populate the media table with event_id from filenames
119
129
  for subdir in [snapshot_path, video_path]:
120
130
 
121
131
  # Ensure the directories exist
122
132
  if not await aiofiles.os.path.exists(subdir):
123
- _LOGGER.debug("Skip, path does not exist, %s", subdir)
133
+ _LOGGER.debug("Path does not exist, skip : %s", subdir)
124
134
  continue
125
135
 
126
- _LOGGER.debug("Scanning directory %s", subdir)
136
+ _LOGGER.debug("Scanning files into : %s", subdir)
127
137
  entries = await aiofiles.os.scandir(subdir)
128
138
  for entry in entries:
129
139
  if entry.is_file() and valid_pattern.match(entry.name):
130
- _LOGGER.debug("Entries found: %s", entry.name)
140
+ _LOGGER.debug("Media found: %s", entry.name)
131
141
  event_id = Path(entry.name).stem
132
- timestamp = self._extraire_timestamp(str(entry.name))
142
+ timestamp = Path(entry.name).stem.split("_")[1]
133
143
  media_type_str = Path(entry.name).suffix.lstrip(".")
134
144
  try:
135
145
  media_type = MediaType(media_type_str)
@@ -146,32 +156,44 @@ class MediaManager:
146
156
  media_type=MediaType(media_type),
147
157
  )
148
158
  )
159
+ _LOGGER.debug("OK, Media table populated with %s files", len(self.media_table))
160
+ return self.media_table
149
161
 
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(
162
+ async def list_missing_files(
158
163
  self,
159
164
  media_cloud_list: list[MediaCloud],
160
165
  dl_type: list[MediaType] | None = None,
161
166
  event_type: list[RecordType] | None = None,
162
167
  ) -> list[MediaCloud]:
163
- """Compare MediaCloud objects with MediaFile objects and return a list of missing MediaCloud objects."""
164
- missing_media = []
168
+ """Compare MediaCloud objects with MediaFile objects and return a list of missing MediaCloud objects.
169
+ :param media_cloud_list: List of MediaCloud objects
170
+ :param dl_type: List of media types to download
171
+ :param event_type: List of event types to filter
172
+ :return: List of missing MediaCloud objects
173
+ """
174
+ missing_media: list[MediaCloud] = []
165
175
  existing_event_ids = {media_file.event_id for media_file in self.media_table}
166
176
 
177
+ if dl_type is None or event_type is None or not dl_type or not event_type:
178
+ _LOGGER.debug(
179
+ "Missing dl_type or event_type parameters, no media file will be downloaded"
180
+ )
181
+ return missing_media
182
+
167
183
  for media_cloud in media_cloud_list:
168
- # Skip if event type is not in the filter
184
+ # Skip if event type is not in the event filter
169
185
  if event_type and media_cloud.event_type not in event_type:
186
+ _LOGGER.debug(
187
+ "Skipping event type %s, is filtered", media_cloud.event_type
188
+ )
170
189
  continue
171
190
 
172
191
  # Check if the media file is missing
173
192
  is_missing = False
174
193
  if media_cloud.event_id not in existing_event_ids:
194
+ _LOGGER.debug(
195
+ "Media file IMG/VIDEO id : %s are missing", media_cloud.event_id
196
+ )
175
197
  is_missing = True # Both image and video are missing
176
198
  else:
177
199
  # Check for missing image
@@ -185,6 +207,9 @@ class MediaManager:
185
207
  for media_file in self.media_table
186
208
  )
187
209
  ):
210
+ _LOGGER.debug(
211
+ "Media file IMG id : %s is missing", media_cloud.event_id
212
+ )
188
213
  is_missing = True
189
214
  # Check for missing video
190
215
  if (
@@ -197,15 +222,20 @@ class MediaManager:
197
222
  for media_file in self.media_table
198
223
  )
199
224
  ):
225
+ _LOGGER.debug(
226
+ "Media file VIDEO id : %s is missing", media_cloud.event_id
227
+ )
200
228
  is_missing = True
201
229
 
202
230
  if is_missing:
203
231
  missing_media.append(media_cloud)
204
-
205
232
  return missing_media
206
233
 
207
- def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
208
- """Process media files for a Feeder device."""
234
+ async def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
235
+ """Process media files for a Feeder device.
236
+ :param feeder: Feeder device object
237
+ :return: List of MediaCloud objects for the device
238
+ """
209
239
  media_files: list[MediaCloud] = []
210
240
  records = feeder.device_records
211
241
 
@@ -217,15 +247,22 @@ class MediaManager:
217
247
  record_list = getattr(records, record_type, [])
218
248
  for record in record_list:
219
249
  media_files.extend(
220
- self._process_feeder_record(record, RecordType(record_type), feeder)
250
+ await self._process_feeder_record(
251
+ record, RecordType(record_type), feeder
252
+ )
221
253
  )
222
254
 
223
255
  return media_files
224
256
 
225
- def _process_feeder_record(
257
+ async def _process_feeder_record(
226
258
  self, record, record_type: RecordType, device_obj: Feeder
227
259
  ) -> list[MediaCloud]:
228
- """Process individual feeder records."""
260
+ """Process individual feeder records.
261
+ :param record: Record object
262
+ :param record_type: Record type
263
+ :param device_obj: Feeder device object
264
+ :return: List of MediaCloud objects for the record
265
+ """
229
266
  media_files: list[MediaCloud] = []
230
267
  user_id = device_obj.user.id if device_obj.user else None
231
268
  feeder_id = device_obj.device_nfo.device_id if device_obj.device_nfo else None
@@ -244,15 +281,15 @@ class MediaManager:
244
281
  return media_files
245
282
 
246
283
  for item in record.items:
247
- timestamp = self._get_timestamp(item)
248
- date_str = (
249
- datetime.fromtimestamp(timestamp).strftime("%Y%m%d")
250
- if timestamp
251
- else "unknown"
252
- )
284
+ timestamp = await self._get_timestamp(item)
285
+ if timestamp is None:
286
+ _LOGGER.error("Missing timestamp for record item")
287
+ continue
253
288
  if not item.event_id:
254
289
  # Skip feed event in the future
255
- _LOGGER.debug("Missing event_id for record item")
290
+ _LOGGER.debug(
291
+ "Missing event_id for record item (probably a feed event not yet completed)"
292
+ )
256
293
  continue
257
294
  if not user_id:
258
295
  _LOGGER.error("Missing user_id for record item")
@@ -260,10 +297,8 @@ class MediaManager:
260
297
  if not item.aes_key:
261
298
  _LOGGER.error("Missing aes_key for record item")
262
299
  continue
263
- if timestamp is None:
264
- _LOGGER.error("Missing timestamp for record item")
265
- continue
266
300
 
301
+ date_str = await self.get_date_from_ts(timestamp)
267
302
  filepath = f"{feeder_id}/{date_str}/{record_type.name.lower()}"
268
303
  media_files.append(
269
304
  MediaCloud(
@@ -272,18 +307,21 @@ class MediaManager:
272
307
  device_id=feeder_id,
273
308
  user_id=user_id,
274
309
  image=item.preview,
275
- video=self.construct_video_url(
310
+ video=await self.construct_video_url(
276
311
  device_type, item.media_api, user_id, cp_sub
277
312
  ),
278
313
  filepath=filepath,
279
314
  aes_key=item.aes_key,
280
- timestamp=self._get_timestamp(item),
315
+ timestamp=timestamp,
281
316
  )
282
317
  )
283
318
  return media_files
284
319
 
285
- def _process_litter(self, litter: Litter) -> list[MediaCloud]:
286
- """Process media files for a Litter device."""
320
+ async def _process_litter(self, litter: Litter) -> list[MediaCloud]:
321
+ """Process media files for a Litter device.
322
+ :param litter: Litter device object
323
+ :return: List of MediaCloud objects for the device
324
+ """
287
325
  media_files: list[MediaCloud] = []
288
326
  records = litter.device_records
289
327
  litter_id = litter.device_nfo.device_id if litter.device_nfo else None
@@ -307,22 +345,19 @@ class MediaManager:
307
345
  return media_files
308
346
 
309
347
  for record in records:
310
- timestamp = record.timestamp or None
311
- date_str = (
312
- datetime.fromtimestamp(timestamp).strftime("%Y%m%d")
313
- if timestamp
314
- else "unknown"
315
- )
316
348
  if not record.event_id:
317
- _LOGGER.error("Missing event_id for record item")
349
+ _LOGGER.debug("Missing event_id for record item")
318
350
  continue
319
351
  if not record.aes_key:
320
- _LOGGER.error("Missing aes_key for record item")
352
+ _LOGGER.debug("Missing aes_key for record item")
321
353
  continue
322
354
  if record.timestamp is None:
323
- _LOGGER.error("Missing timestamp for record item")
355
+ _LOGGER.debug("Missing timestamp for record item")
324
356
  continue
325
357
 
358
+ timestamp = record.timestamp or None
359
+ date_str = await self.get_date_from_ts(timestamp)
360
+
326
361
  filepath = f"{litter_id}/{date_str}/toileting"
327
362
  media_files.append(
328
363
  MediaCloud(
@@ -331,7 +366,7 @@ class MediaManager:
331
366
  device_id=litter_id,
332
367
  user_id=user_id,
333
368
  image=record.preview,
334
- video=self.construct_video_url(
369
+ video=await self.construct_video_url(
335
370
  device_type, record.media_api, user_id, cp_sub
336
371
  ),
337
372
  filepath=filepath,
@@ -342,10 +377,26 @@ class MediaManager:
342
377
  return media_files
343
378
 
344
379
  @staticmethod
345
- def construct_video_url(
380
+ async def get_date_from_ts(timestamp: int | None) -> str:
381
+ """Get date from timestamp.
382
+ :param timestamp: Timestamp
383
+ :return: Date string
384
+ """
385
+ if not timestamp:
386
+ return "unknown"
387
+ return datetime.fromtimestamp(timestamp).strftime("%Y%m%d")
388
+
389
+ @staticmethod
390
+ async def construct_video_url(
346
391
  device_type: str | None, media_url: str | None, user_id: int, cp_sub: int | None
347
392
  ) -> str | None:
348
- """Construct the video URL."""
393
+ """Construct the video URL.
394
+ :param device_type: Device type
395
+ :param media_url: Media URL
396
+ :param user_id: User ID
397
+ :param cp_sub: Cpsub value
398
+ :return: Constructed video URL
399
+ """
349
400
  if not media_url or not user_id or cp_sub != 1:
350
401
  return None
351
402
  params = parse_qs(urlparse(media_url).query)
@@ -353,8 +404,11 @@ class MediaManager:
353
404
  return f"/{device_type}/cloud/video?startTime={param_dict.get("startTime")}&deviceId={param_dict.get("deviceId")}&userId={user_id}&mark={param_dict.get("mark")}"
354
405
 
355
406
  @staticmethod
356
- def _get_timestamp(item) -> int:
357
- """Extract timestamp from a record item and raise an exception if it is None."""
407
+ async def _get_timestamp(item) -> int:
408
+ """Extract timestamp from a record item and raise an exception if it is None.
409
+ :param item: Record item
410
+ :return: Timestamp
411
+ """
358
412
  timestamp = (
359
413
  item.timestamp
360
414
  or item.completed_at
@@ -381,57 +435,84 @@ class DownloadDecryptMedia:
381
435
  self.client = client
382
436
 
383
437
  async def get_fpath(self, file_name: str) -> Path:
384
- """Return the full path of the file."""
438
+ """Return the full path of the file.
439
+ :param file_name: Name of the file.
440
+ :return: Full path of the file.
441
+ """
385
442
  subdir = ""
386
- if file_name.endswith(".jpg"):
443
+ if file_name.endswith(MediaType.IMAGE):
387
444
  subdir = "snapshot"
388
- elif file_name.endswith(".avi"):
445
+ elif file_name.endswith(MediaType.VIDEO):
389
446
  subdir = "video"
390
447
  return Path(self.download_path / self.file_data.filepath / subdir / file_name)
391
448
 
392
449
  async def download_file(
393
- self, file_data: MediaCloud, file_type: MediaType | None
450
+ self, file_data: MediaCloud, file_type: list[MediaType] | None
394
451
  ) -> None:
395
- """Get image and video file"""
396
- _LOGGER.debug("Downloading media file %s", file_data.event_id)
452
+ """Get image and video files."""
397
453
  self.file_data = file_data
454
+ if not file_type:
455
+ file_type = []
456
+ filename = f"{self.file_data.device_id}_{self.file_data.timestamp}"
457
+
458
+ if self.file_data.image and MediaType.IMAGE in file_type:
459
+ full_filename = f"{filename}.{MediaType.IMAGE}"
460
+ if await self.not_existing_file(full_filename):
461
+ # Image download
462
+ _LOGGER.debug("Download image file (event id: %s)", file_data.event_id)
463
+ await self._get_file(
464
+ self.file_data.image,
465
+ self.file_data.aes_key,
466
+ f"{self.file_data.device_id}_{self.file_data.timestamp}.{MediaType.IMAGE}",
467
+ )
398
468
 
399
- if self.file_data.image and (file_type is None or file_type == MediaType.IMAGE):
400
- # Download image file
401
- await self._get_file(
402
- self.file_data.image,
403
- self.file_data.aes_key,
404
- f"{self.file_data.event_id}.jpg",
405
- )
469
+ if self.file_data.video and MediaType.VIDEO in file_type:
470
+ if await self.not_existing_file(f"{filename}.{MediaType.VIDEO}"):
471
+ # Video download
472
+ _LOGGER.debug("Download video file (event id: %s)", file_data.event_id)
473
+ await self._get_video_m3u8()
406
474
 
407
- if self.file_data.video and (file_type is None or file_type == MediaType.VIDEO):
408
- # Download video file
409
- await self._get_video_m3u8()
475
+ async def not_existing_file(self, file_name: str) -> bool:
476
+ """Check if the file already exists.
477
+ :param file_name: Filename
478
+ :return: True if the file exists, False otherwise.
479
+ """
480
+ full_file_path = await self.get_fpath(file_name)
481
+ if full_file_path.exists():
482
+ _LOGGER.debug(
483
+ "File already exist : %s don't re-download it", full_file_path
484
+ )
485
+ return False
486
+ return True
410
487
 
411
488
  async def _get_video_m3u8(self) -> None:
412
- """Iterate through m3u8 file and return all the ts file urls"""
489
+ """Iterate through m3u8 file and return all the ts file URLs."""
413
490
  aes_key, iv_key, segments_lst = await self._get_m3u8_segments()
491
+ file_name = (
492
+ f"{self.file_data.device_id}_{self.file_data.timestamp}.{MediaType.VIDEO}"
493
+ )
414
494
 
415
495
  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)
496
+ _LOGGER.debug("Can't download video file %s", file_name)
417
497
  return
418
498
 
419
- segment_files = []
420
-
421
499
  if len(segments_lst) == 1:
422
- await self._get_file(
423
- segments_lst[0], aes_key, f"{self.file_data.event_id}.avi"
424
- )
500
+ await self._get_file(segments_lst[0], aes_key, file_name)
425
501
  return
426
502
 
427
- for index, segment in enumerate(segments_lst, start=1):
428
- segment_file = await self._get_file(
429
- segment, aes_key, f"{index}_{self.file_data.event_id}.avi"
430
- )
431
- if segment_file:
432
- segment_files.append(
433
- await self.get_fpath(f"{index}_{self.file_data.event_id}.avi")
434
- )
503
+ # Download segments in parallel
504
+ tasks = [
505
+ self._get_file(segment, aes_key, f"{index}_{file_name}")
506
+ for index, segment in enumerate(segments_lst, start=1)
507
+ ]
508
+ results = await asyncio.gather(*tasks)
509
+
510
+ # Collect successful downloads
511
+ segment_files = [
512
+ await self.get_fpath(f"{index + 1}_{file_name}")
513
+ for index, success in enumerate(results)
514
+ if success
515
+ ]
435
516
 
436
517
  if not segment_files:
437
518
  _LOGGER.error("No segment files found")
@@ -439,7 +520,7 @@ class DownloadDecryptMedia:
439
520
  _LOGGER.debug("Single file segment, no need to concatenate")
440
521
  elif len(segment_files) > 1:
441
522
  _LOGGER.debug("Concatenating segments %s", len(segment_files))
442
- await self._concat_segments(segment_files, f"{self.file_data.event_id}.avi")
523
+ await self._concat_segments(segment_files, file_name)
443
524
 
444
525
  async def _get_m3u8_segments(self) -> tuple[str | None, str | None, list[str]]:
445
526
  """Extract the segments from a m3u8 file.
@@ -459,13 +540,12 @@ class DownloadDecryptMedia:
459
540
  return await self.client.extract_segments_m3u8(str(media_api))
460
541
 
461
542
  async def _get_file(self, url: str, aes_key: str, full_filename: str) -> bool:
462
- """Download a file from a URL and decrypt it."""
463
-
464
- full_file_path = await self.get_fpath(full_filename)
465
- if full_file_path.exists():
466
- _LOGGER.debug("File already exist : %s don't re-download it", full_filename)
467
- return True
468
-
543
+ """Download a file from a URL and decrypt it.
544
+ :param url: URL of the file to download.
545
+ :param aes_key: AES key used for decryption.
546
+ :param full_filename: Name of the file to save.
547
+ :return: True if the file was downloaded successfully, False otherwise.
548
+ """
469
549
  # Download the file
470
550
  async with aiohttp.ClientSession() as session, session.get(url) as response:
471
551
  if response.status != 200:
@@ -474,11 +554,8 @@ class DownloadDecryptMedia:
474
554
  )
475
555
  return False
476
556
 
477
- content = await response.read()
478
-
479
- encrypted_file_path = await self._save_file(content, f"{full_filename}.enc")
480
- # Decrypt the image
481
- decrypted_data = await self._decrypt_file(encrypted_file_path, aes_key)
557
+ encrypted_data = await response.read()
558
+ decrypted_data = await self._decrypt_data(encrypted_data, aes_key)
482
559
 
483
560
  if decrypted_data:
484
561
  _LOGGER.debug("Decrypt was successful")
@@ -487,7 +564,11 @@ class DownloadDecryptMedia:
487
564
  return False
488
565
 
489
566
  async def _save_file(self, content: bytes, filename: str) -> Path:
490
- """Save content to a file asynchronously and return the file path."""
567
+ """Save content to a file asynchronously and return the file path.
568
+ :param content: Bytes data to save.
569
+ :param filename: Name of the file to save.
570
+ :return: Path of the saved file.
571
+ """
491
572
  file_path = await self.get_fpath(filename)
492
573
  try:
493
574
  # Ensure the directory exists
@@ -509,9 +590,9 @@ class DownloadDecryptMedia:
509
590
  return file_path
510
591
 
511
592
  @staticmethod
512
- async def _decrypt_file(file_path: Path, aes_key: str) -> bytes | None:
593
+ async def _decrypt_data(encrypted_data: bytes, aes_key: str) -> bytes | None:
513
594
  """Decrypt a file using AES encryption.
514
- :param file_path: Path to the encrypted file.
595
+ :param encrypted_data: Encrypted bytes data.
515
596
  :param aes_key: AES key used for decryption.
516
597
  :return: Decrypted bytes data.
517
598
  """
@@ -519,25 +600,18 @@ class DownloadDecryptMedia:
519
600
  key_bytes: bytes = aes_key.encode("utf-8")
520
601
  iv: bytes = b"\x61" * 16
521
602
  cipher: Any = AES.new(key_bytes, AES.MODE_CBC, iv)
522
-
523
- async with aio_open(file_path, "rb") as encrypted_file:
524
- encrypted_data: bytes = await encrypted_file.read()
525
-
526
603
  decrypted_data: bytes = cipher.decrypt(encrypted_data)
527
604
 
528
605
  try:
529
606
  decrypted_data = unpad(decrypted_data, AES.block_size)
530
607
  except ValueError as e:
531
608
  _LOGGER.debug("Warning: Padding error occurred, ignoring error: %s", e)
532
-
533
- if Path(file_path).exists():
534
- Path(file_path).unlink()
535
609
  return decrypted_data
536
610
 
537
- async def _concat_segments(self, ts_files: list[Path], output_file):
538
- """Concatenate a list of .ts segments into a single output file without using a temporary file.
611
+ async def _concat_segments(self, ts_files: list[Path], output_file) -> None:
612
+ """Concatenate a list of .mp4 segments into a single output file without using a temporary file.
539
613
 
540
- :param ts_files: List of absolute paths of .ts files
614
+ :param ts_files: List of absolute paths of .mp4 files
541
615
  :param output_file: Path of the output file (e.g., "output.mp4")
542
616
  """
543
617
  full_output_file = await self.get_fpath(output_file)
@@ -585,8 +659,11 @@ class DownloadDecryptMedia:
585
659
  except OSError as e:
586
660
  _LOGGER.error("OS error during concatenation: %s", e)
587
661
 
588
- async def _delete_segments(self, ts_files: list[Path]) -> None:
589
- """Delete all segment files after concatenation."""
662
+ @staticmethod
663
+ async def _delete_segments(ts_files: list[Path]) -> None:
664
+ """Delete all segment files after concatenation.
665
+ :param ts_files: List of absolute paths of .mp4 files
666
+ """
590
667
  for file in ts_files:
591
668
  if file.exists():
592
669
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pypetkitapi
3
- Version: 1.10.2
3
+ Version: 1.11.0
4
4
  Summary: Python client for PetKit API
5
5
  License: MIT
6
6
  Author: Jezza34000
@@ -1,19 +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
1
+ pypetkitapi/__init__.py,sha256=shPCRXZDzB8-Pofzju8FSV6FKX8m8F_vPfW9woBu_DQ,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=AWkFMNxDOk2CUE0XJLTZZIRDMvBBZTcR1lLKCtKB7jM,22339
10
+ pypetkitapi/media.py,sha256=BW6WHhGGn7hxdZvN27Rcg6vDu4NXB2Q_nTa8arCvacg,25687
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.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,,
16
+ pypetkitapi-1.11.0.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
17
+ pypetkitapi-1.11.0.dist-info/METADATA,sha256=Xhjy59tO-gmZZodLmf6LDbu0L57IOqFgHL6CdDGfZu4,6256
18
+ pypetkitapi-1.11.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
19
+ pypetkitapi-1.11.0.dist-info/RECORD,,