pypetkitapi 1.5.0__tar.gz → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pypetkitapi
3
- Version: 1.5.0
3
+ Version: 1.6.1
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,196 @@
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
+ timestamp: str | None = None
29
+
30
+
31
+ async def extract_filename_from_url(url: str) -> str:
32
+ """Extract the filename from the URL and format it as requested."""
33
+ match = re.search(r"https?://[^/]+(/[^?]+)", url)
34
+ if match:
35
+ path = match.group(1)
36
+ formatted_filename = path.replace("/", "_").lstrip("_").lower()
37
+ return f"{formatted_filename}.jpg"
38
+ raise ValueError(f"Failed to extract filename from URL: {url}")
39
+
40
+
41
+ class MediaHandler:
42
+ """Class to find media files from PetKit devices."""
43
+
44
+ def __init__(self, device: Feeder, file_path: str):
45
+ """Initialize the class."""
46
+ self.device = device
47
+ self.media_download_decode = MediaDownloadDecode(file_path)
48
+ self.media_files: list[MediasFiles] = []
49
+
50
+ async def get_last_image(self) -> 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 self.device.device_records:
56
+ _LOGGER.error("No device records found for feeder")
57
+ return []
58
+
59
+ for record_type in record_types:
60
+ records = getattr(self.device.device_records, record_type, None)
61
+ if records:
62
+ self.media_files.extend(
63
+ await self._process_records(records, record_type)
64
+ )
65
+
66
+ return self.media_files
67
+
68
+ async def _process_records(
69
+ self, records: RecordsItems, record_type: str
70
+ ) -> list[MediasFiles]:
71
+ """Process individual records and return media info."""
72
+ media_files = []
73
+
74
+ async def process_item(record_items, reversed_order=False):
75
+ items = reversed(record_items) if reversed_order else record_items
76
+ last_item = next(
77
+ (item for item in items if item.preview and item.aes_key),
78
+ None,
79
+ )
80
+ if last_item:
81
+ filename = await extract_filename_from_url(last_item.preview)
82
+ await self.media_download_decode.get_file(
83
+ last_item.preview, last_item.aes_key
84
+ )
85
+
86
+ timestamp = (
87
+ last_item.eat_start_time
88
+ or last_item.completed_at
89
+ or last_item.timestamp
90
+ or None
91
+ )
92
+
93
+ media_files.append(
94
+ MediasFiles(
95
+ record_type=record_type,
96
+ filename=filename,
97
+ url=last_item.preview,
98
+ aes_key=last_item.aes_key,
99
+ timestamp=timestamp,
100
+ )
101
+ )
102
+
103
+ if record_type == "feed":
104
+ for record in reversed(records):
105
+ if record.items:
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)
113
+
114
+ return media_files
115
+
116
+
117
+ class MediaDownloadDecode:
118
+ """Class to download"""
119
+
120
+ def __init__(self, download_path: str):
121
+ """Initialize the class."""
122
+ self.download_path = download_path
123
+
124
+ async def get_file(self, url: str, aes_key: str) -> bool:
125
+ """Download a file from a URL and decrypt it."""
126
+ try:
127
+ # Check if the file already exists
128
+ filename = await extract_filename_from_url(url)
129
+ full_file_path = Path(self.download_path) / filename
130
+ if full_file_path.exists():
131
+ _LOGGER.debug(
132
+ "File already exist : %s don't need to download it", filename
133
+ )
134
+ return True
135
+
136
+ # Download the file
137
+ async with aiohttp.ClientSession() as session, session.get(url) as response:
138
+ if response.status != 200:
139
+ _LOGGER.error(
140
+ "Failed to download %s, status code: %s", url, response.status
141
+ )
142
+ return False
143
+
144
+ content = await response.read()
145
+ encrypted_file_path = await self._save_file(content, f"{filename}.enc")
146
+ # Decrypt the image
147
+ decrypted_data = await self._decrypt_image_from_file(
148
+ encrypted_file_path, aes_key
149
+ )
150
+
151
+ if decrypted_data:
152
+ _LOGGER.debug("Decrypt was successful")
153
+ await self._save_file(decrypted_data, filename)
154
+ Path(encrypted_file_path).unlink()
155
+ return True
156
+ _LOGGER.error("Failed to decrypt %s", encrypted_file_path)
157
+ except Exception as e: # noqa: BLE001
158
+ _LOGGER.error("Error get media file from %s: %s", url, e)
159
+ return False
160
+
161
+ async def _save_file(self, content: bytes, filename: str) -> Path:
162
+ """Save content to a file asynchronously and return the file path."""
163
+ file_path = Path(self.download_path) / filename
164
+ try:
165
+ async with aio_open(file_path, "wb") as file:
166
+ await file.write(content)
167
+ _LOGGER.debug("Saved file: %s", file_path)
168
+ except OSError as e:
169
+ _LOGGER.error("Error saving file %s: %s", file_path, e)
170
+ return file_path
171
+
172
+ async def _decrypt_image_from_file(
173
+ self, file_path: Path, aes_key: str
174
+ ) -> bytes | None:
175
+ """Decrypt an image from a file using AES encryption.
176
+ :param file_path: Path to the encrypted image file.
177
+ :param aes_key: AES key used for decryption.
178
+ :return: Decrypted image data.
179
+ """
180
+ try:
181
+ if aes_key.endswith("\n"):
182
+ aes_key = aes_key[:-1]
183
+ key_bytes: bytes = aes_key.encode("utf-8")
184
+ iv: bytes = b"\x61" * 16
185
+ cipher: AES = AES.new(key_bytes, AES.MODE_CBC, iv)
186
+
187
+ async with aio_open(file_path, "rb") as encrypted_file:
188
+ encrypted_data: bytes = await encrypted_file.read()
189
+
190
+ decrypted_data: bytes = unpad(
191
+ cipher.decrypt(encrypted_data), AES.block_size
192
+ )
193
+ except Exception as e: # noqa: BLE001
194
+ logging.error("Error decrypting image from file %s: %s", file_path, e)
195
+ return None
196
+ 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.1"
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.1"
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