pypetkitapi 1.4.0__py3-none-any.whl → 1.6.0__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/containers.py +14 -2
- pypetkitapi/litter_container.py +1 -1
- pypetkitapi/medias.py +198 -0
- {pypetkitapi-1.4.0.dist-info → pypetkitapi-1.6.0.dist-info}/METADATA +2 -1
- {pypetkitapi-1.4.0.dist-info → pypetkitapi-1.6.0.dist-info}/RECORD +7 -6
- {pypetkitapi-1.4.0.dist-info → pypetkitapi-1.6.0.dist-info}/LICENSE +0 -0
- {pypetkitapi-1.4.0.dist-info → pypetkitapi-1.6.0.dist-info}/WHEEL +0 -0
pypetkitapi/containers.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
"""Dataclasses container for petkit API."""
|
2
2
|
|
3
|
-
from pydantic import BaseModel, Field
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
4
4
|
|
5
5
|
|
6
6
|
class RegionInfo(BaseModel):
|
@@ -68,8 +68,20 @@ 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
|
+
id: int | None = None # Fictive field (for HA compatibility) copied from id
|
72
|
+
sn: int | None = None # Fictive field (for HA compatibility) copied from id
|
71
73
|
pet_name: str | None = Field(None, alias="petName")
|
72
|
-
|
74
|
+
name: str | None = None # Fictive field (for HA compatibility) copied from pet_name
|
75
|
+
device_type: str = "pet" # Fictive field (for HA compatibility) fixed
|
76
|
+
firmware: str | None = None # Fictive field (for HA compatibility) fixed
|
77
|
+
|
78
|
+
@model_validator(mode="before")
|
79
|
+
def populate_fictive_fields(cls, values): # noqa: N805
|
80
|
+
"""Populate fictive fields based on other fields."""
|
81
|
+
values["id"] = values.get("id") or values.get("petId")
|
82
|
+
values["sn"] = values.get("sn") or values.get("id")
|
83
|
+
values["name"] = values.get("name") or values.get("petName")
|
84
|
+
return values
|
73
85
|
|
74
86
|
|
75
87
|
class User(BaseModel):
|
pypetkitapi/litter_container.py
CHANGED
@@ -418,7 +418,7 @@ class Litter(BaseModel):
|
|
418
418
|
device_type: str | None = Field(None, alias="deviceType")
|
419
419
|
device_records: list[LitterRecord] | None = None
|
420
420
|
device_stats: LitterStats | None = None
|
421
|
-
device_pet_graph_out: PetOuGraph | None = None
|
421
|
+
device_pet_graph_out: list[PetOuGraph] | None = None
|
422
422
|
|
423
423
|
@classmethod
|
424
424
|
def get_endpoint(cls, device_type: str) -> str:
|
pypetkitapi/medias.py
ADDED
@@ -0,0 +1,198 @@
|
|
1
|
+
"""Module to handle media files from PetKit devices."""
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
import logging
|
5
|
+
from pathlib import Path
|
6
|
+
import re
|
7
|
+
|
8
|
+
from aiofiles import open as aio_open
|
9
|
+
import aiohttp
|
10
|
+
from Crypto.Cipher import AES
|
11
|
+
from Crypto.Util.Padding import unpad
|
12
|
+
|
13
|
+
from pypetkitapi.feeder_container import Feeder, RecordsItems
|
14
|
+
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class MediasFiles:
|
20
|
+
"""Dataclass for media files.
|
21
|
+
Subclass of many other device dataclasses.
|
22
|
+
"""
|
23
|
+
|
24
|
+
filename: str
|
25
|
+
record_type: str
|
26
|
+
url: str
|
27
|
+
aes_key: str
|
28
|
+
|
29
|
+
|
30
|
+
async def extract_filename_from_url(url: str) -> str:
|
31
|
+
"""Extract the filename from the URL and format it as requested."""
|
32
|
+
match = re.search(r"https?://[^/]+(/[^?]+)", url)
|
33
|
+
if match:
|
34
|
+
path = match.group(1)
|
35
|
+
formatted_filename = path.replace("/", "_").lstrip("_").lower()
|
36
|
+
return f"{formatted_filename}.jpg"
|
37
|
+
raise ValueError(f"Failed to extract filename from URL: {url}")
|
38
|
+
|
39
|
+
|
40
|
+
class MediaHandler:
|
41
|
+
"""Class to find media files from PetKit devices."""
|
42
|
+
|
43
|
+
def __init__(self, device: Feeder, file_path: str):
|
44
|
+
"""Initialize the class."""
|
45
|
+
self.device = device
|
46
|
+
self.media_download_decode = MediaDownloadDecode(file_path)
|
47
|
+
self.media_files: list[MediasFiles] = []
|
48
|
+
|
49
|
+
async def get_last_image(self) -> list[MediasFiles]:
|
50
|
+
"""Process device records and extract media info."""
|
51
|
+
record_types = ["eat", "feed", "move", "pet"]
|
52
|
+
self.media_files = []
|
53
|
+
|
54
|
+
if not self.device.device_records:
|
55
|
+
_LOGGER.error("No device records found for feeder")
|
56
|
+
return []
|
57
|
+
|
58
|
+
for record_type in record_types:
|
59
|
+
records = getattr(self.device.device_records, record_type, None)
|
60
|
+
if records:
|
61
|
+
self.media_files.extend(
|
62
|
+
await self._process_records(records, record_type)
|
63
|
+
)
|
64
|
+
|
65
|
+
return self.media_files
|
66
|
+
|
67
|
+
async def _process_records(
|
68
|
+
self, records: RecordsItems, record_type: str
|
69
|
+
) -> list[MediasFiles]:
|
70
|
+
"""Process individual records and return media info."""
|
71
|
+
media_files = []
|
72
|
+
|
73
|
+
if record_type == "feed":
|
74
|
+
for record in reversed(records):
|
75
|
+
if record.items:
|
76
|
+
last_item = next(
|
77
|
+
(
|
78
|
+
item
|
79
|
+
for item in reversed(record.items)
|
80
|
+
if item.preview and item.aes_key
|
81
|
+
),
|
82
|
+
None,
|
83
|
+
)
|
84
|
+
if last_item:
|
85
|
+
filename = await extract_filename_from_url(last_item.preview)
|
86
|
+
await self.media_download_decode.get_file(
|
87
|
+
last_item.preview, last_item.aes_key
|
88
|
+
)
|
89
|
+
media_files.append(
|
90
|
+
MediasFiles(
|
91
|
+
record_type=record_type,
|
92
|
+
filename=filename,
|
93
|
+
url=last_item.preview,
|
94
|
+
aes_key=last_item.aes_key,
|
95
|
+
)
|
96
|
+
)
|
97
|
+
return media_files
|
98
|
+
else:
|
99
|
+
for record in records:
|
100
|
+
if record.items:
|
101
|
+
last_item = record.items[-1]
|
102
|
+
preview_url = last_item.preview
|
103
|
+
aes_key = last_item.aes_key
|
104
|
+
|
105
|
+
if preview_url and aes_key:
|
106
|
+
filename = await extract_filename_from_url(preview_url)
|
107
|
+
await self.media_download_decode.get_file(preview_url, aes_key)
|
108
|
+
media_files.append(
|
109
|
+
MediasFiles(
|
110
|
+
record_type=record_type,
|
111
|
+
filename=filename,
|
112
|
+
url=preview_url,
|
113
|
+
aes_key=aes_key,
|
114
|
+
)
|
115
|
+
)
|
116
|
+
return media_files
|
117
|
+
|
118
|
+
|
119
|
+
class MediaDownloadDecode:
|
120
|
+
"""Class to download"""
|
121
|
+
|
122
|
+
def __init__(self, download_path: str):
|
123
|
+
"""Initialize the class."""
|
124
|
+
self.download_path = download_path
|
125
|
+
|
126
|
+
async def get_file(self, url: str, aes_key: str) -> bool:
|
127
|
+
"""Download a file from a URL and decrypt it."""
|
128
|
+
try:
|
129
|
+
# Check if the file already exists
|
130
|
+
filename = await extract_filename_from_url(url)
|
131
|
+
full_file_path = Path(self.download_path) / filename
|
132
|
+
if full_file_path.exists():
|
133
|
+
_LOGGER.debug(
|
134
|
+
"File already exist : %s don't need to download it", filename
|
135
|
+
)
|
136
|
+
return True
|
137
|
+
|
138
|
+
# Download the file
|
139
|
+
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
140
|
+
if response.status != 200:
|
141
|
+
_LOGGER.error(
|
142
|
+
"Failed to download %s, status code: %s", url, response.status
|
143
|
+
)
|
144
|
+
return False
|
145
|
+
|
146
|
+
content = await response.read()
|
147
|
+
encrypted_file_path = await self._save_file(content, f"{filename}.enc")
|
148
|
+
# Decrypt the image
|
149
|
+
decrypted_data = await self._decrypt_image_from_file(
|
150
|
+
encrypted_file_path, aes_key
|
151
|
+
)
|
152
|
+
|
153
|
+
if decrypted_data:
|
154
|
+
_LOGGER.debug("Decrypt was successful")
|
155
|
+
await self._save_file(decrypted_data, filename)
|
156
|
+
Path(encrypted_file_path).unlink()
|
157
|
+
return True
|
158
|
+
_LOGGER.error("Failed to decrypt %s", encrypted_file_path)
|
159
|
+
except Exception as e: # noqa: BLE001
|
160
|
+
_LOGGER.error("Error get media file from %s: %s", url, e)
|
161
|
+
return False
|
162
|
+
|
163
|
+
async def _save_file(self, content: bytes, filename: str) -> Path:
|
164
|
+
"""Save content to a file asynchronously and return the file path."""
|
165
|
+
file_path = Path(self.download_path) / filename
|
166
|
+
try:
|
167
|
+
async with aio_open(file_path, "wb") as file:
|
168
|
+
await file.write(content)
|
169
|
+
_LOGGER.debug("Saved file: %s", file_path)
|
170
|
+
except OSError as e:
|
171
|
+
_LOGGER.error("Error saving file %s: %s", file_path, e)
|
172
|
+
return file_path
|
173
|
+
|
174
|
+
async def _decrypt_image_from_file(
|
175
|
+
self, file_path: Path, aes_key: str
|
176
|
+
) -> bytes | None:
|
177
|
+
"""Decrypt an image from a file using AES encryption.
|
178
|
+
:param file_path: Path to the encrypted image file.
|
179
|
+
:param aes_key: AES key used for decryption.
|
180
|
+
:return: Decrypted image data.
|
181
|
+
"""
|
182
|
+
try:
|
183
|
+
if aes_key.endswith("\n"):
|
184
|
+
aes_key = aes_key[:-1]
|
185
|
+
key_bytes: bytes = aes_key.encode("utf-8")
|
186
|
+
iv: bytes = b"\x61" * 16
|
187
|
+
cipher: AES = AES.new(key_bytes, AES.MODE_CBC, iv)
|
188
|
+
|
189
|
+
async with aio_open(file_path, "rb") as encrypted_file:
|
190
|
+
encrypted_data: bytes = await encrypted_file.read()
|
191
|
+
|
192
|
+
decrypted_data: bytes = unpad(
|
193
|
+
cipher.decrypt(encrypted_data), AES.block_size
|
194
|
+
)
|
195
|
+
except Exception as e: # noqa: BLE001
|
196
|
+
logging.error("Error decrypting image from file %s: %s", file_path, e)
|
197
|
+
return None
|
198
|
+
return decrypted_data
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: pypetkitapi
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.6.0
|
4
4
|
Summary: Python client for PetKit API
|
5
5
|
Home-page: https://github.com/Jezza34000/pypetkit
|
6
6
|
License: MIT
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
15
15
|
Requires-Dist: aiohttp (>=3.11.0,<4.0.0)
|
16
|
+
Requires-Dist: pycryptodome (>=3.21.0,<4.0.0)
|
16
17
|
Description-Content-Type: text/markdown
|
17
18
|
|
18
19
|
# Petkit API Client
|
@@ -2,13 +2,14 @@ pypetkitapi/__init__.py,sha256=eVpyGMD3tkYtiHUkdKEeNSZhQlZ4woI2Y5oVoV7CwXM,61
|
|
2
2
|
pypetkitapi/client.py,sha256=iLf1EZRMvuMVwtJ-1PQQ_WT6YjbDDcF--E-5gB7iM44,16716
|
3
3
|
pypetkitapi/command.py,sha256=gw3_J_oZHuuGLk66P8uRSqSrySjYa8ArpKaPHi2ybCw,7155
|
4
4
|
pypetkitapi/const.py,sha256=9XNLhM9k0GwNmWPgGef5roULpsYVZ7hzxptGgNhjs74,3432
|
5
|
-
pypetkitapi/containers.py,sha256=
|
5
|
+
pypetkitapi/containers.py,sha256=zj0MBEYA7wp2Nieu1oW4JROs_r7il9VZN8AUyQv-gJs,4192
|
6
6
|
pypetkitapi/exceptions.py,sha256=NWmpsI2ewC4HaIeu_uFwCeuPIHIJxZBzjoCP7aNwvhs,1139
|
7
7
|
pypetkitapi/feeder_container.py,sha256=28GXZ8Nbs08PnFZZI4ENBe3UJ63gsXT3rFa151KrPxo,13310
|
8
|
-
pypetkitapi/litter_container.py,sha256=
|
8
|
+
pypetkitapi/litter_container.py,sha256=UmgIE1iYNFetHjnTXfM6jhRnOk_0Ubdyr697-6SGKqc,18042
|
9
|
+
pypetkitapi/medias.py,sha256=2cVgB8eiTxTHCUExPrl2Hon9lg-GLEuqL_Rpay7A4P8,7363
|
9
10
|
pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
11
|
pypetkitapi/water_fountain_container.py,sha256=j6FQEgbZAbsPXDVhcIB05RsNnQJftcWUJKmzuX4TrjM,6712
|
11
|
-
pypetkitapi-1.
|
12
|
-
pypetkitapi-1.
|
13
|
-
pypetkitapi-1.
|
14
|
-
pypetkitapi-1.
|
12
|
+
pypetkitapi-1.6.0.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
|
13
|
+
pypetkitapi-1.6.0.dist-info/METADATA,sha256=qxsGx-CErdYdR3YAN4ixrfS4-XtLcu4SAATOxWS8QTM,4636
|
14
|
+
pypetkitapi-1.6.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
15
|
+
pypetkitapi-1.6.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|