pypetkitapi 1.9.3__py3-none-any.whl → 1.10.1__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 +27 -6
- pypetkitapi/bluetooth.py +174 -0
- pypetkitapi/client.py +98 -206
- pypetkitapi/command.py +14 -22
- pypetkitapi/const.py +41 -8
- pypetkitapi/containers.py +9 -0
- pypetkitapi/exceptions.py +9 -0
- pypetkitapi/feeder_container.py +9 -1
- pypetkitapi/litter_container.py +9 -1
- pypetkitapi/media.py +592 -0
- pypetkitapi/schedule_container.py +67 -0
- {pypetkitapi-1.9.3.dist-info → pypetkitapi-1.10.1.dist-info}/METADATA +6 -3
- pypetkitapi-1.10.1.dist-info/RECORD +19 -0
- {pypetkitapi-1.9.3.dist-info → pypetkitapi-1.10.1.dist-info}/WHEEL +1 -1
- pypetkitapi/medias.py +0 -199
- pypetkitapi-1.9.3.dist-info/RECORD +0 -17
- {pypetkitapi-1.9.3.dist-info → pypetkitapi-1.10.1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,67 @@
|
|
1
|
+
"""Dataclasses for Schedule."""
|
2
|
+
|
3
|
+
from datetime import datetime
|
4
|
+
from typing import Any, ClassVar
|
5
|
+
|
6
|
+
from pydantic import BaseModel, Field
|
7
|
+
|
8
|
+
from pypetkitapi.const import DEVICE_DATA, PetkitEndpoint
|
9
|
+
from pypetkitapi.containers import Device
|
10
|
+
|
11
|
+
|
12
|
+
class Owner(BaseModel):
|
13
|
+
"""Dataclass for Owner Data."""
|
14
|
+
|
15
|
+
device_count: int | None = Field(0, alias="deviceCount")
|
16
|
+
id: str | None = None
|
17
|
+
pet_count: int | None = Field(0, alias="petCount")
|
18
|
+
user_count: int | None = Field(0, alias="userCount")
|
19
|
+
|
20
|
+
|
21
|
+
class Type(BaseModel):
|
22
|
+
"""Dataclass for Type Data."""
|
23
|
+
|
24
|
+
enable: int | None = None
|
25
|
+
id: str | None = None
|
26
|
+
img: str | None = None
|
27
|
+
is_custom: int | None = Field(0, alias="isCustom")
|
28
|
+
name: str | None = None
|
29
|
+
priority: int | None = None
|
30
|
+
repeat_option: str | None = Field(alias="repeatOption")
|
31
|
+
rpt: str | None = None
|
32
|
+
schedule_appoint: str | None = Field(alias="scheduleAppoint")
|
33
|
+
with_device_type: str | None = Field(alias="withDeviceType")
|
34
|
+
with_pet: int | None = Field(0, alias="withPet")
|
35
|
+
|
36
|
+
|
37
|
+
class Schedule(BaseModel):
|
38
|
+
"""Dataclass for Schedule Data."""
|
39
|
+
|
40
|
+
data_type: ClassVar[str] = DEVICE_DATA
|
41
|
+
|
42
|
+
alarm_before: int | None = Field(0, alias="alarmBefore")
|
43
|
+
created_at: datetime | None = Field(None, alias="createdAt")
|
44
|
+
device_id: str | None = Field(None, alias="deviceId")
|
45
|
+
device_type: str | None = Field(None, alias="deviceType")
|
46
|
+
id: str | None = None
|
47
|
+
name: str | None = None
|
48
|
+
owner: Owner | None = None
|
49
|
+
repeat: str | None = None
|
50
|
+
status: int | None = None
|
51
|
+
time: datetime | None = None
|
52
|
+
type: Type | None = None
|
53
|
+
user_custom_id: int | None = Field(0, alias="userCustomId")
|
54
|
+
|
55
|
+
@classmethod
|
56
|
+
def get_endpoint(cls, device_type: str) -> str:
|
57
|
+
"""Get the endpoint URL for the given device type."""
|
58
|
+
return PetkitEndpoint.SCHEDULE
|
59
|
+
|
60
|
+
@classmethod
|
61
|
+
def query_param(
|
62
|
+
cls,
|
63
|
+
device: Device,
|
64
|
+
device_data: Any | None = None,
|
65
|
+
) -> dict:
|
66
|
+
"""Generate query parameters including request_date."""
|
67
|
+
return {"limit": 20}
|
@@ -1,8 +1,7 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: pypetkitapi
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.10.1
|
4
4
|
Summary: Python client for PetKit API
|
5
|
-
Home-page: https://github.com/Jezza34000/pypetkit
|
6
5
|
License: MIT
|
7
6
|
Author: Jezza34000
|
8
7
|
Author-email: info@mail.com
|
@@ -14,8 +13,10 @@ Classifier: Programming Language :: Python :: 3.12
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.13
|
15
14
|
Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
|
16
15
|
Requires-Dist: aiohttp (>=3.10.10,<4.0.0)
|
16
|
+
Requires-Dist: m3u8 (>=6.0)
|
17
17
|
Requires-Dist: pycryptodome (>=3.19.1,<4.0.0)
|
18
18
|
Requires-Dist: pydantic (>=1.10.18,<3.0.0)
|
19
|
+
Project-URL: Homepage, https://github.com/Jezza34000/pypetkit
|
19
20
|
Description-Content-Type: text/markdown
|
20
21
|
|
21
22
|
# Petkit API Client
|
@@ -110,7 +111,9 @@ async def main():
|
|
110
111
|
# simple hopper :
|
111
112
|
await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount": 1})
|
112
113
|
# dual hopper :
|
113
|
-
await client.send_api_request(123456789, FeederCommand.
|
114
|
+
await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount1": 2})
|
115
|
+
# or
|
116
|
+
await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount2": 2})
|
114
117
|
|
115
118
|
### Example 3 : Start the cleaning process
|
116
119
|
### Device_ID, Command, Payload
|
@@ -0,0 +1,19 @@
|
|
1
|
+
pypetkitapi/__init__.py,sha256=8Y70aqpRJgFqcA64OlYIxp0f6gLGpsXWuSpi69TXXN8,2107
|
2
|
+
pypetkitapi/bluetooth.py,sha256=u_xGp701WnrroTOt_KuIVUCZ3kRQ7BJeoMR8b9RpJ54,7176
|
3
|
+
pypetkitapi/client.py,sha256=6HdTx4Bj8zwHzSzvQz1acdRzGLCX8nETsTeIv-BVc9M,26921
|
4
|
+
pypetkitapi/command.py,sha256=cMCUutZCQo9Ddvjl_FYR5UjU_CqFz1iyetMznYwjpzM,7500
|
5
|
+
pypetkitapi/const.py,sha256=US5QihmBYvlm8hIHX0PORPUnMmDW3nmLzwLWTepkkGg,4609
|
6
|
+
pypetkitapi/containers.py,sha256=F_uyDBD0a5QD4s_ArjYiKTAAg1XHYBvmV_lEnO9RQ-U,4786
|
7
|
+
pypetkitapi/exceptions.py,sha256=4BXUyYXLfZjNxdnOGJPjyE9ASIl7JmQphjws87jvHtE,1631
|
8
|
+
pypetkitapi/feeder_container.py,sha256=PhidWd5WpsZqtdKZy60PzE67YXgQfApjm8CqvMCHK3U,14743
|
9
|
+
pypetkitapi/litter_container.py,sha256=0HrBjxylkMzxDyLWoReMaLAGRBh1YIJsifGhXzB54hA,19275
|
10
|
+
pypetkitapi/media.py,sha256=31fjIznqfIRpmdObJPYNSwSa95fYmRQnqxEs9Wq5gcM,22116
|
11
|
+
pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
|
12
|
+
pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
+
pypetkitapi/schedule_container.py,sha256=OjLAY6FY-g14JNJJnYMNFV5ZtdkjUzNBit1VUiiZKnQ,2053
|
14
|
+
pypetkitapi/utils.py,sha256=z7325kcJQUburnF28HSXrJMvY_gY9007K73Zwxp-4DQ,743
|
15
|
+
pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
|
16
|
+
pypetkitapi-1.10.1.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
|
17
|
+
pypetkitapi-1.10.1.dist-info/METADATA,sha256=kzPqa6ZgyoZaVsgYL5aroM0h12mmBzXVz0ZxqWwSKjs,6256
|
18
|
+
pypetkitapi-1.10.1.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
19
|
+
pypetkitapi-1.10.1.dist-info/RECORD,,
|
pypetkitapi/medias.py
DELETED
@@ -1,199 +0,0 @@
|
|
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
|
-
from typing import Any
|
8
|
-
|
9
|
-
from aiofiles import open as aio_open
|
10
|
-
import aiohttp
|
11
|
-
from Crypto.Cipher import AES
|
12
|
-
from Crypto.Util.Padding import unpad
|
13
|
-
|
14
|
-
from pypetkitapi.feeder_container import Feeder, RecordsType
|
15
|
-
|
16
|
-
_LOGGER = logging.getLogger(__name__)
|
17
|
-
|
18
|
-
|
19
|
-
@dataclass
|
20
|
-
class MediasFiles:
|
21
|
-
"""Dataclass for media files.
|
22
|
-
Subclass of many other device dataclasses.
|
23
|
-
"""
|
24
|
-
|
25
|
-
filename: str
|
26
|
-
record_type: str
|
27
|
-
url: str
|
28
|
-
aes_key: str
|
29
|
-
timestamp: str | None = None
|
30
|
-
|
31
|
-
|
32
|
-
async def extract_filename_from_url(url: str) -> str:
|
33
|
-
"""Extract the filename from the URL and format it as requested."""
|
34
|
-
match = re.search(r"https?://[^/]+(/[^?]+)", url)
|
35
|
-
if match:
|
36
|
-
path = match.group(1)
|
37
|
-
formatted_filename = path.replace("/", "_").lstrip("_").lower()
|
38
|
-
return f"{formatted_filename}.jpg"
|
39
|
-
raise ValueError(f"Failed to extract filename from URL: {url}")
|
40
|
-
|
41
|
-
|
42
|
-
class MediaHandler:
|
43
|
-
"""Class to find media files from PetKit devices."""
|
44
|
-
|
45
|
-
def __init__(self, file_path: Path):
|
46
|
-
"""Initialize the class."""
|
47
|
-
self.media_download_decode = MediaDownloadDecode(file_path)
|
48
|
-
self.media_files: list[MediasFiles] = []
|
49
|
-
|
50
|
-
async def get_last_image(self, device: Feeder) -> list[MediasFiles]:
|
51
|
-
"""Process device records and extract media info."""
|
52
|
-
record_types = ["eat", "feed", "move", "pet"]
|
53
|
-
self.media_files = []
|
54
|
-
|
55
|
-
if not isinstance(device, Feeder):
|
56
|
-
_LOGGER.error("Device is not a Feeder")
|
57
|
-
return []
|
58
|
-
|
59
|
-
if not device.device_records:
|
60
|
-
_LOGGER.error("No device records found for feeder")
|
61
|
-
return []
|
62
|
-
|
63
|
-
for record_type in record_types:
|
64
|
-
records = getattr(device.device_records, record_type, None)
|
65
|
-
if records:
|
66
|
-
self.media_files.extend(
|
67
|
-
await self._process_records(records, record_type)
|
68
|
-
)
|
69
|
-
return self.media_files
|
70
|
-
|
71
|
-
async def _process_records(
|
72
|
-
self, records: RecordsType, record_type: str
|
73
|
-
) -> list[MediasFiles]:
|
74
|
-
"""Process individual records and return media info."""
|
75
|
-
media_files = []
|
76
|
-
|
77
|
-
async def process_item(record_items):
|
78
|
-
last_item = next(
|
79
|
-
(
|
80
|
-
item
|
81
|
-
for item in reversed(record_items)
|
82
|
-
if item.preview and item.aes_key
|
83
|
-
),
|
84
|
-
None,
|
85
|
-
)
|
86
|
-
if last_item:
|
87
|
-
filename = await extract_filename_from_url(last_item.preview)
|
88
|
-
await self.media_download_decode.get_file(
|
89
|
-
last_item.preview, last_item.aes_key
|
90
|
-
)
|
91
|
-
timestamp = (
|
92
|
-
last_item.eat_start_time
|
93
|
-
or last_item.completed_at
|
94
|
-
or last_item.timestamp
|
95
|
-
or None
|
96
|
-
)
|
97
|
-
media_files.append(
|
98
|
-
MediasFiles(
|
99
|
-
record_type=record_type,
|
100
|
-
filename=filename,
|
101
|
-
url=last_item.preview,
|
102
|
-
aes_key=last_item.aes_key,
|
103
|
-
timestamp=timestamp,
|
104
|
-
)
|
105
|
-
)
|
106
|
-
|
107
|
-
for record in records:
|
108
|
-
if hasattr(record, "items"):
|
109
|
-
await process_item(record.items) # type: ignore[attr-defined]
|
110
|
-
|
111
|
-
return media_files
|
112
|
-
|
113
|
-
|
114
|
-
class MediaDownloadDecode:
|
115
|
-
"""Class to download"""
|
116
|
-
|
117
|
-
def __init__(self, download_path: Path):
|
118
|
-
"""Initialize the class."""
|
119
|
-
self.download_path = download_path
|
120
|
-
|
121
|
-
async def get_file(self, url: str, aes_key: str) -> bool:
|
122
|
-
"""Download a file from a URL and decrypt it."""
|
123
|
-
# Check if the file already exists
|
124
|
-
filename = await extract_filename_from_url(url)
|
125
|
-
full_file_path = Path(self.download_path) / filename
|
126
|
-
if full_file_path.exists():
|
127
|
-
_LOGGER.debug("File already exist : %s don't need to download it", filename)
|
128
|
-
return True
|
129
|
-
|
130
|
-
# Download the file
|
131
|
-
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
132
|
-
if response.status != 200:
|
133
|
-
_LOGGER.error(
|
134
|
-
"Failed to download %s, status code: %s", url, response.status
|
135
|
-
)
|
136
|
-
return False
|
137
|
-
|
138
|
-
content = await response.read()
|
139
|
-
|
140
|
-
encrypted_file_path = await self._save_file(content, f"{filename}.enc")
|
141
|
-
# Decrypt the image
|
142
|
-
decrypted_data = await self._decrypt_image_from_file(
|
143
|
-
encrypted_file_path, aes_key
|
144
|
-
)
|
145
|
-
|
146
|
-
if decrypted_data:
|
147
|
-
_LOGGER.debug("Decrypt was successful")
|
148
|
-
await self._save_file(decrypted_data, filename)
|
149
|
-
return True
|
150
|
-
return False
|
151
|
-
|
152
|
-
async def _save_file(self, content: bytes, filename: str) -> Path:
|
153
|
-
"""Save content to a file asynchronously and return the file path."""
|
154
|
-
file_path = Path(self.download_path) / filename
|
155
|
-
try:
|
156
|
-
# Ensure the directory exists
|
157
|
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
158
|
-
|
159
|
-
async with aio_open(file_path, "wb") as file:
|
160
|
-
await file.write(content)
|
161
|
-
_LOGGER.debug("Save file OK : %s", file_path)
|
162
|
-
except PermissionError as e:
|
163
|
-
_LOGGER.error("Save file, permission denied %s: %s", file_path, e)
|
164
|
-
except FileNotFoundError as e:
|
165
|
-
_LOGGER.error("Save file, file/folder not found %s: %s", file_path, e)
|
166
|
-
except OSError as e:
|
167
|
-
_LOGGER.error("Save file, error saving file %s: %s", file_path, e)
|
168
|
-
except Exception as e: # noqa: BLE001
|
169
|
-
_LOGGER.error(
|
170
|
-
"Save file, unexpected error saving file %s: %s", file_path, e
|
171
|
-
)
|
172
|
-
return file_path
|
173
|
-
|
174
|
-
@staticmethod
|
175
|
-
async def _decrypt_image_from_file(file_path: Path, aes_key: str) -> bytes | None:
|
176
|
-
"""Decrypt an image from a file using AES encryption.
|
177
|
-
:param file_path: Path to the encrypted image file.
|
178
|
-
:param aes_key: AES key used for decryption.
|
179
|
-
:return: Decrypted image data.
|
180
|
-
"""
|
181
|
-
try:
|
182
|
-
if aes_key.endswith("\n"):
|
183
|
-
aes_key = aes_key[:-1]
|
184
|
-
key_bytes: bytes = aes_key.encode("utf-8")
|
185
|
-
iv: bytes = b"\x61" * 16
|
186
|
-
cipher: Any = AES.new(key_bytes, AES.MODE_CBC, iv)
|
187
|
-
|
188
|
-
async with aio_open(file_path, "rb") as encrypted_file:
|
189
|
-
encrypted_data: bytes = await encrypted_file.read()
|
190
|
-
|
191
|
-
decrypted_data: bytes = unpad(
|
192
|
-
cipher.decrypt(encrypted_data), AES.block_size # type: ignore[attr-defined]
|
193
|
-
)
|
194
|
-
except Exception as e: # noqa: BLE001
|
195
|
-
logging.error("Error decrypting image from file %s: %s", file_path, e)
|
196
|
-
return None
|
197
|
-
if Path(file_path).exists():
|
198
|
-
Path(file_path).unlink()
|
199
|
-
return decrypted_data
|
@@ -1,17 +0,0 @@
|
|
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,,
|
File without changes
|