pypetkitapi 1.9.1__py3-none-any.whl → 1.9.3__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
@@ -40,7 +40,7 @@ from .medias import MediaHandler, MediasFiles
40
40
  from .purifier_container import Purifier
41
41
  from .water_fountain_container import WaterFountain
42
42
 
43
- __version__ = "1.9.1"
43
+ __version__ = "1.9.3"
44
44
 
45
45
  __all__ = [
46
46
  "CTW3",
pypetkitapi/client.py CHANGED
@@ -17,6 +17,7 @@ from pypetkitapi.const import (
17
17
  BLE_CONNECT_ATTEMPT,
18
18
  BLE_END_TRAME,
19
19
  BLE_START_TRAME,
20
+ CLIENT_NFO,
20
21
  DEVICE_DATA,
21
22
  DEVICE_RECORDS,
22
23
  DEVICE_STATS,
@@ -25,6 +26,8 @@ from pypetkitapi.const import (
25
26
  DEVICES_PURIFIER,
26
27
  DEVICES_WATER_FOUNTAIN,
27
28
  ERR_KEY,
29
+ LITTER_NO_CAMERA,
30
+ LITTER_WITH_CAMERA,
28
31
  LOGIN_DATA,
29
32
  PET,
30
33
  RES_KEY,
@@ -57,8 +60,22 @@ from pypetkitapi.exceptions import (
57
60
  from pypetkitapi.feeder_container import Feeder, FeederRecord
58
61
  from pypetkitapi.litter_container import Litter, LitterRecord, LitterStats, PetOutGraph
59
62
  from pypetkitapi.purifier_container import Purifier
63
+ from pypetkitapi.utils import get_timezone_offset
60
64
  from pypetkitapi.water_fountain_container import WaterFountain, WaterFountainRecord
61
65
 
66
+ data_handlers = {}
67
+
68
+
69
+ def data_handler(data_type):
70
+ """Register a data handler for a specific data type."""
71
+
72
+ def wrapper(func):
73
+ data_handlers[data_type] = func
74
+ return func
75
+
76
+ return wrapper
77
+
78
+
62
79
  _LOGGER = logging.getLogger(__name__)
63
80
 
64
81
 
@@ -86,7 +103,9 @@ class PetKitClient:
86
103
  self.petkit_entities = {}
87
104
  self.aiohttp_session = session or aiohttp.ClientSession()
88
105
  self.req = PrepReq(
89
- base_url=PetkitDomain.PASSPORT_PETKIT, session=self.aiohttp_session
106
+ base_url=PetkitDomain.PASSPORT_PETKIT,
107
+ session=self.aiohttp_session,
108
+ timezone=self.timezone,
90
109
  )
91
110
 
92
111
  async def _get_base_url(self) -> None:
@@ -134,7 +153,12 @@ class PetKitClient:
134
153
  _LOGGER.info("Logging in to PetKit server")
135
154
 
136
155
  # Prepare the data to send
156
+ client_nfo = CLIENT_NFO.copy()
157
+ client_nfo["timezoneId"] = self.timezone
158
+ client_nfo["timezone"] = get_timezone_offset(self.timezone)
159
+
137
160
  data = LOGIN_DATA.copy()
161
+ data["client"] = str(client_nfo)
138
162
  data["encrypt"] = "1"
139
163
  data["region"] = self.region
140
164
  data["username"] = self.username
@@ -223,7 +247,7 @@ class PetKitClient:
223
247
  groupId=0,
224
248
  type=0,
225
249
  typeCode=0,
226
- uniqueId=pet.sn,
250
+ uniqueId=str(pet.sn),
227
251
  )
228
252
 
229
253
  async def get_devices_data(self) -> None:
@@ -234,16 +258,31 @@ class PetKitClient:
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)
263
+
264
+ await asyncio.gather(*main_tasks)
265
+ await asyncio.gather(*record_tasks)
266
+ await self._execute_stats_tasks()
240
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(
@@ -607,6 +673,8 @@ class PetKitClient:
607
673
  device = self.petkit_entities.get(device_id, None)
608
674
  if not device:
609
675
  raise PypetkitError(f"Device with ID {device_id} not found.")
676
+ if device.device_nfo is None:
677
+ raise PypetkitError(f"Device with ID {device_id} has no device_nfo.")
610
678
 
611
679
  _LOGGER.debug(
612
680
  "Control API device=%s id=%s action=%s param=%s",
@@ -669,29 +737,30 @@ class PetKitClient:
669
737
  class PrepReq:
670
738
  """Prepare the request to the PetKit API."""
671
739
 
672
- def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None:
740
+ def __init__(
741
+ self, base_url: str, session: aiohttp.ClientSession, timezone: str
742
+ ) -> None:
673
743
  """Initialize the request."""
674
744
  self.base_url = base_url
675
745
  self.session = session
746
+ self.timezone = timezone
676
747
  self.base_headers = self._generate_header()
677
748
 
678
- @staticmethod
679
- def _generate_header() -> dict[str, str]:
749
+ def _generate_header(self) -> dict[str, str]:
680
750
  """Create header for interaction with API endpoint."""
681
-
682
751
  return {
683
752
  "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,
753
+ "Accept-Language": Header.ACCEPT_LANG.value,
754
+ "Accept-Encoding": Header.ENCODING.value,
755
+ "Content-Type": Header.CONTENT_TYPE.value,
756
+ "User-Agent": Header.AGENT.value,
757
+ "X-Img-Version": Header.IMG_VERSION.value,
758
+ "X-Locale": Header.LOCALE.value,
759
+ "X-Client": Header.CLIENT.value,
760
+ "X-Hour": Header.HOUR.value,
761
+ "X-TimezoneId": self.timezone,
762
+ "X-Api-Version": Header.API_VERSION.value,
763
+ "X-Timezone": get_timezone_offset(self.timezone),
695
764
  }
696
765
 
697
766
  async def request(
pypetkitapi/const.py CHANGED
@@ -35,6 +35,8 @@ K3 = "k3"
35
35
  PET = "pet"
36
36
 
37
37
  DEVICES_LITTER_BOX = [T3, T4, T5, T6]
38
+ LITTER_WITH_CAMERA = [T5, T6]
39
+ LITTER_NO_CAMERA = [T3, T4]
38
40
  DEVICES_FEEDER = [FEEDER, FEEDER_MINI, D3, D4, D4S, D4H, D4SH]
39
41
  DEVICES_WATER_FOUNTAIN = [W5, CTW3]
40
42
  DEVICES_PURIFIER = [K2]
@@ -68,12 +70,10 @@ class Header(StrEnum):
68
70
  ACCEPT = "*/*"
69
71
  ACCEPT_LANG = "en-US;q=1, it-US;q=0.9"
70
72
  ENCODING = "gzip, deflate"
71
- API_VERSION = "11.3.1"
73
+ API_VERSION = "11.4.0"
72
74
  CONTENT_TYPE = "application/x-www-form-urlencoded"
73
75
  AGENT = "okhttp/3.12.11"
74
76
  CLIENT = f"{Client.PLATFORM_TYPE}({Client.OS_VERSION};{Client.MODEL_NAME})"
75
- TIMEZONE = "1.0"
76
- TIMEZONE_ID = "Europe/Paris" # TODO: Make this dynamic
77
77
  LOCALE = "en-US"
78
78
  IMG_VERSION = "1.0"
79
79
  HOUR = "24"
@@ -85,14 +85,11 @@ CLIENT_NFO = {
85
85
  "osVersion": Client.OS_VERSION.value,
86
86
  "platform": Client.PLATFORM_TYPE.value,
87
87
  "source": Client.SOURCE.value,
88
- "timezone": Header.TIMEZONE.value, # TODO: Make this dynamic
89
- "timezoneId": Header.TIMEZONE_ID.value,
90
88
  "version": Header.API_VERSION.value,
91
89
  }
92
90
 
93
91
  LOGIN_DATA = {
94
- "client": str(CLIENT_NFO),
95
- "oldVersion": Header.API_VERSION,
92
+ "oldVersion": Header.API_VERSION.value,
96
93
  }
97
94
 
98
95
 
@@ -5,7 +5,15 @@ from typing import Any, ClassVar
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from pypetkitapi.const import D3, D4, D4S, DEVICE_DATA, DEVICE_RECORDS, PetkitEndpoint
8
+ from pypetkitapi.const import (
9
+ D3,
10
+ D4,
11
+ D4S,
12
+ DEVICE_DATA,
13
+ DEVICE_RECORDS,
14
+ FEEDER_MINI,
15
+ PetkitEndpoint,
16
+ )
9
17
  from pypetkitapi.containers import CloudProduct, Device, FirmwareDetail, Wifi
10
18
 
11
19
 
@@ -41,7 +49,7 @@ class CameraMultiNew(BaseModel):
41
49
 
42
50
  enable: int | None = None
43
51
  rpt: str | None = None
44
- time: list[tuple[int, int]] | None = None
52
+ time: list[list[int]] | None = None
45
53
 
46
54
 
47
55
  class SettingsFeeder(BaseModel):
@@ -77,9 +85,7 @@ class SettingsFeeder(BaseModel):
77
85
  highlight: int | None = None
78
86
  light_config: int | None = Field(None, alias="lightConfig")
79
87
  light_mode: int | None = Field(None, alias="lightMode")
80
- light_multi_range: list[tuple[int, int]] | None = Field(
81
- None, alias="lightMultiRange"
82
- )
88
+ light_multi_range: list[list[int]] | None = Field(None, alias="lightMultiRange")
83
89
  live_encrypt: int | None = Field(None, alias="liveEncrypt")
84
90
  low_battery_notify: int | None = Field(None, alias="lowBatteryNotify")
85
91
  manual_lock: int | None = Field(None, alias="manualLock")
@@ -104,7 +110,7 @@ class SettingsFeeder(BaseModel):
104
110
  time_display: int | None = Field(None, alias="timeDisplay")
105
111
  tone_config: int | None = Field(None, alias="toneConfig")
106
112
  tone_mode: int | None = Field(None, alias="toneMode")
107
- tone_multi_range: list[tuple[int, int]] | None = Field(None, alias="toneMultiRange")
113
+ tone_multi_range: list[list[int]] | None = Field(None, alias="toneMultiRange")
108
114
  upload: int | None = None
109
115
  volume: int | None = None
110
116
 
@@ -276,8 +282,10 @@ class FeederRecord(BaseModel):
276
282
  return PetkitEndpoint.DAILY_FEED_AND_EAT
277
283
  if device_type == D4:
278
284
  return PetkitEndpoint.FEED_STATISTIC
279
- if device_type == D4S:
285
+ if device_type in D4S:
280
286
  return PetkitEndpoint.DAILY_FEED
287
+ if device_type in FEEDER_MINI:
288
+ return PetkitEndpoint.DAILY_FEED.lower() # Workaround for Feeder Mini
281
289
  return PetkitEndpoint.GET_DEVICE_RECORD
282
290
 
283
291
  @classmethod
@@ -305,8 +313,9 @@ class Feeder(BaseModel):
305
313
  bt_mac: str | None = Field(None, alias="btMac")
306
314
  cloud_product: CloudProduct | None = Field(None, alias="cloudProduct")
307
315
  created_at: str | None = Field(None, alias="createdAt")
316
+ desc: str | None = None # D3
308
317
  device_records: FeederRecord | None = None
309
- firmware: float
318
+ firmware: str
310
319
  firmware_details: list[FirmwareDetail] | None = Field(None, alias="firmwareDetails")
311
320
  hardware: int
312
321
  id: int
@@ -9,10 +9,9 @@ from pypetkitapi.const import (
9
9
  DEVICE_DATA,
10
10
  DEVICE_RECORDS,
11
11
  DEVICE_STATS,
12
+ LITTER_NO_CAMERA,
13
+ LITTER_WITH_CAMERA,
12
14
  T3,
13
- T4,
14
- T5,
15
- T6,
16
15
  PetkitEndpoint,
17
16
  )
18
17
  from pypetkitapi.containers import CloudProduct, Device, FirmwareDetail, Wifi
@@ -240,9 +239,9 @@ class LitterRecord(BaseModel):
240
239
  @classmethod
241
240
  def get_endpoint(cls, device_type: str) -> str:
242
241
  """Get the endpoint URL for the given device type."""
243
- if device_type in [T3, T4]:
242
+ if device_type in LITTER_NO_CAMERA:
244
243
  return PetkitEndpoint.GET_DEVICE_RECORD
245
- if device_type in [T5, T6]:
244
+ if device_type in LITTER_WITH_CAMERA:
246
245
  return PetkitEndpoint.GET_DEVICE_RECORD_RELEASE
247
246
  raise ValueError(f"Invalid device type: {device_type}")
248
247
 
@@ -255,11 +254,11 @@ class LitterRecord(BaseModel):
255
254
  ) -> dict:
256
255
  """Generate query parameters including request_date."""
257
256
  device_type = device.device_type
258
- if device_type in [T3, T4]:
257
+ if device_type in LITTER_NO_CAMERA:
259
258
  request_date = request_date or datetime.now().strftime("%Y%m%d")
260
259
  key = "day" if device_type == T3 else "date"
261
260
  return {key: int(request_date), "deviceId": device.device_id}
262
- if device_type in [T5, T6]:
261
+ if device_type in LITTER_WITH_CAMERA:
263
262
  return {
264
263
  "timestamp": int(datetime.now().timestamp()),
265
264
  "deviceId": device.device_id,
@@ -407,7 +406,7 @@ class K3Device(BaseModel):
407
406
 
408
407
  class Litter(BaseModel):
409
408
  """Dataclass for Litter Data.
410
- Supported devices = T3, T4, T6
409
+ Supported devices = T3, T4, T5, T6
411
410
  """
412
411
 
413
412
  data_type: ClassVar[str] = DEVICE_DATA
pypetkitapi/medias.py CHANGED
@@ -4,13 +4,14 @@ from dataclasses import dataclass
4
4
  import logging
5
5
  from pathlib import Path
6
6
  import re
7
+ from typing import Any
7
8
 
8
9
  from aiofiles import open as aio_open
9
10
  import aiohttp
10
11
  from Crypto.Cipher import AES
11
12
  from Crypto.Util.Padding import unpad
12
13
 
13
- from pypetkitapi.feeder_container import Feeder, RecordsItems
14
+ from pypetkitapi.feeder_container import Feeder, RecordsType
14
15
 
15
16
  _LOGGER = logging.getLogger(__name__)
16
17
 
@@ -68,7 +69,7 @@ class MediaHandler:
68
69
  return self.media_files
69
70
 
70
71
  async def _process_records(
71
- self, records: RecordsItems, record_type: str
72
+ self, records: RecordsType, record_type: str
72
73
  ) -> list[MediasFiles]:
73
74
  """Process individual records and return media info."""
74
75
  media_files = []
@@ -104,8 +105,8 @@ class MediaHandler:
104
105
  )
105
106
 
106
107
  for record in records:
107
- if record.items:
108
- await process_item(record.items)
108
+ if hasattr(record, "items"):
109
+ await process_item(record.items) # type: ignore[attr-defined]
109
110
 
110
111
  return media_files
111
112
 
@@ -170,9 +171,8 @@ class MediaDownloadDecode:
170
171
  )
171
172
  return file_path
172
173
 
173
- async def _decrypt_image_from_file(
174
- self, file_path: Path, aes_key: str
175
- ) -> bytes | None:
174
+ @staticmethod
175
+ async def _decrypt_image_from_file(file_path: Path, aes_key: str) -> bytes | None:
176
176
  """Decrypt an image from a file using AES encryption.
177
177
  :param file_path: Path to the encrypted image file.
178
178
  :param aes_key: AES key used for decryption.
@@ -183,13 +183,13 @@ class MediaDownloadDecode:
183
183
  aes_key = aes_key[:-1]
184
184
  key_bytes: bytes = aes_key.encode("utf-8")
185
185
  iv: bytes = b"\x61" * 16
186
- cipher: AES = AES.new(key_bytes, AES.MODE_CBC, iv)
186
+ cipher: Any = AES.new(key_bytes, AES.MODE_CBC, iv)
187
187
 
188
188
  async with aio_open(file_path, "rb") as encrypted_file:
189
189
  encrypted_data: bytes = await encrypted_file.read()
190
190
 
191
191
  decrypted_data: bytes = unpad(
192
- cipher.decrypt(encrypted_data), AES.block_size
192
+ cipher.decrypt(encrypted_data), AES.block_size # type: ignore[attr-defined]
193
193
  )
194
194
  except Exception as e: # noqa: BLE001
195
195
  logging.error("Error decrypting image from file %s: %s", file_path, e)
pypetkitapi/utils.py ADDED
@@ -0,0 +1,22 @@
1
+ """Utils functions for the PyPetKit API."""
2
+
3
+ from datetime import datetime
4
+ import logging
5
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+
10
+ def get_timezone_offset(timezone_user: str) -> str:
11
+ """Get the timezone offset from its name. Return 0.0 if an error occurs."""
12
+ try:
13
+ timezone = ZoneInfo(timezone_user)
14
+ now = datetime.now(timezone)
15
+ offset = now.utcoffset()
16
+ if offset is None:
17
+ return "0.0"
18
+ offset_in_hours = offset.total_seconds() / 3600
19
+ return str(offset_in_hours)
20
+ except (ZoneInfoNotFoundError, AttributeError) as e:
21
+ _LOGGER.warning("Cannot get timezone offset for %s: %s", timezone_user, e)
22
+ return "0.0"
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pypetkitapi
3
- Version: 1.9.1
3
+ Version: 1.9.3
4
4
  Summary: Python client for PetKit API
5
5
  Home-page: https://github.com/Jezza34000/pypetkit
6
6
  License: MIT
@@ -32,6 +32,12 @@ Description-Content-Type: text/markdown
32
32
  [![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
33
  [![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
34
  [![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)
35
+ [![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)
36
+ [![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)
37
+ [![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)
38
+ [![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)
39
+
40
+ [![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
41
 
36
42
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
37
43
  [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
@@ -0,0 +1,17 @@
1
+ pypetkitapi/__init__.py,sha256=dQiuXe1aOwEGmW27csDtKRVc4vhXrqvsW-0ElcbrTRY,1562
2
+ pypetkitapi/client.py,sha256=oi1GhGIcvWMP5J9ueN2Y1xDX-Wm91b7LfjTVSe_plk4,30357
3
+ pypetkitapi/command.py,sha256=G7AEtUcaK-lcRliNf4oUxPkvDO_GNBkJ-ZUcOo7DGHM,7697
4
+ pypetkitapi/const.py,sha256=xDsF6sdxqQPy0B0Qhpe0Nn5xrkDjfo_omL4XL_oXFDE,4050
5
+ pypetkitapi/containers.py,sha256=oJR22ZruMr-0IRgiucdnj_nutOH59MKvmaFTwLJNiJI,4635
6
+ pypetkitapi/exceptions.py,sha256=fuTLT6Iw2_kA7eOyNJPf59vQkgfByhAnTThY4lC0Rt0,1283
7
+ pypetkitapi/feeder_container.py,sha256=ZGJhgqP-gjTFB2q91XoyZQ_G1S5cAY37JoqqHbzoanU,14640
8
+ pypetkitapi/litter_container.py,sha256=-z2BtdtRg8RyLJzJYY3AIACs9GGZ0C64hVhW4do6yQo,19172
9
+ pypetkitapi/medias.py,sha256=ZFdiPj24crYYFwKBUqlxKhfKGrW2uXoXzDl2vWukZ-A,7036
10
+ pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
11
+ pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pypetkitapi/utils.py,sha256=z7325kcJQUburnF28HSXrJMvY_gY9007K73Zwxp-4DQ,743
13
+ pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
14
+ pypetkitapi-1.9.3.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
15
+ pypetkitapi-1.9.3.dist-info/METADATA,sha256=p0SF9NMmun43lj81I0Sbc24o7JwFhuQgHoOK4tTquC8,6115
16
+ pypetkitapi-1.9.3.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
17
+ pypetkitapi-1.9.3.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- pypetkitapi/__init__.py,sha256=MLvrbBarlxdGzxkVY1NW80hTgkSy9fYe5_cuy1mnHbQ,1562
2
- pypetkitapi/client.py,sha256=o8dBNxdupWwf7AIt6GB4Jc4SLExc0Zv1E-eX2Qjt5FY,27807
3
- pypetkitapi/command.py,sha256=G7AEtUcaK-lcRliNf4oUxPkvDO_GNBkJ-ZUcOo7DGHM,7697
4
- pypetkitapi/const.py,sha256=pkTJ0l-8mQix9aoJNC2UYfyUdG7ie826xnv7EkOZtPw,4208
5
- pypetkitapi/containers.py,sha256=oJR22ZruMr-0IRgiucdnj_nutOH59MKvmaFTwLJNiJI,4635
6
- pypetkitapi/exceptions.py,sha256=fuTLT6Iw2_kA7eOyNJPf59vQkgfByhAnTThY4lC0Rt0,1283
7
- pypetkitapi/feeder_container.py,sha256=q9lsUvBMxVt-vs8gE3Gg7jVyiIz_eh-3Vq-vMhDIzGA,14472
8
- pypetkitapi/litter_container.py,sha256=qKP3XFUkbzLREZPXEMDvpR1sqo6BI560O6eJYdkrX7w,19110
9
- pypetkitapi/medias.py,sha256=IuWkC7usw0Hbx173X8TGv24jOp4nqv6bIUosZBpXMGg,6945
10
- pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
11
- pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
13
- pypetkitapi-1.9.1.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
14
- pypetkitapi-1.9.1.dist-info/METADATA,sha256=qp69EJwHbxvvY3Ve870iLQ2mmWBUwz6ePPTfn5YpALw,5167
15
- pypetkitapi-1.9.1.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
16
- pypetkitapi-1.9.1.dist-info/RECORD,,