pypetkitapi 1.6.3__tar.gz → 1.7.1__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.1
2
2
  Name: pypetkitapi
3
- Version: 1.6.3
3
+ Version: 1.7.1
4
4
  Summary: Python client for PetKit API
5
5
  Home-page: https://github.com/Jezza34000/pypetkit
6
6
  License: MIT
@@ -12,8 +12,9 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: aiohttp (>=3.11.0,<4.0.0)
16
- Requires-Dist: pycryptodome (>=3.21.0,<4.0.0)
15
+ Requires-Dist: aiohttp (>=3.11.11,<4.0.0)
16
+ Requires-Dist: pycryptodome (>=3.19.1,<4.0.0)
17
+ Requires-Dist: pydantic (>=1.10.19,<2.0.0)
17
18
  Description-Content-Type: text/markdown
18
19
 
19
20
  # Petkit API Client
@@ -82,11 +83,13 @@ async def main():
82
83
 
83
84
  await client.get_devices_data()
84
85
 
85
- # Read the account data
86
- print(client.account_data)
86
+ # Lists all devices and pet from account
87
87
 
88
- # Read the devices data
89
- print(client.petkit_entities)
88
+ for key, value in client.petkit_entities.items():
89
+ print(f"{key}: {type(value).__name__} - {value.name}")
90
+
91
+ # Read devices or pet information
92
+ print(client.petkit_entities[123456789])
90
93
 
91
94
  # Send command to the devices
92
95
  ### Example 1 : Turn on the indicator light
@@ -64,11 +64,13 @@ async def main():
64
64
 
65
65
  await client.get_devices_data()
66
66
 
67
- # Read the account data
68
- print(client.account_data)
67
+ # Lists all devices and pet from account
69
68
 
70
- # Read the devices data
71
- print(client.petkit_entities)
69
+ for key, value in client.petkit_entities.items():
70
+ print(f"{key}: {type(value).__name__} - {value.name}")
71
+
72
+ # Read devices or pet information
73
+ print(client.petkit_entities[123456789])
72
74
 
73
75
  # Send command to the devices
74
76
  ### Example 1 : Turn on the indicator light
@@ -21,7 +21,6 @@ from pypetkitapi.const import (
21
21
  ERR_KEY,
22
22
  LOGIN_DATA,
23
23
  RES_KEY,
24
- SUCCESS_KEY,
25
24
  T4,
26
25
  T6,
27
26
  Header,
@@ -38,7 +37,7 @@ from pypetkitapi.exceptions import (
38
37
  PypetkitError,
39
38
  )
40
39
  from pypetkitapi.feeder_container import Feeder, FeederRecord
41
- from pypetkitapi.litter_container import Litter, LitterRecord, LitterStats, PetOuGraph
40
+ from pypetkitapi.litter_container import Litter, LitterRecord, LitterStats, PetOutGraph
42
41
  from pypetkitapi.water_fountain_container import WaterFountain, WaterFountainRecord
43
42
 
44
43
  _LOGGER = logging.getLogger(__name__)
@@ -200,6 +199,7 @@ class PetKitClient:
200
199
  main_tasks = []
201
200
  record_tasks = []
202
201
  device_list: list[Device] = []
202
+ stats_tasks = []
203
203
 
204
204
  for account in self.account_data:
205
205
  _LOGGER.debug("List devices data for account: %s", account)
@@ -221,7 +221,7 @@ class PetKitClient:
221
221
  if device_type == T4:
222
222
  record_tasks.append(self._fetch_device_data(device, LitterStats))
223
223
  if device_type == T6:
224
- record_tasks.append(self._fetch_device_data(device, PetOuGraph))
224
+ record_tasks.append(self._fetch_device_data(device, PetOutGraph))
225
225
 
226
226
  elif device_type in DEVICES_WATER_FOUNTAIN:
227
227
  main_tasks.append(self._fetch_device_data(device, WaterFountain))
@@ -235,6 +235,16 @@ class PetKitClient:
235
235
  # Then execute record tasks
236
236
  await asyncio.gather(*record_tasks)
237
237
 
238
+ # Add populate_pet_stats tasks
239
+ stats_tasks = [
240
+ self.populate_pet_stats(self.petkit_entities[device.device_id])
241
+ for device in device_list
242
+ if device.device_type.lower() in DEVICES_LITTER_BOX
243
+ ]
244
+
245
+ # Execute stats tasks
246
+ await asyncio.gather(*stats_tasks)
247
+
238
248
  end_time = datetime.now()
239
249
  total_time = end_time - start_time
240
250
  _LOGGER.debug("Petkit data fetched successfully in: %s", total_time)
@@ -311,6 +321,69 @@ class PetKitClient:
311
321
  else:
312
322
  _LOGGER.error("Unknown data type: %s", data_class.data_type)
313
323
 
324
+ async def get_pets_list(self) -> list[Pet]:
325
+ """Extract and return the list of pets."""
326
+ return [
327
+ entity
328
+ for entity in self.petkit_entities.values()
329
+ if isinstance(entity, Pet)
330
+ ]
331
+
332
+ @staticmethod
333
+ def get_safe_value(value: int | None, default: int = 0) -> int:
334
+ """Return the value if not None, otherwise return the default."""
335
+ return value if value is not None else default
336
+
337
+ @staticmethod
338
+ def calculate_duration(start: int | None, end: int | None) -> int:
339
+ """Calculate the duration, ensuring both start and end are not None."""
340
+ if start is None or end is None:
341
+ return 0
342
+ return end - start
343
+
344
+ async def populate_pet_stats(self, stats_data: Litter | None) -> None:
345
+ """Collect data from litter data to populate pet stats."""
346
+ if stats_data is None:
347
+ return
348
+
349
+ pets_list = await self.get_pets_list()
350
+ for pet in pets_list:
351
+ if stats_data.device_records:
352
+ await self._process_t4(pet, stats_data.device_records)
353
+ elif stats_data.device_pet_graph_out:
354
+ await self._process_t6(pet, stats_data.device_pet_graph_out)
355
+
356
+ async def _process_t4(self, pet, device_records):
357
+ """Process T4 device records."""
358
+ for stat in device_records:
359
+ if stat.pet_id == pet.pet_id and (
360
+ pet.last_litter_usage is None
361
+ or self.get_safe_value(stat.timestamp) > pet.last_litter_usage
362
+ ):
363
+ pet.last_litter_usage = stat.timestamp
364
+ pet.last_measured_weight = self.get_safe_value(
365
+ stat.content.pet_weight if stat.content else None
366
+ )
367
+ pet.last_duration_usage = self.calculate_duration(
368
+ stat.content.time_in if stat.content else None,
369
+ stat.content.time_out if stat.content else None,
370
+ )
371
+ pet.last_device_used = "Pura Max"
372
+
373
+ async def _process_t6(self, pet, pet_graphs):
374
+ """Process T6 pet graphs."""
375
+ for graph in pet_graphs:
376
+ if graph.pet_id == pet.pet_id and (
377
+ pet.last_litter_usage is None
378
+ or self.get_safe_value(graph.time) > pet.last_litter_usage
379
+ ):
380
+ pet.last_litter_usage = graph.time
381
+ pet.last_measured_weight = self.get_safe_value(
382
+ graph.content.pet_weight if graph.content else None
383
+ )
384
+ pet.last_duration_usage = self.get_safe_value(graph.toilet_time)
385
+ pet.last_device_used = "Purobot Ultra"
386
+
314
387
  async def send_api_request(
315
388
  self,
316
389
  device_id: int,
@@ -371,12 +444,8 @@ class PetKitClient:
371
444
  data=params,
372
445
  headers=await self.get_session_id(),
373
446
  )
374
-
375
- if res in [RES_KEY, SUCCESS_KEY]:
376
- _LOGGER.debug("Command executed successfully")
377
- return True
378
- _LOGGER.error("Command execution failed")
379
- return False
447
+ _LOGGER.debug("Command execution success, API response : %s", res)
448
+ return True
380
449
 
381
450
  async def close(self) -> None:
382
451
  """Close the aiohttp session if it was created by the client."""
@@ -68,13 +68,19 @@ class Pet(BaseModel):
68
68
  avatar: str | None = None
69
69
  created_at: int = Field(alias="createdAt")
70
70
  pet_id: int = Field(alias="petId")
71
+ pet_name: str | None = Field(None, alias="petName")
71
72
  id: int | None = None # Fictive field (for HA compatibility) copied from id
72
73
  sn: int | None = None # Fictive field (for HA compatibility) copied from id
73
- pet_name: str | None = Field(None, alias="petName")
74
74
  name: str | None = None # Fictive field (for HA compatibility) copied from pet_name
75
75
  device_type: str = "pet" # Fictive field (for HA compatibility) fixed
76
76
  firmware: str | None = None # Fictive field (for HA compatibility) fixed
77
77
 
78
+ # Got from Litter stats
79
+ last_litter_usage: int = 0
80
+ last_device_used: str | None = None
81
+ last_duration_usage: int = 0
82
+ last_measured_weight: int = 0
83
+
78
84
  @root_validator(pre=True)
79
85
  def populate_fictive_fields(cls, values): # noqa: N805
80
86
  """Populate fictive fields based on other fields."""
@@ -220,7 +220,7 @@ class LitterRecord(BaseModel):
220
220
  mark: int | None = None
221
221
  media: int | None = None
222
222
  media_api: str | None = Field(None, alias="mediaApi")
223
- pet_id: str | None = Field(None, alias="petId")
223
+ pet_id: int | None = Field(None, alias="petId")
224
224
  pet_name: str | None = Field(None, alias="petName")
225
225
  preview: str | None = None
226
226
  related_event: str | None = Field(None, alias="relatedEvent")
@@ -268,12 +268,12 @@ class StatisticInfo(BaseModel):
268
268
  Subclass of LitterStats.
269
269
  """
270
270
 
271
- pet_id: str | None = Field(None, alias="petId")
271
+ pet_id: int | None = Field(None, alias="petId")
272
272
  pet_name: str | None = Field(None, alias="petName")
273
273
  pet_times: int | None = Field(None, alias="petTimes")
274
274
  pet_total_time: int | None = Field(None, alias="petTotalTime")
275
275
  pet_weight: int | None = Field(None, alias="petWeight")
276
- statistic_date: str | None = Field(None, alias="statisticDate")
276
+ statistic_date: int | None = Field(None, alias="statisticDate")
277
277
  x_time: int | None = Field(None, alias="xTime")
278
278
 
279
279
 
@@ -334,8 +334,10 @@ class PetGraphContent(BaseModel):
334
334
  time_out: int | None = Field(None, alias="timeOut")
335
335
 
336
336
 
337
- class PetOuGraph(BaseModel):
338
- """Dataclass for event data."""
337
+ class PetOutGraph(BaseModel):
338
+ """Dataclass for event data.
339
+ Main Dataclass
340
+ """
339
341
 
340
342
  data_type: ClassVar[str] = DEVICE_STATS
341
343
 
@@ -346,7 +348,7 @@ class PetOuGraph(BaseModel):
346
348
  expire: int | None = None
347
349
  is_need_upload_video: int | None = Field(None, alias="isNeedUploadVideo")
348
350
  media_api: str | None = Field(None, alias="mediaApi")
349
- pet_id: str | None = Field(None, alias="petId")
351
+ pet_id: int | None = Field(None, alias="petId")
350
352
  pet_name: str | None = Field(None, alias="petName")
351
353
  preview: str | None = None
352
354
  storage_space: int | None = Field(None, alias="storageSpace")
@@ -418,7 +420,7 @@ class Litter(BaseModel):
418
420
  device_type: str | None = Field(None, alias="deviceType")
419
421
  device_records: list[LitterRecord] | None = None
420
422
  device_stats: LitterStats | None = None
421
- device_pet_graph_out: list[PetOuGraph] | None = None
423
+ device_pet_graph_out: list[PetOutGraph] | None = None
422
424
 
423
425
  @classmethod
424
426
  def get_endpoint(cls, device_type: str) -> str:
@@ -71,10 +71,13 @@ class MediaHandler:
71
71
  """Process individual records and return media info."""
72
72
  media_files = []
73
73
 
74
- async def process_item(record_items, reversed_order=False):
75
- items = reversed(record_items) if reversed_order else record_items
74
+ async def process_item(record_items):
76
75
  last_item = next(
77
- (item for item in items if item.preview and item.aes_key),
76
+ (
77
+ item
78
+ for item in reversed(record_items)
79
+ if item.preview and item.aes_key
80
+ ),
78
81
  None,
79
82
  )
80
83
  if last_item:
@@ -82,14 +85,12 @@ class MediaHandler:
82
85
  await self.media_download_decode.get_file(
83
86
  last_item.preview, last_item.aes_key
84
87
  )
85
-
86
88
  timestamp = (
87
89
  last_item.eat_start_time
88
90
  or last_item.completed_at
89
91
  or last_item.timestamp
90
92
  or None
91
93
  )
92
-
93
94
  media_files.append(
94
95
  MediasFiles(
95
96
  record_type=record_type,
@@ -100,16 +101,9 @@ class MediaHandler:
100
101
  )
101
102
  )
102
103
 
103
- if record_type == "feed":
104
- for record in reversed(records):
105
- if record.items:
106
- await process_item(record.items, reversed_order=True)
107
- if media_files: # Stop processing if a media file is added
108
- return media_files
109
- else:
110
- for record in records:
111
- if record.items:
112
- await process_item(record.items)
104
+ for record in records:
105
+ if record.items:
106
+ await process_item(record.items)
113
107
 
114
108
  return media_files
115
109
 
@@ -162,11 +156,22 @@ class MediaDownloadDecode:
162
156
  """Save content to a file asynchronously and return the file path."""
163
157
  file_path = Path(self.download_path) / filename
164
158
  try:
159
+ # Ensure the directory exists
160
+ file_path.parent.mkdir(parents=True, exist_ok=True)
161
+
165
162
  async with aio_open(file_path, "wb") as file:
166
163
  await file.write(content)
167
- _LOGGER.debug("Saved file: %s", file_path)
164
+ _LOGGER.debug("Save file OK : %s", file_path)
165
+ except PermissionError as e:
166
+ _LOGGER.error("Save file, permission denied %s: %s", file_path, e)
167
+ except FileNotFoundError as e:
168
+ _LOGGER.error("Save file, file/folder not found %s: %s", file_path, e)
168
169
  except OSError as e:
169
- _LOGGER.error("Error saving file %s: %s", file_path, e)
170
+ _LOGGER.error("Save file, error saving file %s: %s", file_path, e)
171
+ except Exception as e: # noqa: BLE001
172
+ _LOGGER.error(
173
+ "Save file, unexpected error saving file %s: %s", file_path, e
174
+ )
170
175
  return file_path
171
176
 
172
177
  async def _decrypt_image_from_file(
@@ -187,7 +187,7 @@ build-backend = "poetry.core.masonry.api"
187
187
 
188
188
  [tool.poetry]
189
189
  name = "pypetkitapi"
190
- version = "1.6.3"
190
+ version = "1.7.1"
191
191
  description = "Python client for PetKit API"
192
192
  authors = ["Jezza34000 <info@mail.com>"]
193
193
  readme = "README.md"
@@ -196,8 +196,9 @@ license = "MIT"
196
196
 
197
197
  [tool.poetry.dependencies]
198
198
  python = ">=3.11"
199
- aiohttp = "^3.11.0"
200
- pycryptodome = "^3.21.0"
199
+ aiohttp = "^3.11.11"
200
+ pycryptodome = "^3.19.1"
201
+ pydantic = "^1.10.19"
201
202
 
202
203
  [tool.poetry.dev-dependencies]
203
204
  pre-commit = "^4.0.1"
@@ -206,7 +207,7 @@ ruff = "^0.8.1"
206
207
  types-aiofiles = "^24.1.0.20240626"
207
208
 
208
209
  [tool.bumpver]
209
- current_version = "1.6.3"
210
+ current_version = "1.7.1"
210
211
  version_pattern = "MAJOR.MINOR.PATCH"
211
212
  commit_message = "bump version {old_version} -> {new_version}"
212
213
  tag_message = "{new_version}"
File without changes