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.
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/PKG-INFO +10 -7
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/README.md +6 -4
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/client.py +78 -9
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/containers.py +7 -1
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/litter_container.py +9 -7
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/medias.py +22 -17
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pyproject.toml +5 -4
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/LICENSE +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/__init__.py +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/command.py +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/const.py +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/exceptions.py +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/feeder_container.py +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/py.typed +0 -0
- {pypetkitapi-1.6.3 → pypetkitapi-1.7.1}/pypetkitapi/water_fountain_container.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: pypetkitapi
|
3
|
-
Version: 1.
|
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.
|
16
|
-
Requires-Dist: pycryptodome (>=3.
|
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
|
-
#
|
86
|
-
print(client.account_data)
|
86
|
+
# Lists all devices and pet from account
|
87
87
|
|
88
|
-
|
89
|
-
|
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
|
-
#
|
68
|
-
print(client.account_data)
|
67
|
+
# Lists all devices and pet from account
|
69
68
|
|
70
|
-
|
71
|
-
|
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,
|
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,
|
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
|
-
|
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:
|
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:
|
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:
|
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
|
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:
|
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[
|
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
|
75
|
-
items = reversed(record_items) if reversed_order else record_items
|
74
|
+
async def process_item(record_items):
|
76
75
|
last_item = next(
|
77
|
-
(
|
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
|
-
|
104
|
-
|
105
|
-
|
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("
|
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("
|
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.
|
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.
|
200
|
-
pycryptodome = "^3.
|
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.
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|