pypetkitapi 1.10.2__tar.gz → 1.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/PKG-INFO +1 -1
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/__init__.py +1 -1
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/bluetooth.py +31 -11
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/client.py +9 -4
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/const.py +1 -1
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/media.py +195 -118
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pyproject.toml +2 -2
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/LICENSE +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/README.md +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/command.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/containers.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/exceptions.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/feeder_container.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/litter_container.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/purifier_container.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/py.typed +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/schedule_container.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/utils.py +0 -0
- {pypetkitapi-1.10.2 → pypetkitapi-1.11.0}/pypetkitapi/water_fountain_container.py +0 -0
@@ -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
|
@@ -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.
|
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.
|
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]
|
@@ -31,8 +31,8 @@ _LOGGER = logging.getLogger(__name__)
|
|
31
31
|
|
32
32
|
@dataclass
|
33
33
|
class MediaCloud:
|
34
|
-
"""Dataclass
|
35
|
-
Represents a media file from
|
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
|
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
|
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
|
104
|
+
async def gather_all_media_from_disk(
|
102
105
|
self, storage_path: Path, device_id: int
|
103
|
-
) ->
|
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(
|
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("
|
133
|
+
_LOGGER.debug("Path does not exist, skip : %s", subdir)
|
124
134
|
continue
|
125
135
|
|
126
|
-
_LOGGER.debug("Scanning
|
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("
|
140
|
+
_LOGGER.debug("Media found: %s", entry.name)
|
131
141
|
event_id = Path(entry.name).stem
|
132
|
-
timestamp =
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
249
|
-
|
250
|
-
|
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(
|
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=
|
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.
|
349
|
+
_LOGGER.debug("Missing event_id for record item")
|
318
350
|
continue
|
319
351
|
if not record.aes_key:
|
320
|
-
_LOGGER.
|
352
|
+
_LOGGER.debug("Missing aes_key for record item")
|
321
353
|
continue
|
322
354
|
if record.timestamp is None:
|
323
|
-
_LOGGER.
|
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
|
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(
|
443
|
+
if file_name.endswith(MediaType.IMAGE):
|
387
444
|
subdir = "snapshot"
|
388
|
-
elif file_name.endswith(
|
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
|
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.
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
self.
|
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
|
-
|
408
|
-
|
409
|
-
|
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
|
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",
|
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
|
-
|
428
|
-
|
429
|
-
|
430
|
-
)
|
431
|
-
|
432
|
-
|
433
|
-
|
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,
|
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
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
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
|
-
|
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
|
593
|
+
async def _decrypt_data(encrypted_data: bytes, aes_key: str) -> bytes | None:
|
513
594
|
"""Decrypt a file using AES encryption.
|
514
|
-
:param
|
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 .
|
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 .
|
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
|
-
|
589
|
-
|
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:
|
@@ -187,7 +187,7 @@ build-backend = "poetry.core.masonry.api"
|
|
187
187
|
|
188
188
|
[tool.poetry]
|
189
189
|
name = "pypetkitapi"
|
190
|
-
version = "1.
|
190
|
+
version = "1.11.0"
|
191
191
|
description = "Python client for PetKit API"
|
192
192
|
authors = ["Jezza34000 <info@mail.com>"]
|
193
193
|
readme = "README.md"
|
@@ -209,7 +209,7 @@ ruff = "^0.8.1"
|
|
209
209
|
types-aiofiles = "^24.1.0.20240626"
|
210
210
|
|
211
211
|
[tool.bumpver]
|
212
|
-
current_version = "1.
|
212
|
+
current_version = "1.11.0"
|
213
213
|
version_pattern = "MAJOR.MINOR.PATCH"
|
214
214
|
commit_message = "bump version {old_version} -> {new_version}"
|
215
215
|
tag_message = "{new_version}"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|