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 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
- device_type: str = "pet"
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):
@@ -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.4.0
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=m7vzWcJG0U1EPftuBF6OB8eTVRhCoA2DFqekxI6LozI,3428
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=oNjg-A_kNbGB3SvRntosaaL-vwLF8fbIKoLwoVm_ejQ,18036
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.4.0.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
12
- pypetkitapi-1.4.0.dist-info/METADATA,sha256=4kKgKgNKqJ-PvJb1Tfj8dQhNiczRZWbxRMQZESPev0A,4590
13
- pypetkitapi-1.4.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
14
- pypetkitapi-1.4.0.dist-info/RECORD,,
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,,