pypetkitapi 1.5.0__tar.gz → 1.6.0__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.5.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
@@ -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:
@@ -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
@@ -187,7 +187,7 @@ build-backend = "poetry.core.masonry.api"
187
187
 
188
188
  [tool.poetry]
189
189
  name = "pypetkitapi"
190
- version = "1.5.0"
190
+ version = "1.6.0"
191
191
  description = "Python client for PetKit API"
192
192
  authors = ["Jezza34000 <info@mail.com>"]
193
193
  readme = "README.md"
@@ -197,14 +197,16 @@ license = "MIT"
197
197
  [tool.poetry.dependencies]
198
198
  python = ">=3.11"
199
199
  aiohttp = "^3.11.0"
200
+ pycryptodome = "^3.21.0"
200
201
 
201
202
  [tool.poetry.dev-dependencies]
202
203
  pre-commit = "^4.0.1"
203
204
  black = "^24.10.0"
204
205
  ruff = "^0.8.1"
206
+ types-aiofiles = "^24.1.0.20240626"
205
207
 
206
208
  [tool.bumpver]
207
- current_version = "1.5.0"
209
+ current_version = "1.6.0"
208
210
  version_pattern = "MAJOR.MINOR.PATCH"
209
211
  commit_message = "bump version {old_version} -> {new_version}"
210
212
  tag_message = "{new_version}"
File without changes
File without changes