pypetkitapi 1.9.2__tar.gz → 1.9.4__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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2020 Jezza34000
3
+ Copyright (c) 2024 - 2025 Jezza34000
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pypetkitapi
3
- Version: 1.9.2
3
+ Version: 1.9.4
4
4
  Summary: Python client for PetKit API
5
- Home-page: https://github.com/Jezza34000/pypetkit
6
5
  License: MIT
7
6
  Author: Jezza34000
8
7
  Author-email: info@mail.com
@@ -14,8 +13,10 @@ Classifier: Programming Language :: Python :: 3.12
14
13
  Classifier: Programming Language :: Python :: 3.13
15
14
  Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
16
15
  Requires-Dist: aiohttp (>=3.10.10,<4.0.0)
16
+ Requires-Dist: m3u8 (>=6.0)
17
17
  Requires-Dist: pycryptodome (>=3.19.1,<4.0.0)
18
18
  Requires-Dist: pydantic (>=1.10.18,<3.0.0)
19
+ Project-URL: Homepage, https://github.com/Jezza34000/pypetkit
19
20
  Description-Content-Type: text/markdown
20
21
 
21
22
  # Petkit API Client
@@ -32,6 +33,12 @@ Description-Content-Type: text/markdown
32
33
  [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
33
34
  [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
34
35
  [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
36
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=bugs)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
37
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
38
+ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
39
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
40
+
41
+ [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
35
42
 
36
43
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
37
44
  [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
@@ -104,7 +111,9 @@ async def main():
104
111
  # simple hopper :
105
112
  await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount": 1})
106
113
  # dual hopper :
107
- await client.send_api_request(123456789, FeederCommand.MANUAL_FEED_DUAL, {"amount1": 2})
114
+ await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount1": 2})
115
+ # or
116
+ await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount2": 2})
108
117
 
109
118
  ### Example 3 : Start the cleaning process
110
119
  ### Device_ID, Command, Payload
@@ -12,6 +12,12 @@
12
12
  [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
13
13
  [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
14
14
  [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
15
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=bugs)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
16
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
17
+ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
18
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
19
+
20
+ [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
15
21
 
16
22
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
17
23
  [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
@@ -84,7 +90,9 @@ async def main():
84
90
  # simple hopper :
85
91
  await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount": 1})
86
92
  # dual hopper :
87
- await client.send_api_request(123456789, FeederCommand.MANUAL_FEED_DUAL, {"amount1": 2})
93
+ await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount1": 2})
94
+ # or
95
+ await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount2": 2})
88
96
 
89
97
  ### Example 3 : Start the cleaning process
90
98
  ### Device_ID, Command, Payload
@@ -36,11 +36,11 @@ from .containers import Pet
36
36
  from .exceptions import PetkitAuthenticationError, PypetkitError
37
37
  from .feeder_container import Feeder, RecordsItems
38
38
  from .litter_container import Litter, LitterRecord, WorkState
39
- from .medias import MediaHandler, MediasFiles
39
+ from .media import DownloadDecryptMedia, MediaFile, MediaManager
40
40
  from .purifier_container import Purifier
41
41
  from .water_fountain_container import WaterFountain
42
42
 
43
- __version__ = "1.9.2"
43
+ __version__ = "1.9.4"
44
44
 
45
45
  __all__ = [
46
46
  "CTW3",
@@ -65,8 +65,9 @@ __all__ = [
65
65
  "Litter",
66
66
  "LitterCommand",
67
67
  "LitterRecord",
68
- "MediaHandler",
69
- "MediasFiles",
68
+ "MediaManager",
69
+ "DownloadDecryptMedia",
70
+ "MediaFile",
70
71
  "Pet",
71
72
  "PetCommand",
72
73
  "PetKitClient",
@@ -11,12 +11,14 @@ import urllib.parse
11
11
 
12
12
  import aiohttp
13
13
  from aiohttp import ContentTypeError
14
+ import m3u8
14
15
 
15
16
  from pypetkitapi.command import ACTIONS_MAP, FOUNTAIN_COMMAND, FountainAction
16
17
  from pypetkitapi.const import (
17
18
  BLE_CONNECT_ATTEMPT,
18
19
  BLE_END_TRAME,
19
20
  BLE_START_TRAME,
21
+ CLIENT_NFO,
20
22
  DEVICE_DATA,
21
23
  DEVICE_RECORDS,
22
24
  DEVICE_STATS,
@@ -25,6 +27,8 @@ from pypetkitapi.const import (
25
27
  DEVICES_PURIFIER,
26
28
  DEVICES_WATER_FOUNTAIN,
27
29
  ERR_KEY,
30
+ LITTER_NO_CAMERA,
31
+ LITTER_WITH_CAMERA,
28
32
  LOGIN_DATA,
29
33
  PET,
30
34
  RES_KEY,
@@ -50,6 +54,7 @@ from pypetkitapi.exceptions import (
50
54
  PetkitInvalidHTTPResponseCodeError,
51
55
  PetkitInvalidResponseFormat,
52
56
  PetkitRegionalServerNotFoundError,
57
+ PetkitSessionError,
53
58
  PetkitSessionExpiredError,
54
59
  PetkitTimeoutError,
55
60
  PypetkitError,
@@ -57,8 +62,22 @@ from pypetkitapi.exceptions import (
57
62
  from pypetkitapi.feeder_container import Feeder, FeederRecord
58
63
  from pypetkitapi.litter_container import Litter, LitterRecord, LitterStats, PetOutGraph
59
64
  from pypetkitapi.purifier_container import Purifier
65
+ from pypetkitapi.utils import get_timezone_offset
60
66
  from pypetkitapi.water_fountain_container import WaterFountain, WaterFountainRecord
61
67
 
68
+ data_handlers = {}
69
+
70
+
71
+ def data_handler(data_type):
72
+ """Register a data handler for a specific data type."""
73
+
74
+ def wrapper(func):
75
+ data_handlers[data_type] = func
76
+ return func
77
+
78
+ return wrapper
79
+
80
+
62
81
  _LOGGER = logging.getLogger(__name__)
63
82
 
64
83
 
@@ -86,7 +105,9 @@ class PetKitClient:
86
105
  self.petkit_entities = {}
87
106
  self.aiohttp_session = session or aiohttp.ClientSession()
88
107
  self.req = PrepReq(
89
- base_url=PetkitDomain.PASSPORT_PETKIT, session=self.aiohttp_session
108
+ base_url=PetkitDomain.PASSPORT_PETKIT,
109
+ session=self.aiohttp_session,
110
+ timezone=self.timezone,
90
111
  )
91
112
 
92
113
  async def _get_base_url(self) -> None:
@@ -129,12 +150,18 @@ class PetKitClient:
129
150
  async def login(self, valid_code: str | None = None) -> None:
130
151
  """Login to the PetKit service and retrieve the appropriate server."""
131
152
  # Retrieve the list of servers
153
+ self._session = None
132
154
  await self._get_base_url()
133
155
 
134
156
  _LOGGER.info("Logging in to PetKit server")
135
157
 
136
158
  # Prepare the data to send
159
+ client_nfo = CLIENT_NFO.copy()
160
+ client_nfo["timezoneId"] = self.timezone
161
+ client_nfo["timezone"] = get_timezone_offset(self.timezone)
162
+
137
163
  data = LOGIN_DATA.copy()
164
+ data["client"] = str(client_nfo)
138
165
  data["encrypt"] = "1"
139
166
  data["region"] = self.region
140
167
  data["username"] = self.username
@@ -156,6 +183,8 @@ class PetKitClient:
156
183
  )
157
184
  session_data = response["session"]
158
185
  self._session = SessionInfo(**session_data)
186
+ expiration_date = datetime.now() + timedelta(seconds=self._session.expires_in)
187
+ _LOGGER.debug("Login successful (token expiration %s)", expiration_date)
159
188
 
160
189
  async def refresh_session(self) -> None:
161
190
  """Refresh the session."""
@@ -169,6 +198,7 @@ class PetKitClient:
169
198
  session_data = response["session"]
170
199
  self._session = SessionInfo(**session_data)
171
200
  self._session.refreshed_at = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")
201
+ _LOGGER.debug("Session refreshed at %s", self._session.refreshed_at)
172
202
 
173
203
  async def validate_session(self) -> None:
174
204
  """Check if the session is still valid and refresh or re-login if necessary."""
@@ -177,31 +207,27 @@ class PetKitClient:
177
207
  await self.login()
178
208
  return
179
209
 
180
- created_at = datetime.strptime(
181
- self._session.created_at,
182
- "%Y-%m-%dT%H:%M:%S.%f%z",
210
+ created = datetime.strptime(self._session.created_at, "%Y-%m-%dT%H:%M:%S.%f%z")
211
+ is_expired = datetime.now(tz=created.tzinfo) - created >= timedelta(
212
+ seconds=self._session.expires_in
183
213
  )
184
- current_time = datetime.now(tz=created_at.tzinfo)
185
- token_age = current_time - created_at
186
- max_age = timedelta(seconds=self._session.expires_in)
187
- half_max_age = max_age / 2
188
214
 
189
- if token_age > max_age:
215
+ if is_expired:
190
216
  _LOGGER.debug("Token expired, re-logging in")
191
217
  await self.login()
192
- elif half_max_age < token_age <= max_age:
193
- _LOGGER.debug("Token still OK, but refreshing session")
194
- await self.refresh_session()
218
+ # elif (max_age / 2) < token_age < max_age:
219
+ # _LOGGER.debug("Token still OK, but refreshing session")
220
+ # await self.refresh_session()
195
221
 
196
222
  async def get_session_id(self) -> dict:
197
223
  """Return the session ID."""
224
+ await self.validate_session()
198
225
  if self._session is None:
199
- raise PypetkitError("Session is not initialized.")
226
+ raise PetkitSessionError("No session ID available")
200
227
  return {"F-Session": self._session.id, "X-Session": self._session.id}
201
228
 
202
229
  async def _get_account_data(self) -> None:
203
230
  """Get the account data from the PetKit service."""
204
- await self.validate_session()
205
231
  _LOGGER.debug("Fetching account data")
206
232
  response = await self.req.request(
207
233
  method=HTTPMethod.GET,
@@ -223,27 +249,40 @@ class PetKitClient:
223
249
  groupId=0,
224
250
  type=0,
225
251
  typeCode=0,
226
- uniqueId=pet.sn,
252
+ uniqueId=str(pet.sn),
227
253
  )
228
254
 
229
255
  async def get_devices_data(self) -> None:
230
256
  """Get the devices data from the PetKit servers."""
231
- await self.validate_session()
232
-
233
257
  start_time = datetime.now()
234
258
  if not self.account_data:
235
259
  await self._get_account_data()
236
260
 
237
- main_tasks = []
238
- record_tasks = []
239
- device_list: list[Device] = []
261
+ device_list = self._collect_devices()
262
+ main_tasks, record_tasks = self._prepare_tasks(device_list)
240
263
 
264
+ await asyncio.gather(*main_tasks)
265
+ await asyncio.gather(*record_tasks)
266
+ await self._execute_stats_tasks()
267
+
268
+ end_time = datetime.now()
269
+ _LOGGER.debug("Petkit data fetched successfully in: %s", end_time - start_time)
270
+
271
+ def _collect_devices(self) -> list[Device]:
272
+ """Collect all devices from account data."""
273
+ device_list = []
241
274
  for account in self.account_data:
242
275
  _LOGGER.debug("List devices data for account: %s", account)
243
276
  if account.device_list:
244
277
  _LOGGER.debug("Devices in account: %s", account.device_list)
245
278
  device_list.extend(account.device_list)
246
279
  _LOGGER.debug("Found %s devices", len(account.device_list))
280
+ return device_list
281
+
282
+ def _prepare_tasks(self, device_list: list[Device]) -> tuple[list, list]:
283
+ """Prepare main and record tasks based on device types."""
284
+ main_tasks = []
285
+ record_tasks = []
247
286
 
248
287
  for device in device_list:
249
288
  device_type = device.device_type
@@ -253,15 +292,9 @@ class PetKitClient:
253
292
  record_tasks.append(self._fetch_device_data(device, FeederRecord))
254
293
 
255
294
  elif device_type in DEVICES_LITTER_BOX:
256
- main_tasks.append(
257
- self._fetch_device_data(device, Litter),
258
- )
295
+ main_tasks.append(self._fetch_device_data(device, Litter))
259
296
  record_tasks.append(self._fetch_device_data(device, LitterRecord))
260
-
261
- if device_type in [T3, T4]:
262
- record_tasks.append(self._fetch_device_data(device, LitterStats))
263
- if device_type in [T5, T6]:
264
- record_tasks.append(self._fetch_device_data(device, PetOutGraph))
297
+ self._add_litter_box_tasks(record_tasks, device_type, device)
265
298
 
266
299
  elif device_type in DEVICES_WATER_FOUNTAIN:
267
300
  main_tasks.append(self._fetch_device_data(device, WaterFountain))
@@ -272,26 +305,26 @@ class PetKitClient:
272
305
  elif device_type in DEVICES_PURIFIER:
273
306
  main_tasks.append(self._fetch_device_data(device, Purifier))
274
307
 
275
- # Execute main device tasks first
276
- await asyncio.gather(*main_tasks)
308
+ return main_tasks, record_tasks
277
309
 
278
- # Then execute record tasks
279
- await asyncio.gather(*record_tasks)
310
+ def _add_litter_box_tasks(
311
+ self, record_tasks: list, device_type: str, device: Device
312
+ ):
313
+ """Add specific tasks for litter box devices."""
314
+ if device_type in [T3, T4]:
315
+ record_tasks.append(self._fetch_device_data(device, LitterStats))
316
+ if device_type in [T5, T6]:
317
+ record_tasks.append(self._fetch_device_data(device, PetOutGraph))
280
318
 
281
- # Add populate_pet_stats tasks
319
+ async def _execute_stats_tasks(self) -> None:
320
+ """Execute tasks to populate pet stats."""
282
321
  stats_tasks = [
283
- self.populate_pet_stats(self.petkit_entities[device.device_id])
284
- for device in device_list
285
- if device.device_type in DEVICES_LITTER_BOX
322
+ self.populate_pet_stats(entity)
323
+ for device_id, entity in self.petkit_entities.items()
324
+ if isinstance(entity, Litter)
286
325
  ]
287
-
288
- # Execute stats tasks
289
326
  await asyncio.gather(*stats_tasks)
290
327
 
291
- end_time = datetime.now()
292
- total_time = end_time - start_time
293
- _LOGGER.debug("Petkit data fetched successfully in: %s", total_time)
294
-
295
328
  async def _fetch_device_data(
296
329
  self,
297
330
  device: Device,
@@ -345,23 +378,48 @@ class PetKitClient:
345
378
  _LOGGER.error("Unexpected response type: %s", type(response))
346
379
  return
347
380
 
348
- if data_class.data_type == DEVICE_DATA:
349
- self.petkit_entities[device.device_id] = device_data
350
- self.petkit_entities[device.device_id].device_nfo = device
351
- _LOGGER.debug("Device data fetched OK for %s", device_type)
352
- elif data_class.data_type == DEVICE_RECORDS:
353
- self.petkit_entities[device.device_id].device_records = device_data
381
+ # Dispatch to the appropriate handler
382
+ handler = data_handlers.get(data_class.data_type)
383
+ if handler:
384
+ await handler(self, device, device_data, device_type)
385
+ else:
386
+ _LOGGER.error("Unknown data type: %s", data_class.data_type)
387
+
388
+ @data_handler(DEVICE_DATA)
389
+ async def _handle_device_data(self, device, device_data, device_type):
390
+ """Handle device data."""
391
+ self.petkit_entities[device.device_id] = device_data
392
+ self.petkit_entities[device.device_id].device_nfo = device
393
+ _LOGGER.debug("Device data fetched OK for %s", device_type)
394
+
395
+ @data_handler(DEVICE_RECORDS)
396
+ async def _handle_device_records(self, device, device_data, device_type):
397
+ """Handle device records."""
398
+ entity = self.petkit_entities.get(device.device_id)
399
+ if entity and isinstance(entity, (Feeder, Litter, WaterFountain)):
400
+ entity.device_records = device_data
354
401
  _LOGGER.debug("Device records fetched OK for %s", device_type)
355
- elif data_class.data_type == DEVICE_STATS:
356
- if device_type in [T3, T4]:
357
- self.petkit_entities[device.device_id].device_stats = device_data
358
- if device_type in [T5, T6]:
359
- self.petkit_entities[device.device_id].device_pet_graph_out = (
360
- device_data
361
- )
402
+ else:
403
+ _LOGGER.warning(
404
+ "Cannot assign device_records to entity of type %s",
405
+ type(entity),
406
+ )
407
+
408
+ @data_handler(DEVICE_STATS)
409
+ async def _handle_device_stats(self, device, device_data, device_type):
410
+ """Handle device stats."""
411
+ entity = self.petkit_entities.get(device.device_id)
412
+ if isinstance(entity, Litter):
413
+ if device_type in LITTER_NO_CAMERA:
414
+ entity.device_stats = device_data
415
+ if device_type in LITTER_WITH_CAMERA:
416
+ entity.device_pet_graph_out = device_data
362
417
  _LOGGER.debug("Device stats fetched OK for %s", device_type)
363
418
  else:
364
- _LOGGER.error("Unknown data type: %s", data_class.data_type)
419
+ _LOGGER.warning(
420
+ "Cannot assign device_stats or device_pet_graph_out to entity of type %s",
421
+ type(entity),
422
+ )
365
423
 
366
424
  async def get_pets_list(self) -> list[Pet]:
367
425
  """Extract and return the list of pets."""
@@ -386,6 +444,12 @@ class PetKitClient:
386
444
  async def populate_pet_stats(self, stats_data: Litter) -> None:
387
445
  """Collect data from litter data to populate pet stats."""
388
446
 
447
+ if not stats_data.device_nfo:
448
+ _LOGGER.warning(
449
+ "No device info for %s can't populate pet infos", stats_data
450
+ )
451
+ return
452
+
389
453
  pets_list = await self.get_pets_list()
390
454
  for pet in pets_list:
391
455
  if (
@@ -433,25 +497,27 @@ class PetKitClient:
433
497
  async def _get_fountain_instance(self, fountain_id: int) -> WaterFountain:
434
498
  # Retrieve the water fountain object
435
499
  water_fountain = self.petkit_entities.get(fountain_id)
436
- if not water_fountain:
500
+ if not isinstance(water_fountain, WaterFountain):
437
501
  _LOGGER.error("Water fountain with ID %s not found.", fountain_id)
438
- raise ValueError(f"Water fountain with ID {fountain_id} not found.")
502
+ raise TypeError(f"Water fountain with ID {fountain_id} not found.")
439
503
  return water_fountain
440
504
 
441
505
  async def check_relay_availability(self, fountain_id: int) -> bool:
442
506
  """Check if BLE relay is available for the account."""
443
507
  fountain = None
508
+
444
509
  for account in self.account_data:
445
- fountain = next(
446
- (
447
- device
448
- for device in account.device_list
449
- if device.device_id == fountain_id
450
- ),
451
- None,
452
- )
453
- if fountain:
454
- break
510
+ if account.device_list:
511
+ fountain = next(
512
+ (
513
+ device
514
+ for device in account.device_list
515
+ if device.device_id == fountain_id
516
+ ),
517
+ None,
518
+ )
519
+ if fountain:
520
+ break
455
521
 
456
522
  if not fountain:
457
523
  raise ValueError(
@@ -568,13 +634,13 @@ class PetKitClient:
568
634
  _LOGGER.error("BLE connection not established.")
569
635
  return False
570
636
 
571
- command = FOUNTAIN_COMMAND.get[command, None]
572
- if command is None:
637
+ command_data = FOUNTAIN_COMMAND.get(command)
638
+ if command_data is None:
573
639
  _LOGGER.error("Command not found.")
574
640
  return False
575
641
 
576
642
  cmd_code, cmd_data = await self.get_ble_cmd_data(
577
- command, water_fountain.ble_counter
643
+ list(command_data), water_fountain.ble_counter
578
644
  )
579
645
 
580
646
  response = await self.req.request(
@@ -595,6 +661,46 @@ class PetKitClient:
595
661
  _LOGGER.info("BLE command sent successfully.")
596
662
  return True
597
663
 
664
+ async def get_cloud_video(self, video_url: str) -> dict[str, str | int]:
665
+ """Get the video m3u8 link from the cloud."""
666
+ response = await self.req.request(
667
+ method=HTTPMethod.POST,
668
+ url=video_url,
669
+ headers=await self.get_session_id(),
670
+ )
671
+ return response[0]
672
+
673
+ async def extract_segments_m3u8(self, m3u8_url: str) -> tuple[str, str, list[str]]:
674
+ """Extract segments from the m3u8 file.
675
+ :param: m3u8_url: URL of the m3u8 file
676
+ :return: aes_key, key_iv, segment_lst
677
+ """
678
+ # Extract segments from m3u8 file
679
+ response = await self.req.request(
680
+ method=HTTPMethod.GET,
681
+ url=m3u8_url,
682
+ headers=await self.get_session_id(),
683
+ )
684
+ m3u8_obj = m3u8.loads(response[RES_KEY])
685
+
686
+ if not m3u8_obj.segments or not m3u8_obj.keys:
687
+ raise PetkitInvalidResponseFormat("No segments or key found in m3u8 file.")
688
+
689
+ # Extract segments from m3u8 file
690
+ segment_lst = [segment.uri for segment in m3u8_obj.segments]
691
+ # Extract key_uri and key_iv from m3u8 file
692
+ key_uri = m3u8_obj.keys[0].uri
693
+ key_iv = str(m3u8_obj.keys[0].iv)
694
+
695
+ # Extract aes_key from video segments
696
+ response = await self.req.request(
697
+ method=HTTPMethod.GET,
698
+ url=key_uri,
699
+ full_url=True,
700
+ headers=await self.get_session_id(),
701
+ )
702
+ return response[RES_KEY], key_iv, segment_lst
703
+
598
704
  async def send_api_request(
599
705
  self,
600
706
  device_id: int,
@@ -602,11 +708,11 @@ class PetKitClient:
602
708
  setting: dict | None = None,
603
709
  ) -> bool:
604
710
  """Control the device using the PetKit API."""
605
- await self.validate_session()
606
-
607
711
  device = self.petkit_entities.get(device_id, None)
608
712
  if not device:
609
713
  raise PypetkitError(f"Device with ID {device_id} not found.")
714
+ if device.device_nfo is None:
715
+ raise PypetkitError(f"Device with ID {device_id} has no device_nfo.")
610
716
 
611
717
  _LOGGER.debug(
612
718
  "Control API device=%s id=%s action=%s param=%s",
@@ -669,41 +775,43 @@ class PetKitClient:
669
775
  class PrepReq:
670
776
  """Prepare the request to the PetKit API."""
671
777
 
672
- def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None:
778
+ def __init__(
779
+ self, base_url: str, session: aiohttp.ClientSession, timezone: str
780
+ ) -> None:
673
781
  """Initialize the request."""
674
782
  self.base_url = base_url
675
783
  self.session = session
784
+ self.timezone = timezone
676
785
  self.base_headers = self._generate_header()
677
786
 
678
- @staticmethod
679
- def _generate_header() -> dict[str, str]:
787
+ def _generate_header(self) -> dict[str, str]:
680
788
  """Create header for interaction with API endpoint."""
681
-
682
789
  return {
683
790
  "Accept": Header.ACCEPT.value,
684
- "Accept-Language": Header.ACCEPT_LANG,
685
- "Accept-Encoding": Header.ENCODING,
686
- "Content-Type": Header.CONTENT_TYPE,
687
- "User-Agent": Header.AGENT,
688
- "X-Img-Version": Header.IMG_VERSION,
689
- "X-Locale": Header.LOCALE,
690
- "X-Client": Header.CLIENT,
691
- "X-Hour": Header.HOUR,
692
- "X-TimezoneId": Header.TIMEZONE_ID,
693
- "X-Api-Version": Header.API_VERSION,
694
- "X-Timezone": Header.TIMEZONE,
791
+ "Accept-Language": Header.ACCEPT_LANG.value,
792
+ "Accept-Encoding": Header.ENCODING.value,
793
+ "Content-Type": Header.CONTENT_TYPE.value,
794
+ "User-Agent": Header.AGENT.value,
795
+ "X-Img-Version": Header.IMG_VERSION.value,
796
+ "X-Locale": Header.LOCALE.value,
797
+ "X-Client": Header.CLIENT.value,
798
+ "X-Hour": Header.HOUR.value,
799
+ "X-TimezoneId": self.timezone,
800
+ "X-Api-Version": Header.API_VERSION.value,
801
+ "X-Timezone": get_timezone_offset(self.timezone),
695
802
  }
696
803
 
697
804
  async def request(
698
805
  self,
699
806
  method: str,
700
807
  url: str,
808
+ full_url: bool = False,
701
809
  params=None,
702
810
  data=None,
703
811
  headers=None,
704
812
  ) -> dict:
705
813
  """Make a request to the PetKit API."""
706
- _url = "/".join(s.strip("/") for s in [self.base_url, url])
814
+ _url = url if full_url else "/".join(s.strip("/") for s in [self.base_url, url])
707
815
  _headers = {**self.base_headers, **(headers or {})}
708
816
  _LOGGER.debug("Request: %s %s", method, _url)
709
817
  try:
@@ -729,12 +837,14 @@ class PrepReq:
729
837
  ) from e
730
838
 
731
839
  try:
732
- response_json = await response.json()
840
+ if response.content_type == "application/json":
841
+ response_json = await response.json()
842
+ else:
843
+ return {RES_KEY: await response.text()}
733
844
  except ContentTypeError:
734
845
  raise PetkitInvalidResponseFormat(
735
846
  "Response is not in JSON format"
736
847
  ) from None
737
-
738
848
  # Check for errors in the response
739
849
  if ERR_KEY in response_json:
740
850
  error_code = int(response_json[ERR_KEY].get("code", 0))