pypetkitapi 1.9.2__py3-none-any.whl → 1.9.4__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.
@@ -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}
pypetkitapi/utils.py ADDED
@@ -0,0 +1,22 @@
1
+ """Utils functions for the PyPetKit API."""
2
+
3
+ from datetime import datetime
4
+ import logging
5
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+
10
+ def get_timezone_offset(timezone_user: str) -> str:
11
+ """Get the timezone offset from its name. Return 0.0 if an error occurs."""
12
+ try:
13
+ timezone = ZoneInfo(timezone_user)
14
+ now = datetime.now(timezone)
15
+ offset = now.utcoffset()
16
+ if offset is None:
17
+ return "0.0"
18
+ offset_in_hours = offset.total_seconds() / 3600
19
+ return str(offset_in_hours)
20
+ except (ZoneInfoNotFoundError, AttributeError) as e:
21
+ _LOGGER.warning("Cannot get timezone offset for %s: %s", timezone_user, e)
22
+ return "0.0"
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2020 Jezza34000
3
+ Copyright (c) 2024 - 2025 Jezza34000
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pypetkitapi
3
- Version: 1.9.2
3
+ Version: 1.9.4
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
@@ -32,6 +33,12 @@ Description-Content-Type: text/markdown
32
33
  [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
33
34
  [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
34
35
  [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
36
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=bugs)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
37
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
38
+ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
39
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
40
+
41
+ [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
35
42
 
36
43
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
37
44
  [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
@@ -104,7 +111,9 @@ async def main():
104
111
  # simple hopper :
105
112
  await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount": 1})
106
113
  # dual hopper :
107
- await client.send_api_request(123456789, FeederCommand.MANUAL_FEED_DUAL, {"amount1": 2})
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})
108
117
 
109
118
  ### Example 3 : Start the cleaning process
110
119
  ### Device_ID, Command, Payload
@@ -0,0 +1,18 @@
1
+ pypetkitapi/__init__.py,sha256=kp58MpP6LwBcz2o1IpOjgN7c8bm4qtH361l4A0tWA4M,1607
2
+ pypetkitapi/client.py,sha256=0vM-fsu_cGE2_XKd8kJFQdJTXyBEGvSt3zHjFU13dns,32169
3
+ pypetkitapi/command.py,sha256=cMCUutZCQo9Ddvjl_FYR5UjU_CqFz1iyetMznYwjpzM,7500
4
+ pypetkitapi/const.py,sha256=g_oz73Emiw7nMYi3ANaUkUVLNtdacST7weyui5FviYg,4516
5
+ pypetkitapi/containers.py,sha256=oJR22ZruMr-0IRgiucdnj_nutOH59MKvmaFTwLJNiJI,4635
6
+ pypetkitapi/exceptions.py,sha256=cBLj2kP70yd6rfWnOXTCXo1a2TXca8QtxiRMa1UrttU,1644
7
+ pypetkitapi/feeder_container.py,sha256=ZGJhgqP-gjTFB2q91XoyZQ_G1S5cAY37JoqqHbzoanU,14640
8
+ pypetkitapi/litter_container.py,sha256=-z2BtdtRg8RyLJzJYY3AIACs9GGZ0C64hVhW4do6yQo,19172
9
+ pypetkitapi/media.py,sha256=a-ZhM7khu50Htn1jYNnsQNmwhpG0CnT_QW752Is-J4Q,15611
10
+ pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
11
+ pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pypetkitapi/schedule_container.py,sha256=OjLAY6FY-g14JNJJnYMNFV5ZtdkjUzNBit1VUiiZKnQ,2053
13
+ pypetkitapi/utils.py,sha256=z7325kcJQUburnF28HSXrJMvY_gY9007K73Zwxp-4DQ,743
14
+ pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
15
+ pypetkitapi-1.9.4.dist-info/LICENSE,sha256=u5jNkZEn6YMrtN4Kr5rU3TcBJ5-eAt0qMx4JDsbsnzM,1074
16
+ pypetkitapi-1.9.4.dist-info/METADATA,sha256=1OcbAB1WBj1uCCVYeqLSOPY7Rj_uVBeivBAPm7sTQNk,6255
17
+ pypetkitapi-1.9.4.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
18
+ pypetkitapi-1.9.4.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.0.0
2
+ Generator: poetry-core 2.0.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
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
-
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, file_path: Path):
45
- """Initialize the class."""
46
- self.media_download_decode = MediaDownloadDecode(file_path)
47
- self.media_files: list[MediasFiles] = []
48
-
49
- async def get_last_image(self, device: Feeder) -> 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 isinstance(device, Feeder):
55
- _LOGGER.error("Device is not a Feeder")
56
- return []
57
-
58
- if not device.device_records:
59
- _LOGGER.error("No device records found for feeder")
60
- return []
61
-
62
- for record_type in record_types:
63
- records = getattr(device.device_records, record_type, None)
64
- if records:
65
- self.media_files.extend(
66
- await self._process_records(records, record_type)
67
- )
68
- return self.media_files
69
-
70
- async def _process_records(
71
- self, records: RecordsItems, record_type: str
72
- ) -> list[MediasFiles]:
73
- """Process individual records and return media info."""
74
- media_files = []
75
-
76
- async def process_item(record_items):
77
- last_item = next(
78
- (
79
- item
80
- for item in reversed(record_items)
81
- if item.preview and item.aes_key
82
- ),
83
- None,
84
- )
85
- if last_item:
86
- filename = await extract_filename_from_url(last_item.preview)
87
- await self.media_download_decode.get_file(
88
- last_item.preview, last_item.aes_key
89
- )
90
- timestamp = (
91
- last_item.eat_start_time
92
- or last_item.completed_at
93
- or last_item.timestamp
94
- or None
95
- )
96
- media_files.append(
97
- MediasFiles(
98
- record_type=record_type,
99
- filename=filename,
100
- url=last_item.preview,
101
- aes_key=last_item.aes_key,
102
- timestamp=timestamp,
103
- )
104
- )
105
-
106
- for record in records:
107
- if record.items:
108
- await process_item(record.items)
109
-
110
- return media_files
111
-
112
-
113
- class MediaDownloadDecode:
114
- """Class to download"""
115
-
116
- def __init__(self, download_path: Path):
117
- """Initialize the class."""
118
- self.download_path = download_path
119
-
120
- async def get_file(self, url: str, aes_key: str) -> bool:
121
- """Download a file from a URL and decrypt it."""
122
- # Check if the file already exists
123
- filename = await extract_filename_from_url(url)
124
- full_file_path = Path(self.download_path) / filename
125
- if full_file_path.exists():
126
- _LOGGER.debug("File already exist : %s don't need to download it", filename)
127
- return True
128
-
129
- # Download the file
130
- async with aiohttp.ClientSession() as session, session.get(url) as response:
131
- if response.status != 200:
132
- _LOGGER.error(
133
- "Failed to download %s, status code: %s", url, response.status
134
- )
135
- return False
136
-
137
- content = await response.read()
138
-
139
- encrypted_file_path = await self._save_file(content, f"{filename}.enc")
140
- # Decrypt the image
141
- decrypted_data = await self._decrypt_image_from_file(
142
- encrypted_file_path, aes_key
143
- )
144
-
145
- if decrypted_data:
146
- _LOGGER.debug("Decrypt was successful")
147
- await self._save_file(decrypted_data, filename)
148
- return True
149
- return False
150
-
151
- async def _save_file(self, content: bytes, filename: str) -> Path:
152
- """Save content to a file asynchronously and return the file path."""
153
- file_path = Path(self.download_path) / filename
154
- try:
155
- # Ensure the directory exists
156
- file_path.parent.mkdir(parents=True, exist_ok=True)
157
-
158
- async with aio_open(file_path, "wb") as file:
159
- await file.write(content)
160
- _LOGGER.debug("Save file OK : %s", file_path)
161
- except PermissionError as e:
162
- _LOGGER.error("Save file, permission denied %s: %s", file_path, e)
163
- except FileNotFoundError as e:
164
- _LOGGER.error("Save file, file/folder not found %s: %s", file_path, e)
165
- except OSError as e:
166
- _LOGGER.error("Save file, error saving file %s: %s", file_path, e)
167
- except Exception as e: # noqa: BLE001
168
- _LOGGER.error(
169
- "Save file, unexpected error saving file %s: %s", file_path, e
170
- )
171
- return file_path
172
-
173
- async def _decrypt_image_from_file(
174
- self, file_path: Path, aes_key: str
175
- ) -> 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: AES = 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
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,16 +0,0 @@
1
- pypetkitapi/__init__.py,sha256=gwZUf8yep0CbngCc6uTqzZdmmTIaNTdIX5qE0YU6CrU,1562
2
- pypetkitapi/client.py,sha256=o8dBNxdupWwf7AIt6GB4Jc4SLExc0Zv1E-eX2Qjt5FY,27807
3
- pypetkitapi/command.py,sha256=G7AEtUcaK-lcRliNf4oUxPkvDO_GNBkJ-ZUcOo7DGHM,7697
4
- pypetkitapi/const.py,sha256=pkTJ0l-8mQix9aoJNC2UYfyUdG7ie826xnv7EkOZtPw,4208
5
- pypetkitapi/containers.py,sha256=oJR22ZruMr-0IRgiucdnj_nutOH59MKvmaFTwLJNiJI,4635
6
- pypetkitapi/exceptions.py,sha256=fuTLT6Iw2_kA7eOyNJPf59vQkgfByhAnTThY4lC0Rt0,1283
7
- pypetkitapi/feeder_container.py,sha256=vfgxPOwbAFd3OFDMXH8md_lk1RLVlEDCFMjbREB4eS4,14640
8
- pypetkitapi/litter_container.py,sha256=qKP3XFUkbzLREZPXEMDvpR1sqo6BI560O6eJYdkrX7w,19110
9
- pypetkitapi/medias.py,sha256=IuWkC7usw0Hbx173X8TGv24jOp4nqv6bIUosZBpXMGg,6945
10
- pypetkitapi/purifier_container.py,sha256=ssyIxhNben5XJ4KlQTXTrtULg2ji6DqHqjzOq08d1-I,2491
11
- pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- pypetkitapi/water_fountain_container.py,sha256=5J0b-fDZYcFLNX2El7fifv8H6JMhBCt-ttxSow1ozRQ,6787
13
- pypetkitapi-1.9.2.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
14
- pypetkitapi-1.9.2.dist-info/METADATA,sha256=RUqDHszBUmhesyoV2WfQX-RWQrGZdGSpm-IFBwwMDnE,5167
15
- pypetkitapi-1.9.2.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
16
- pypetkitapi-1.9.2.dist-info/RECORD,,