pypetkitapi 1.9.4__tar.gz → 1.10.2__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
  Metadata-Version: 2.3
2
2
  Name: pypetkitapi
3
- Version: 1.9.4
3
+ Version: 1.10.2
4
4
  Summary: Python client for PetKit API
5
5
  License: MIT
6
6
  Author: Jezza34000
@@ -23,24 +23,35 @@ from .const import (
23
23
  DEVICES_WATER_FOUNTAIN,
24
24
  FEEDER,
25
25
  FEEDER_MINI,
26
+ FEEDER_WITH_CAMERA,
26
27
  K2,
27
28
  K3,
29
+ LITTER_NO_CAMERA,
30
+ LITTER_WITH_CAMERA,
28
31
  T3,
29
32
  T4,
30
33
  T5,
31
34
  T6,
32
35
  W5,
36
+ MediaType,
33
37
  RecordType,
34
38
  )
35
39
  from .containers import Pet
36
- from .exceptions import PetkitAuthenticationError, PypetkitError
40
+ from .exceptions import (
41
+ PetkitAuthenticationUnregisteredEmailError,
42
+ PetkitRegionalServerNotFoundError,
43
+ PetkitSessionError,
44
+ PetkitSessionExpiredError,
45
+ PetkitTimeoutError,
46
+ PypetkitError,
47
+ )
37
48
  from .feeder_container import Feeder, RecordsItems
38
49
  from .litter_container import Litter, LitterRecord, WorkState
39
- from .media import DownloadDecryptMedia, MediaFile, MediaManager
50
+ from .media import DownloadDecryptMedia, MediaCloud, MediaFile, MediaManager
40
51
  from .purifier_container import Purifier
41
52
  from .water_fountain_container import WaterFountain
42
53
 
43
- __version__ = "1.9.4"
54
+ __version__ = "1.10.2"
44
55
 
45
56
  __all__ = [
46
57
  "CTW3",
@@ -53,6 +64,9 @@ __all__ = [
53
64
  "DEVICES_LITTER_BOX",
54
65
  "DEVICES_PURIFIER",
55
66
  "DEVICES_WATER_FOUNTAIN",
67
+ "FEEDER_WITH_CAMERA",
68
+ "LITTER_WITH_CAMERA",
69
+ "LITTER_NO_CAMERA",
56
70
  "DeviceAction",
57
71
  "DeviceCommand",
58
72
  "FEEDER",
@@ -67,11 +81,17 @@ __all__ = [
67
81
  "LitterRecord",
68
82
  "MediaManager",
69
83
  "DownloadDecryptMedia",
84
+ "MediaCloud",
70
85
  "MediaFile",
86
+ "MediaType",
71
87
  "Pet",
72
88
  "PetCommand",
73
89
  "PetKitClient",
74
- "PetkitAuthenticationError",
90
+ "PetkitAuthenticationUnregisteredEmailError",
91
+ "PetkitRegionalServerNotFoundError",
92
+ "PetkitSessionError",
93
+ "PetkitSessionExpiredError",
94
+ "PetkitTimeoutError",
75
95
  "PurMode",
76
96
  "Purifier",
77
97
  "PypetkitError",
@@ -0,0 +1,174 @@
1
+ """Module for handling Bluetooth communication with PetKit devices."""
2
+
3
+ import asyncio
4
+ import base64
5
+ from datetime import datetime
6
+ from http import HTTPMethod
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+ import urllib.parse
10
+
11
+ from pypetkitapi.command import FOUNTAIN_COMMAND, FountainAction
12
+ from pypetkitapi.const import (
13
+ BLE_CONNECT_ATTEMPT,
14
+ BLE_END_TRAME,
15
+ BLE_START_TRAME,
16
+ PetkitEndpoint,
17
+ )
18
+ from pypetkitapi.containers import BleRelay
19
+
20
+ if TYPE_CHECKING:
21
+ from pypetkitapi import PetKitClient, WaterFountain
22
+
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ class BluetoothManager:
27
+ """Class for handling Bluetooth communication with PetKit devices."""
28
+
29
+ def __init__(self, client: "PetKitClient"):
30
+ """Initialize the BluetoothManager class."""
31
+ self.client = client
32
+
33
+ async def _get_fountain_instance(self, fountain_id: int) -> "WaterFountain":
34
+ """Get the WaterFountain instance for the given fountain_id."""
35
+ from pypetkitapi.water_fountain_container import WaterFountain
36
+
37
+ water_fountain = self.client.petkit_entities.get(fountain_id)
38
+ if not isinstance(water_fountain, WaterFountain):
39
+ _LOGGER.error("Water fountain with ID %s not found.", fountain_id)
40
+ raise TypeError(f"Water fountain with ID {fountain_id} not found.")
41
+ return water_fountain
42
+
43
+ async def check_relay_availability(self, fountain_id: int) -> bool:
44
+ """Check if BLE relay is available for the given fountain_id."""
45
+ fountain = None
46
+ for account in self.client.account_data:
47
+ if account.device_list:
48
+ fountain = next(
49
+ (
50
+ device
51
+ for device in account.device_list
52
+ if device.device_id == fountain_id
53
+ ),
54
+ None,
55
+ )
56
+ if fountain:
57
+ break
58
+ if not fountain:
59
+ raise ValueError(
60
+ f"Fountain with device_id {fountain_id} not found for the current account"
61
+ )
62
+ group_id = fountain.group_id
63
+ response = await self.client.req.request(
64
+ method=HTTPMethod.POST,
65
+ url=f"{PetkitEndpoint.BLE_AS_RELAY}",
66
+ params={"groupId": group_id},
67
+ headers=await self.client.get_session_id(),
68
+ )
69
+ ble_relays = [BleRelay(**relay) for relay in response]
70
+ if len(ble_relays) == 0:
71
+ _LOGGER.warning("No BLE relay devices found.")
72
+ return False
73
+ return True
74
+
75
+ async def open_ble_connection(self, fountain_id: int) -> bool:
76
+ """Open a BLE connection to the given fountain_id."""
77
+ _LOGGER.info("Opening BLE connection to fountain %s", fountain_id)
78
+ water_fountain = await self._get_fountain_instance(fountain_id)
79
+ if await self.check_relay_availability(fountain_id) is False:
80
+ _LOGGER.error("BLE relay not available.")
81
+ return False
82
+ if water_fountain.is_connected is True:
83
+ _LOGGER.error("BLE connection already established.")
84
+ return True
85
+ response = await self.client.req.request(
86
+ method=HTTPMethod.POST,
87
+ url=PetkitEndpoint.BLE_CONNECT,
88
+ data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
89
+ headers=await self.client.get_session_id(),
90
+ )
91
+ if response != {"state": 1}:
92
+ _LOGGER.error("Failed to establish BLE connection.")
93
+ water_fountain.is_connected = False
94
+ return False
95
+ for attempt in range(BLE_CONNECT_ATTEMPT):
96
+ _LOGGER.warning("BLE connection attempt n%s", attempt)
97
+ response = await self.client.req.request(
98
+ method=HTTPMethod.POST,
99
+ url=PetkitEndpoint.BLE_POLL,
100
+ data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
101
+ headers=await self.client.get_session_id(),
102
+ )
103
+ if response == 1:
104
+ _LOGGER.info("BLE connection established successfully.")
105
+ water_fountain.is_connected = True
106
+ water_fountain.last_ble_poll = datetime.now().strftime(
107
+ "%Y-%m-%dT%H:%M:%S.%f"
108
+ )
109
+ return True
110
+ await asyncio.sleep(4)
111
+ _LOGGER.error("Failed to establish BLE connection after multiple attempts.")
112
+ water_fountain.is_connected = False
113
+ return False
114
+
115
+ async def close_ble_connection(self, fountain_id: int) -> None:
116
+ """Close the BLE connection to the given fountain_id."""
117
+ _LOGGER.info("Closing BLE connection to fountain %s", fountain_id)
118
+ water_fountain = await self._get_fountain_instance(fountain_id)
119
+ await self.client.req.request(
120
+ method=HTTPMethod.POST,
121
+ url=PetkitEndpoint.BLE_CANCEL,
122
+ data={"bleId": fountain_id, "type": 24, "mac": water_fountain.mac},
123
+ headers=await self.client.get_session_id(),
124
+ )
125
+ _LOGGER.info("BLE connection closed successfully.")
126
+
127
+ async def get_ble_cmd_data(
128
+ self, fountain_command: list, counter: int
129
+ ) -> tuple[int, str]:
130
+ """Get the BLE command data for the given fountain_command."""
131
+ cmd_code = fountain_command[0]
132
+ modified_command = fountain_command[:2] + [counter] + fountain_command[2:]
133
+ ble_data = [*BLE_START_TRAME, *modified_command, *BLE_END_TRAME]
134
+ encoded_data = await self._encode_ble_data(ble_data)
135
+ return cmd_code, encoded_data
136
+
137
+ @staticmethod
138
+ async def _encode_ble_data(byte_list: list) -> str:
139
+ """Encode the given byte_list to a base64 encoded string."""
140
+ byte_array = bytearray(byte_list)
141
+ b64_encoded = base64.b64encode(byte_array)
142
+ return urllib.parse.quote(b64_encoded)
143
+
144
+ async def send_ble_command(self, fountain_id: int, command: FountainAction) -> bool:
145
+ """Send the given BLE command to the fountain_id."""
146
+ _LOGGER.info("Sending BLE command to fountain %s", fountain_id)
147
+ water_fountain = await self._get_fountain_instance(fountain_id)
148
+ if water_fountain.is_connected is False:
149
+ _LOGGER.error("BLE connection not established.")
150
+ return False
151
+ command_data = FOUNTAIN_COMMAND.get(command)
152
+ if command_data is None:
153
+ _LOGGER.error("Command not found.")
154
+ return False
155
+ cmd_code, cmd_data = await self.get_ble_cmd_data(
156
+ list(command_data), water_fountain.ble_counter
157
+ )
158
+ response = await self.client.req.request(
159
+ method=HTTPMethod.POST,
160
+ url=PetkitEndpoint.BLE_CONTROL_DEVICE,
161
+ data={
162
+ "bleId": water_fountain.id,
163
+ "cmd": cmd_code,
164
+ "data": cmd_data,
165
+ "mac": water_fountain.mac,
166
+ "type": 24,
167
+ },
168
+ headers=await self.client.get_session_id(),
169
+ )
170
+ if response != 1:
171
+ _LOGGER.error("Failed to send BLE command.")
172
+ return False
173
+ _LOGGER.info("BLE command sent successfully.")
174
+ return True
@@ -1,23 +1,19 @@
1
1
  """Pypetkit Client: A Python library for interfacing with PetKit"""
2
2
 
3
3
  import asyncio
4
- import base64
5
4
  from datetime import datetime, timedelta
6
5
  from enum import StrEnum
7
6
  import hashlib
8
7
  from http import HTTPMethod
9
8
  import logging
10
- import urllib.parse
11
9
 
12
10
  import aiohttp
13
11
  from aiohttp import ContentTypeError
14
12
  import m3u8
15
13
 
16
- from pypetkitapi.command import ACTIONS_MAP, FOUNTAIN_COMMAND, FountainAction
14
+ from pypetkitapi.bluetooth import BluetoothManager
15
+ from pypetkitapi.command import ACTIONS_MAP
17
16
  from pypetkitapi.const import (
18
- BLE_CONNECT_ATTEMPT,
19
- BLE_END_TRAME,
20
- BLE_START_TRAME,
21
17
  CLIENT_NFO,
22
18
  DEVICE_DATA,
23
19
  DEVICE_RECORDS,
@@ -27,6 +23,7 @@ from pypetkitapi.const import (
27
23
  DEVICES_PURIFIER,
28
24
  DEVICES_WATER_FOUNTAIN,
29
25
  ERR_KEY,
26
+ FEEDER_WITH_CAMERA,
30
27
  LITTER_NO_CAMERA,
31
28
  LITTER_WITH_CAMERA,
32
29
  LOGIN_DATA,
@@ -40,14 +37,7 @@ from pypetkitapi.const import (
40
37
  PetkitDomain,
41
38
  PetkitEndpoint,
42
39
  )
43
- from pypetkitapi.containers import (
44
- AccountData,
45
- BleRelay,
46
- Device,
47
- Pet,
48
- RegionInfo,
49
- SessionInfo,
50
- )
40
+ from pypetkitapi.containers import AccountData, Device, Pet, RegionInfo, SessionInfo
51
41
  from pypetkitapi.exceptions import (
52
42
  PetkitAuthenticationError,
53
43
  PetkitAuthenticationUnregisteredEmailError,
@@ -84,10 +74,6 @@ _LOGGER = logging.getLogger(__name__)
84
74
  class PetKitClient:
85
75
  """Petkit Client"""
86
76
 
87
- _session: SessionInfo | None = None
88
- account_data: list[AccountData] = []
89
- petkit_entities: dict[int, Feeder | Litter | WaterFountain | Purifier | Pet] = {}
90
-
91
77
  def __init__(
92
78
  self,
93
79
  username: str,
@@ -101,14 +87,21 @@ class PetKitClient:
101
87
  self.password = password
102
88
  self.region = region.lower()
103
89
  self.timezone = timezone
104
- self._session = None
105
- self.petkit_entities = {}
90
+ self._session: SessionInfo | None = None
91
+ self.account_data: list[AccountData] = []
92
+ self.petkit_entities: dict[
93
+ int, Feeder | Litter | WaterFountain | Purifier | Pet
94
+ ] = {}
106
95
  self.aiohttp_session = session or aiohttp.ClientSession()
107
96
  self.req = PrepReq(
108
97
  base_url=PetkitDomain.PASSPORT_PETKIT,
109
98
  session=self.aiohttp_session,
110
99
  timezone=self.timezone,
111
100
  )
101
+ self.bluetooth_manager = BluetoothManager(self)
102
+ from pypetkitapi import MediaManager
103
+
104
+ self.media_manager = MediaManager()
112
105
 
113
106
  async def _get_base_url(self) -> None:
114
107
  """Get the list of API servers, filter by region, and return the matching server."""
@@ -259,10 +252,11 @@ class PetKitClient:
259
252
  await self._get_account_data()
260
253
 
261
254
  device_list = self._collect_devices()
262
- main_tasks, record_tasks = self._prepare_tasks(device_list)
255
+ main_tasks, record_tasks, media_tasks = self._prepare_tasks(device_list)
263
256
 
264
257
  await asyncio.gather(*main_tasks)
265
258
  await asyncio.gather(*record_tasks)
259
+ await asyncio.gather(*media_tasks)
266
260
  await self._execute_stats_tasks()
267
261
 
268
262
  end_time = datetime.now()
@@ -279,10 +273,11 @@ class PetKitClient:
279
273
  _LOGGER.debug("Found %s devices", len(account.device_list))
280
274
  return device_list
281
275
 
282
- def _prepare_tasks(self, device_list: list[Device]) -> tuple[list, list]:
276
+ def _prepare_tasks(self, device_list: list[Device]) -> tuple[list, list, list]:
283
277
  """Prepare main and record tasks based on device types."""
284
- main_tasks = []
285
- record_tasks = []
278
+ main_tasks: list = []
279
+ record_tasks: list = []
280
+ media_tasks: list = []
286
281
 
287
282
  for device in device_list:
288
283
  device_type = device.device_type
@@ -290,11 +285,14 @@ class PetKitClient:
290
285
  if device_type in DEVICES_FEEDER:
291
286
  main_tasks.append(self._fetch_device_data(device, Feeder))
292
287
  record_tasks.append(self._fetch_device_data(device, FeederRecord))
288
+ self._add_feeder_task_by_type(media_tasks, device_type, device)
293
289
 
294
290
  elif device_type in DEVICES_LITTER_BOX:
295
291
  main_tasks.append(self._fetch_device_data(device, Litter))
296
292
  record_tasks.append(self._fetch_device_data(device, LitterRecord))
297
- self._add_litter_box_tasks(record_tasks, device_type, device)
293
+ self._add_lb_task_by_type(
294
+ record_tasks, media_tasks, device_type, device
295
+ )
298
296
 
299
297
  elif device_type in DEVICES_WATER_FOUNTAIN:
300
298
  main_tasks.append(self._fetch_device_data(device, WaterFountain))
@@ -305,16 +303,24 @@ class PetKitClient:
305
303
  elif device_type in DEVICES_PURIFIER:
306
304
  main_tasks.append(self._fetch_device_data(device, Purifier))
307
305
 
308
- return main_tasks, record_tasks
306
+ return main_tasks, record_tasks, media_tasks
309
307
 
310
- def _add_litter_box_tasks(
311
- self, record_tasks: list, device_type: str, device: Device
308
+ def _add_lb_task_by_type(
309
+ self, record_tasks: list, media_tasks: list, device_type: str, device: Device
312
310
  ):
313
311
  """Add specific tasks for litter box devices."""
314
- if device_type in [T3, T4]:
312
+ if device_type in LITTER_NO_CAMERA:
315
313
  record_tasks.append(self._fetch_device_data(device, LitterStats))
316
- if device_type in [T5, T6]:
314
+ if device_type in LITTER_WITH_CAMERA:
317
315
  record_tasks.append(self._fetch_device_data(device, PetOutGraph))
316
+ media_tasks.append(self._fetch_media(device))
317
+
318
+ def _add_feeder_task_by_type(
319
+ self, media_tasks: list, device_type: str, device: Device
320
+ ):
321
+ """Add specific tasks for feeder box devices."""
322
+ if device_type in FEEDER_WITH_CAMERA:
323
+ media_tasks.append(self._fetch_media(device))
318
324
 
319
325
  async def _execute_stats_tasks(self) -> None:
320
326
  """Execute tasks to populate pet stats."""
@@ -325,6 +331,15 @@ class PetKitClient:
325
331
  ]
326
332
  await asyncio.gather(*stats_tasks)
327
333
 
334
+ async def _fetch_media(self, device: Device) -> None:
335
+ """Fetch media data from the PetKit servers."""
336
+ _LOGGER.debug("Fetching media data for device: %s", device.device_id)
337
+
338
+ device_entity = self.petkit_entities[device.device_id]
339
+ device_entity.medias = await self.media_manager.get_all_media_files(
340
+ [device_entity]
341
+ )
342
+
328
343
  async def _fetch_device_data(
329
344
  self,
330
345
  device: Device,
@@ -494,180 +509,18 @@ class PetKitClient:
494
509
  pet.last_duration_usage = self.get_safe_value(graph.toilet_time)
495
510
  pet.last_device_used = pet_graphs.device_nfo.device_name
496
511
 
497
- async def _get_fountain_instance(self, fountain_id: int) -> WaterFountain:
498
- # Retrieve the water fountain object
499
- water_fountain = self.petkit_entities.get(fountain_id)
500
- if not isinstance(water_fountain, WaterFountain):
501
- _LOGGER.error("Water fountain with ID %s not found.", fountain_id)
502
- raise TypeError(f"Water fountain with ID {fountain_id} not found.")
503
- return water_fountain
504
-
505
- async def check_relay_availability(self, fountain_id: int) -> bool:
506
- """Check if BLE relay is available for the account."""
507
- fountain = None
508
-
509
- for account in self.account_data:
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
521
-
522
- if not fountain:
523
- raise ValueError(
524
- f"Fountain with device_id {fountain_id} not found for the current account"
525
- )
526
-
527
- group_id = fountain.group_id
528
-
529
- response = await self.req.request(
530
- method=HTTPMethod.POST,
531
- url=f"{PetkitEndpoint.BLE_AS_RELAY}",
532
- params={"groupId": group_id},
533
- headers=await self.get_session_id(),
534
- )
535
- ble_relays = [BleRelay(**relay) for relay in response]
536
-
537
- if len(ble_relays) == 0:
538
- _LOGGER.warning("No BLE relay devices found.")
539
- return False
540
- return True
541
-
542
- async def open_ble_connection(self, fountain_id: int) -> bool:
543
- """Open a BLE connection to a PetKit device."""
544
- _LOGGER.info("Opening BLE connection to fountain %s", fountain_id)
545
- water_fountain = await self._get_fountain_instance(fountain_id)
546
-
547
- if await self.check_relay_availability(fountain_id) is False:
548
- _LOGGER.error("BLE relay not available.")
549
- return False
550
-
551
- if water_fountain.is_connected is True:
552
- _LOGGER.error("BLE connection already established.")
553
- return True
554
-
555
- response = await self.req.request(
556
- method=HTTPMethod.POST,
557
- url=PetkitEndpoint.BLE_CONNECT,
558
- data={
559
- "bleId": fountain_id,
560
- "type": 24,
561
- "mac": water_fountain.mac,
562
- },
563
- headers=await self.get_session_id(),
564
- )
565
- if response != {"state": 1}:
566
- _LOGGER.error("Failed to establish BLE connection.")
567
- water_fountain.is_connected = False
568
- return False
569
-
570
- for attempt in range(BLE_CONNECT_ATTEMPT):
571
- _LOGGER.warning("BLE connection attempt n%s", attempt)
572
- response = await self.req.request(
573
- method=HTTPMethod.POST,
574
- url=PetkitEndpoint.BLE_POLL,
575
- data={
576
- "bleId": fountain_id,
577
- "type": 24,
578
- "mac": water_fountain.mac,
579
- },
580
- headers=await self.get_session_id(),
581
- )
582
- if response == 1:
583
- _LOGGER.info("BLE connection established successfully.")
584
- water_fountain.is_connected = True
585
- water_fountain.last_ble_poll = datetime.now().strftime(
586
- "%Y-%m-%dT%H:%M:%S.%f"
587
- )
588
- return True
589
- await asyncio.sleep(4)
590
- _LOGGER.error("Failed to establish BLE connection after multiple attempts.")
591
- water_fountain.is_connected = False
592
- return False
593
-
594
- async def close_ble_connection(self, fountain_id: int) -> None:
595
- """Close the BLE connection to a PetKit device."""
596
- _LOGGER.info("Closing BLE connection to fountain %s", fountain_id)
597
- water_fountain = await self._get_fountain_instance(fountain_id)
598
-
599
- await self.req.request(
600
- method=HTTPMethod.POST,
601
- url=PetkitEndpoint.BLE_CANCEL,
602
- data={
603
- "bleId": fountain_id,
604
- "type": 24,
605
- "mac": water_fountain.mac,
606
- },
607
- headers=await self.get_session_id(),
608
- )
609
- _LOGGER.info("BLE connection closed successfully.")
610
-
611
- async def get_ble_cmd_data(
612
- self, fountain_command: list, counter: int
613
- ) -> tuple[int, str]:
614
- """Prepare BLE data by adding start and end trame to the command and extracting the first number."""
615
- cmd_code = fountain_command[0]
616
- modified_command = fountain_command[:2] + [counter] + fountain_command[2:]
617
- ble_data = [*BLE_START_TRAME, *modified_command, *BLE_END_TRAME]
618
- encoded_data = await self._encode_ble_data(ble_data)
619
- return cmd_code, encoded_data
620
-
621
- @staticmethod
622
- async def _encode_ble_data(byte_list: list) -> str:
623
- """Encode a list of bytes to a URL encoded base64 string."""
624
- byte_array = bytearray(byte_list)
625
- b64_encoded = base64.b64encode(byte_array)
626
- return urllib.parse.quote(b64_encoded)
627
-
628
- async def send_ble_command(self, fountain_id: int, command: FountainAction) -> bool:
629
- """BLE command to a PetKit device."""
630
- _LOGGER.info("Sending BLE command to fountain %s", fountain_id)
631
- water_fountain = await self._get_fountain_instance(fountain_id)
632
-
633
- if water_fountain.is_connected is False:
634
- _LOGGER.error("BLE connection not established.")
635
- return False
636
-
637
- command_data = FOUNTAIN_COMMAND.get(command)
638
- if command_data is None:
639
- _LOGGER.error("Command not found.")
640
- return False
641
-
642
- cmd_code, cmd_data = await self.get_ble_cmd_data(
643
- list(command_data), water_fountain.ble_counter
644
- )
645
-
646
- response = await self.req.request(
647
- method=HTTPMethod.POST,
648
- url=PetkitEndpoint.BLE_CONTROL_DEVICE,
649
- data={
650
- "bleId": water_fountain.id,
651
- "cmd": cmd_code,
652
- "data": cmd_data,
653
- "mac": water_fountain.mac,
654
- "type": 24,
655
- },
656
- headers=await self.get_session_id(),
657
- )
658
- if response != 1:
659
- _LOGGER.error("Failed to send BLE command.")
660
- return False
661
- _LOGGER.info("BLE command sent successfully.")
662
- return True
663
-
664
- async def get_cloud_video(self, video_url: str) -> dict[str, str | int]:
512
+ async def get_cloud_video(self, video_url: str) -> dict[str, str | int] | None:
665
513
  """Get the video m3u8 link from the cloud."""
666
514
  response = await self.req.request(
667
515
  method=HTTPMethod.POST,
668
516
  url=video_url,
669
517
  headers=await self.get_session_id(),
670
518
  )
519
+ 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 ?"
522
+ )
523
+ return None
671
524
  return response[0]
672
525
 
673
526
  async def extract_segments_m3u8(self, m3u8_url: str) -> tuple[str, str, list[str]]:
@@ -858,9 +711,7 @@ class PrepReq:
858
711
  f"Authentication failed: {error_msg}"
859
712
  )
860
713
  case 125:
861
- raise PetkitAuthenticationUnregisteredEmailError(
862
- f"Authentication failed: {error_msg}"
863
- )
714
+ raise PetkitAuthenticationUnregisteredEmailError
864
715
  case _:
865
716
  raise PypetkitError(
866
717
  f"Request failed code : {error_code}, details : {error_msg} url : {url}"
@@ -34,12 +34,18 @@ K2 = "k2"
34
34
  K3 = "k3"
35
35
  PET = "pet"
36
36
 
37
+ # Litter
37
38
  DEVICES_LITTER_BOX = [T3, T4, T5, T6]
38
39
  LITTER_WITH_CAMERA = [T5, T6]
39
40
  LITTER_NO_CAMERA = [T3, T4]
41
+ # Feeder
42
+ FEEDER_WITH_CAMERA = [D4H, D4SH]
40
43
  DEVICES_FEEDER = [FEEDER, FEEDER_MINI, D3, D4, D4S, D4H, D4SH]
44
+ # Water Fountain
41
45
  DEVICES_WATER_FOUNTAIN = [W5, CTW3]
46
+ # Purifier
42
47
  DEVICES_PURIFIER = [K2]
48
+ # All devices
43
49
  ALL_DEVICES = [
44
50
  *DEVICES_LITTER_BOX,
45
51
  *DEVICES_FEEDER,
@@ -99,6 +99,15 @@ class Pet(BaseModel):
99
99
  self.name = self.name or self.pet_name
100
100
 
101
101
 
102
+ class UserDevice(BaseModel):
103
+ """Dataclass for user data.
104
+ Subclass of Devices.
105
+ """
106
+
107
+ id: int | None = None
108
+ nick: str | None = None
109
+
110
+
102
111
  class User(BaseModel):
103
112
  """Dataclass for user data.
104
113
  Subclass of AccountData.
@@ -22,7 +22,7 @@ class PetkitSessionExpiredError(PypetkitError):
22
22
  class PetkitAuthenticationUnregisteredEmailError(PypetkitError):
23
23
  """Exception raised when the email is not registered with Petkit."""
24
24
 
25
- def __init__(self, region: str):
25
+ def __init__(self):
26
26
  """Initialize the exception."""
27
27
  self.message = "The email you provided is not registered on Petkit's servers. Please check your email, or you are using the correct region."
28
28
  super().__init__(self.message)
@@ -14,7 +14,13 @@ from pypetkitapi.const import (
14
14
  FEEDER_MINI,
15
15
  PetkitEndpoint,
16
16
  )
17
- from pypetkitapi.containers import CloudProduct, Device, FirmwareDetail, Wifi
17
+ from pypetkitapi.containers import (
18
+ CloudProduct,
19
+ Device,
20
+ FirmwareDetail,
21
+ UserDevice,
22
+ Wifi,
23
+ )
18
24
 
19
25
 
20
26
  class FeedItem(BaseModel):
@@ -335,7 +341,9 @@ class Feeder(BaseModel):
335
341
  sn: str
336
342
  state: StateFeeder | None = None
337
343
  timezone: float | None = None
344
+ user: UserDevice | None = None
338
345
  device_nfo: Device | None = None
346
+ medias: list | None = None
339
347
 
340
348
  @classmethod
341
349
  def get_endpoint(cls, device_type: str) -> str:
@@ -14,7 +14,13 @@ from pypetkitapi.const import (
14
14
  T3,
15
15
  PetkitEndpoint,
16
16
  )
17
- from pypetkitapi.containers import CloudProduct, Device, FirmwareDetail, Wifi
17
+ from pypetkitapi.containers import (
18
+ CloudProduct,
19
+ Device,
20
+ FirmwareDetail,
21
+ UserDevice,
22
+ Wifi,
23
+ )
18
24
 
19
25
 
20
26
  class SettingsLitter(BaseModel):
@@ -445,10 +451,12 @@ class Litter(BaseModel):
445
451
  service_status: int | None = Field(None, alias="serviceStatus")
446
452
  total_time: int | None = Field(None, alias="totalTime")
447
453
  with_k3: int | None = Field(None, alias="withK3")
454
+ user: UserDevice | None = None
448
455
  device_records: list[LitterRecord] | None = None
449
456
  device_stats: LitterStats | None = None
450
457
  device_pet_graph_out: list[PetOutGraph] | None = None
451
458
  device_nfo: Device | None = None
459
+ medias: list | None = None
452
460
 
453
461
  @classmethod
454
462
  def get_endpoint(cls, device_type: str) -> str:
@@ -7,22 +7,30 @@ from dataclasses import dataclass
7
7
  from datetime import datetime
8
8
  import logging
9
9
  from pathlib import Path
10
+ import re
10
11
  from typing import Any
11
12
  from urllib.parse import parse_qs, urlparse
12
13
 
14
+ import aiofiles
13
15
  from aiofiles import open as aio_open
16
+ import aiofiles.os
14
17
  import aiohttp
15
18
  from Crypto.Cipher import AES
16
19
  from Crypto.Util.Padding import unpad
17
20
 
18
21
  from pypetkitapi import Feeder, Litter, PetKitClient, RecordType
19
- from pypetkitapi.const import D4H, D4SH, T5, T6, RecordTypeLST
22
+ from pypetkitapi.const import (
23
+ FEEDER_WITH_CAMERA,
24
+ LITTER_WITH_CAMERA,
25
+ MediaType,
26
+ RecordTypeLST,
27
+ )
20
28
 
21
29
  _LOGGER = logging.getLogger(__name__)
22
30
 
23
31
 
24
32
  @dataclass
25
- class MediaFile:
33
+ class MediaCloud:
26
34
  """Dataclass MediaFile.
27
35
  Represents a media file from a PetKit device.
28
36
  """
@@ -30,27 +38,46 @@ class MediaFile:
30
38
  event_id: str
31
39
  event_type: RecordType
32
40
  device_id: int
33
- user_id: str
41
+ user_id: int
34
42
  image: str | None
35
43
  video: str | None
36
44
  filepath: str
37
45
  aes_key: str
38
46
  timestamp: int
39
- is_available: bool = False
47
+
48
+
49
+ @dataclass
50
+ class MediaFile:
51
+ """Dataclass MediaFile.
52
+ Represents a media file into disk.
53
+ """
54
+
55
+ event_id: str
56
+ device_id: int
57
+ timestamp: int
58
+ media_type: MediaType
59
+ event_type: RecordType
60
+ full_file_path: Path
40
61
 
41
62
 
42
63
  class MediaManager:
43
64
  """Class to manage media files from PetKit devices."""
44
65
 
66
+ media_table: list[MediaFile] = []
67
+
45
68
  async def get_all_media_files(
46
69
  self, devices: list[Feeder | Litter]
47
- ) -> list[MediaFile]:
70
+ ) -> list[MediaCloud]:
48
71
  """Get all media files from all devices and return a list of MediaFile."""
49
- media_files: list[MediaFile] = []
72
+ media_files: list[MediaCloud] = []
73
+ _LOGGER.debug("Processing media files for %s devices", len(devices))
50
74
 
51
75
  for device in devices:
52
76
  if isinstance(device, Feeder):
53
- if device.device_nfo and device.device_nfo.device_type in [D4SH, D4H]:
77
+ if (
78
+ device.device_nfo
79
+ and device.device_nfo.device_type in FEEDER_WITH_CAMERA
80
+ ):
54
81
  media_files.extend(self._process_feeder(device))
55
82
  else:
56
83
  _LOGGER.debug(
@@ -58,7 +85,10 @@ class MediaManager:
58
85
  device.name,
59
86
  )
60
87
  elif isinstance(device, Litter):
61
- if device.device_nfo and device.device_nfo.device_type in [T5, T6]:
88
+ if (
89
+ device.device_nfo
90
+ and device.device_nfo.device_type in LITTER_WITH_CAMERA
91
+ ):
62
92
  media_files.extend(self._process_litter(device))
63
93
  else:
64
94
  _LOGGER.debug(
@@ -68,39 +98,147 @@ class MediaManager:
68
98
 
69
99
  return media_files
70
100
 
71
- def _process_feeder(self, feeder: Feeder) -> list[MediaFile]:
101
+ async def get_all_media_files_disk(
102
+ self, storage_path: Path, device_id: int
103
+ ) -> None:
104
+ """Construct the media file table for disk storage."""
105
+ self.media_table.clear()
106
+
107
+ today_str = datetime.now().strftime("%Y%m%d")
108
+ base_path = storage_path / str(device_id) / today_str
109
+
110
+ for record_type in RecordType:
111
+ record_path = base_path / record_type
112
+ snapshot_path = record_path / "snapshot"
113
+ video_path = record_path / "video"
114
+
115
+ # Regex pattern to match valid filenames
116
+ valid_pattern = re.compile(rf"^(?:\d+_)?{device_id}_\d+\.(jpg|avi)$")
117
+
118
+ # Populate the media table with event_id from filenames
119
+ for subdir in [snapshot_path, video_path]:
120
+
121
+ # Ensure the directories exist
122
+ if not await aiofiles.os.path.exists(subdir):
123
+ _LOGGER.debug("Skip, path does not exist, %s", subdir)
124
+ continue
125
+
126
+ _LOGGER.debug("Scanning directory %s", subdir)
127
+ entries = await aiofiles.os.scandir(subdir)
128
+ for entry in entries:
129
+ if entry.is_file() and valid_pattern.match(entry.name):
130
+ _LOGGER.debug("Entries found: %s", entry.name)
131
+ event_id = Path(entry.name).stem
132
+ timestamp = self._extraire_timestamp(str(entry.name))
133
+ media_type_str = Path(entry.name).suffix.lstrip(".")
134
+ try:
135
+ media_type = MediaType(media_type_str)
136
+ except ValueError:
137
+ _LOGGER.warning("Unknown media type: %s", media_type_str)
138
+ continue
139
+ self.media_table.append(
140
+ MediaFile(
141
+ event_id=event_id,
142
+ device_id=device_id,
143
+ timestamp=int(timestamp),
144
+ event_type=RecordType(record_type),
145
+ full_file_path=subdir / entry.name,
146
+ media_type=MediaType(media_type),
147
+ )
148
+ )
149
+
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(
158
+ self,
159
+ media_cloud_list: list[MediaCloud],
160
+ dl_type: list[MediaType] | None = None,
161
+ event_type: list[RecordType] | None = None,
162
+ ) -> list[MediaCloud]:
163
+ """Compare MediaCloud objects with MediaFile objects and return a list of missing MediaCloud objects."""
164
+ missing_media = []
165
+ existing_event_ids = {media_file.event_id for media_file in self.media_table}
166
+
167
+ for media_cloud in media_cloud_list:
168
+ # Skip if event type is not in the filter
169
+ if event_type and media_cloud.event_type not in event_type:
170
+ continue
171
+
172
+ # Check if the media file is missing
173
+ is_missing = False
174
+ if media_cloud.event_id not in existing_event_ids:
175
+ is_missing = True # Both image and video are missing
176
+ else:
177
+ # Check for missing image
178
+ if (
179
+ media_cloud.image
180
+ and MediaType.IMAGE
181
+ in (dl_type or [MediaType.IMAGE, MediaType.VIDEO])
182
+ and not any(
183
+ media_file.event_id == media_cloud.event_id
184
+ and media_file.media_type == MediaType.IMAGE
185
+ for media_file in self.media_table
186
+ )
187
+ ):
188
+ is_missing = True
189
+ # Check for missing video
190
+ if (
191
+ media_cloud.video
192
+ and MediaType.VIDEO
193
+ in (dl_type or [MediaType.IMAGE, MediaType.VIDEO])
194
+ and not any(
195
+ media_file.event_id == media_cloud.event_id
196
+ and media_file.media_type == MediaType.VIDEO
197
+ for media_file in self.media_table
198
+ )
199
+ ):
200
+ is_missing = True
201
+
202
+ if is_missing:
203
+ missing_media.append(media_cloud)
204
+
205
+ return missing_media
206
+
207
+ def _process_feeder(self, feeder: Feeder) -> list[MediaCloud]:
72
208
  """Process media files for a Feeder device."""
73
- media_files: list[MediaFile] = []
209
+ media_files: list[MediaCloud] = []
74
210
  records = feeder.device_records
75
211
 
76
- device_id = (
77
- feeder.device_nfo.device_id
78
- if feeder.device_nfo and feeder.device_nfo.device_type
79
- else None
80
- )
81
- if device_id is None:
82
- raise ValueError("Missing device ID for feeder")
83
-
84
212
  if not records:
213
+ _LOGGER.debug("No records found for %s", feeder.name)
85
214
  return media_files
86
215
 
87
216
  for record_type in RecordTypeLST:
88
217
  record_list = getattr(records, record_type, [])
89
218
  for record in record_list:
90
219
  media_files.extend(
91
- self._process_feeder_record(
92
- record, RecordType(record_type), device_id
93
- )
220
+ self._process_feeder_record(record, RecordType(record_type), feeder)
94
221
  )
95
222
 
96
223
  return media_files
97
224
 
98
225
  def _process_feeder_record(
99
- self, record, record_type: RecordType, device_id: int
100
- ) -> list[MediaFile]:
226
+ self, record, record_type: RecordType, device_obj: Feeder
227
+ ) -> list[MediaCloud]:
101
228
  """Process individual feeder records."""
102
- media_files: list[MediaFile] = []
103
- user_id = record.user_id
229
+ media_files: list[MediaCloud] = []
230
+ user_id = device_obj.user.id if device_obj.user else None
231
+ feeder_id = device_obj.device_nfo.device_id if device_obj.device_nfo else None
232
+ device_type = (
233
+ device_obj.device_nfo.device_type if device_obj.device_nfo else None
234
+ )
235
+ cp_sub = (
236
+ device_obj.cloud_product.subscribe if device_obj.cloud_product else None
237
+ )
238
+
239
+ if not feeder_id:
240
+ _LOGGER.error("Missing feeder_id for record")
241
+ return media_files
104
242
 
105
243
  if not record.items:
106
244
  return media_files
@@ -113,7 +251,8 @@ class MediaManager:
113
251
  else "unknown"
114
252
  )
115
253
  if not item.event_id:
116
- _LOGGER.error("Missing event_id for record item")
254
+ # Skip feed event in the future
255
+ _LOGGER.debug("Missing event_id for record item")
117
256
  continue
118
257
  if not user_id:
119
258
  _LOGGER.error("Missing user_id for record item")
@@ -125,15 +264,17 @@ class MediaManager:
125
264
  _LOGGER.error("Missing timestamp for record item")
126
265
  continue
127
266
 
128
- filepath = f"{device_id}/{date_str}/{record_type.name.lower()}"
267
+ filepath = f"{feeder_id}/{date_str}/{record_type.name.lower()}"
129
268
  media_files.append(
130
- MediaFile(
269
+ MediaCloud(
131
270
  event_id=item.event_id,
132
271
  event_type=record_type,
133
- device_id=device_id,
272
+ device_id=feeder_id,
134
273
  user_id=user_id,
135
274
  image=item.preview,
136
- video=self.construct_video_url(item.media_api, user_id),
275
+ video=self.construct_video_url(
276
+ device_type, item.media_api, user_id, cp_sub
277
+ ),
137
278
  filepath=filepath,
138
279
  aes_key=item.aes_key,
139
280
  timestamp=self._get_timestamp(item),
@@ -141,10 +282,26 @@ class MediaManager:
141
282
  )
142
283
  return media_files
143
284
 
144
- def _process_litter(self, litter: Litter) -> list[MediaFile]:
285
+ def _process_litter(self, litter: Litter) -> list[MediaCloud]:
145
286
  """Process media files for a Litter device."""
146
- media_files: list[MediaFile] = []
287
+ media_files: list[MediaCloud] = []
147
288
  records = litter.device_records
289
+ litter_id = litter.device_nfo.device_id if litter.device_nfo else None
290
+ device_type = litter.device_nfo.device_type if litter.device_nfo else None
291
+ user_id = litter.user.id if litter.user else None
292
+ cp_sub = litter.cloud_product.subscribe if litter.cloud_product else None
293
+
294
+ if not litter_id:
295
+ _LOGGER.error("Missing litter_id for record")
296
+ return media_files
297
+
298
+ if not device_type:
299
+ _LOGGER.error("Missing device_type for record")
300
+ return media_files
301
+
302
+ if not user_id:
303
+ _LOGGER.error("Missing user_id for record")
304
+ return media_files
148
305
 
149
306
  if not records:
150
307
  return media_files
@@ -159,12 +316,6 @@ class MediaManager:
159
316
  if not record.event_id:
160
317
  _LOGGER.error("Missing event_id for record item")
161
318
  continue
162
- if not record.device_id:
163
- _LOGGER.error("Missing event_id for record item")
164
- continue
165
- if not record.user_id:
166
- _LOGGER.error("Missing user_id for record item")
167
- continue
168
319
  if not record.aes_key:
169
320
  _LOGGER.error("Missing aes_key for record item")
170
321
  continue
@@ -172,15 +323,17 @@ class MediaManager:
172
323
  _LOGGER.error("Missing timestamp for record item")
173
324
  continue
174
325
 
175
- filepath = f"{record.device_id}/{date_str}/toileting"
326
+ filepath = f"{litter_id}/{date_str}/toileting"
176
327
  media_files.append(
177
- MediaFile(
328
+ MediaCloud(
178
329
  event_id=record.event_id,
179
330
  event_type=RecordType.TOILETING,
180
- device_id=record.device_id,
181
- user_id=record.user_id,
331
+ device_id=litter_id,
332
+ user_id=user_id,
182
333
  image=record.preview,
183
- video=self.construct_video_url(record.media_api, record.user_id),
334
+ video=self.construct_video_url(
335
+ device_type, record.media_api, user_id, cp_sub
336
+ ),
184
337
  filepath=filepath,
185
338
  aes_key=record.aes_key,
186
339
  timestamp=record.timestamp,
@@ -189,13 +342,15 @@ class MediaManager:
189
342
  return media_files
190
343
 
191
344
  @staticmethod
192
- def construct_video_url(media_url: str | None, user_id: str | None) -> str | None:
345
+ def construct_video_url(
346
+ device_type: str | None, media_url: str | None, user_id: int, cp_sub: int | None
347
+ ) -> str | None:
193
348
  """Construct the video URL."""
194
- if not media_url or not user_id:
349
+ if not media_url or not user_id or cp_sub != 1:
195
350
  return None
196
351
  params = parse_qs(urlparse(media_url).query)
197
352
  param_dict = {k: v[0] for k, v in params.items()}
198
- return f"/d4sh/cloud/video?startTime={param_dict.get("startTime")}&deviceId={param_dict.get("deviceId")}&userId={user_id}&mark={param_dict.get("mark")}"
353
+ return f"/{device_type}/cloud/video?startTime={param_dict.get("startTime")}&deviceId={param_dict.get("deviceId")}&userId={user_id}&mark={param_dict.get("mark")}"
199
354
 
200
355
  @staticmethod
201
356
  def _get_timestamp(item) -> int:
@@ -218,7 +373,7 @@ class MediaManager:
218
373
  class DownloadDecryptMedia:
219
374
  """Class to download and decrypt media files from PetKit devices."""
220
375
 
221
- file_data: MediaFile
376
+ file_data: MediaCloud
222
377
 
223
378
  def __init__(self, download_path: Path, client: PetKitClient):
224
379
  """Initialize the class."""
@@ -234,11 +389,14 @@ class DownloadDecryptMedia:
234
389
  subdir = "video"
235
390
  return Path(self.download_path / self.file_data.filepath / subdir / file_name)
236
391
 
237
- async def download_file(self, file_data: MediaFile) -> None:
392
+ async def download_file(
393
+ self, file_data: MediaCloud, file_type: MediaType | None
394
+ ) -> None:
238
395
  """Get image and video file"""
396
+ _LOGGER.debug("Downloading media file %s", file_data.event_id)
239
397
  self.file_data = file_data
240
398
 
241
- if self.file_data.image:
399
+ if self.file_data.image and (file_type is None or file_type == MediaType.IMAGE):
242
400
  # Download image file
243
401
  await self._get_file(
244
402
  self.file_data.image,
@@ -246,7 +404,7 @@ class DownloadDecryptMedia:
246
404
  f"{self.file_data.event_id}.jpg",
247
405
  )
248
406
 
249
- if self.file_data.video:
407
+ if self.file_data.video and (file_type is None or file_type == MediaType.VIDEO):
250
408
  # Download video file
251
409
  await self._get_video_m3u8()
252
410
 
@@ -254,7 +412,18 @@ class DownloadDecryptMedia:
254
412
  """Iterate through m3u8 file and return all the ts file urls"""
255
413
  aes_key, iv_key, segments_lst = await self._get_m3u8_segments()
256
414
 
415
+ 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)
417
+ return
418
+
257
419
  segment_files = []
420
+
421
+ if len(segments_lst) == 1:
422
+ await self._get_file(
423
+ segments_lst[0], aes_key, f"{self.file_data.event_id}.avi"
424
+ )
425
+ return
426
+
258
427
  for index, segment in enumerate(segments_lst, start=1):
259
428
  segment_file = await self._get_file(
260
429
  segment, aes_key, f"{index}_{self.file_data.event_id}.avi"
@@ -264,13 +433,15 @@ class DownloadDecryptMedia:
264
433
  await self.get_fpath(f"{index}_{self.file_data.event_id}.avi")
265
434
  )
266
435
 
267
- if len(segment_files) > 1:
268
- _LOGGER.debug("Concatenating segments %s", len(segment_files))
269
- await self._concat_segments(segment_files, f"{self.file_data.event_id}.avi")
436
+ if not segment_files:
437
+ _LOGGER.error("No segment files found")
270
438
  elif len(segment_files) == 1:
271
439
  _LOGGER.debug("Single file segment, no need to concatenate")
440
+ elif len(segment_files) > 1:
441
+ _LOGGER.debug("Concatenating segments %s", len(segment_files))
442
+ await self._concat_segments(segment_files, f"{self.file_data.event_id}.avi")
272
443
 
273
- async def _get_m3u8_segments(self) -> tuple[str, str, list[str]]:
444
+ async def _get_m3u8_segments(self) -> tuple[str | None, str | None, list[str]]:
274
445
  """Extract the segments from a m3u8 file.
275
446
  :return: Tuple of AES key, IV key, and list of segment URLs
276
447
  """
@@ -278,6 +449,9 @@ class DownloadDecryptMedia:
278
449
  raise ValueError("Missing video URL")
279
450
  video_data = await self.client.get_cloud_video(self.file_data.video)
280
451
 
452
+ if not video_data:
453
+ return None, None, []
454
+
281
455
  media_api = video_data.get("mediaApi", None)
282
456
  if not media_api:
283
457
  _LOGGER.error("Missing mediaApi in video data")
@@ -371,6 +545,7 @@ class DownloadDecryptMedia:
371
545
  _LOGGER.debug(
372
546
  "Output file already exists: %s, skipping concatenation.", output_file
373
547
  )
548
+ await self._delete_segments(ts_files)
374
549
  return
375
550
 
376
551
  # Build the argument for `ffmpeg` with the files formatted for the command line
@@ -187,7 +187,7 @@ build-backend = "poetry.core.masonry.api"
187
187
 
188
188
  [tool.poetry]
189
189
  name = "pypetkitapi"
190
- version = "1.9.4"
190
+ version = "1.10.2"
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.9.4"
212
+ current_version = "1.10.2"
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