pypetkitapi 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Jezza34000
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.1
2
+ Name: pypetkitapi
3
+ Version: 0.1.0
4
+ Summary: Python client for PetKit API
5
+ Home-page: https://github.com/Jezza34000/pypetkit
6
+ License: MIT
7
+ Author: Jezza34000
8
+ Author-email: info@mail.com
9
+ Requires-Python: >=3.11
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: aiohttp (>=3.11.0,<4.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Petkit API Client
19
+
20
+ ---
21
+
22
+ # WIP - UNDER DEVELOPMENT
23
+
24
+ ---
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/pypetkitapi.svg)][pypi_]
27
+ [![Python Version](https://img.shields.io/pypi/pyversions/pypetkitapi)][python version]
28
+
29
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
30
+ [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
31
+ [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy.readthedocs.io/en/stable/)
32
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
33
+ [![Actions status](https://github.com/Jezza34000/py-petkit-api/workflows/CI/badge.svg)](https://github.com/Jezza34000/py-petkit-api/actions)
34
+
35
+ ---
36
+
37
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
38
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
39
+
40
+ [pypi_]: https://pypi.org/project/pypetkitapi/
41
+ [python version]: https://pypi.org/project/pypetkitapi
42
+ [pre-commit]: https://github.com/pre-commit/pre-commit
43
+ [black]: https://github.com/psf/black
44
+
45
+ ## Overview
46
+
47
+ PetKit Client is a Python library for interacting with the PetKit API. It allows you to manage your PetKit devices, retrieve account data, and control devices through the API.
48
+
49
+ ## Features
50
+
51
+ Login and session management
52
+ Fetch account and device data
53
+ Control PetKit devices (Feeder, Litter Box, Water Fountain)
54
+
55
+ ## Installation
56
+
57
+ Install the library using pip:
58
+
59
+ ```bash
60
+ pip install pypetkitapi
61
+ ```
62
+
63
+ ## Usage Example:
64
+
65
+ ```python
66
+ import asyncio
67
+ import logging
68
+ from pypetkitapi.client import PetKitClient
69
+
70
+ logging.basicConfig(level=logging.DEBUG)
71
+
72
+
73
+ async def main():
74
+ client = PetKitClient(
75
+ username="username", # Your PetKit account username
76
+ password="password", # Your PetKit account password
77
+ region="France", # Your region
78
+ timezone="Europe/Paris", # Your timezone
79
+ )
80
+
81
+ # To get the account and devices data attached to the account
82
+ await client.get_devices_data()
83
+ # Read the account data
84
+ print(client.account_data)
85
+ # Read the devices data
86
+ print(client.device_list)
87
+
88
+ # client.device_list[0] is the first device in the list in this example it's a Feeder
89
+ # Get the Feeder from the device list
90
+ my_feeder = client.device_list[0]
91
+
92
+ # Send command to the devices
93
+ ### Example 1 : Turn on the indicator light
94
+ await client.send_api_request(my_feeder, DeviceCommand.UPDATE_SETTING, {"lightMode": 1})
95
+
96
+ ### Example 2 : Feed the pet
97
+ await client.send_api_request(my_feeder, FeederCommand.MANUAL_FEED, {"amount": 1})
98
+
99
+
100
+ if __name__ == "__main__":
101
+ asyncio.run(main())
102
+ ```
103
+
104
+ ## Contributing
105
+
106
+ Contributions are welcome! Please open an issue or submit a pull request.
107
+
108
+ ## License
109
+
110
+ This project is licensed under the MIT License. See the LICENSE file for details.
111
+
@@ -0,0 +1,93 @@
1
+ # Petkit API Client
2
+
3
+ ---
4
+
5
+ # WIP - UNDER DEVELOPMENT
6
+
7
+ ---
8
+
9
+ [![PyPI](https://img.shields.io/pypi/v/pypetkitapi.svg)][pypi_]
10
+ [![Python Version](https://img.shields.io/pypi/pyversions/pypetkitapi)][python version]
11
+
12
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
13
+ [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
14
+ [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy.readthedocs.io/en/stable/)
15
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
16
+ [![Actions status](https://github.com/Jezza34000/py-petkit-api/workflows/CI/badge.svg)](https://github.com/Jezza34000/py-petkit-api/actions)
17
+
18
+ ---
19
+
20
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
21
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Jezza34000_py-petkit-api&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Jezza34000_py-petkit-api)
22
+
23
+ [pypi_]: https://pypi.org/project/pypetkitapi/
24
+ [python version]: https://pypi.org/project/pypetkitapi
25
+ [pre-commit]: https://github.com/pre-commit/pre-commit
26
+ [black]: https://github.com/psf/black
27
+
28
+ ## Overview
29
+
30
+ PetKit Client is a Python library for interacting with the PetKit API. It allows you to manage your PetKit devices, retrieve account data, and control devices through the API.
31
+
32
+ ## Features
33
+
34
+ Login and session management
35
+ Fetch account and device data
36
+ Control PetKit devices (Feeder, Litter Box, Water Fountain)
37
+
38
+ ## Installation
39
+
40
+ Install the library using pip:
41
+
42
+ ```bash
43
+ pip install pypetkitapi
44
+ ```
45
+
46
+ ## Usage Example:
47
+
48
+ ```python
49
+ import asyncio
50
+ import logging
51
+ from pypetkitapi.client import PetKitClient
52
+
53
+ logging.basicConfig(level=logging.DEBUG)
54
+
55
+
56
+ async def main():
57
+ client = PetKitClient(
58
+ username="username", # Your PetKit account username
59
+ password="password", # Your PetKit account password
60
+ region="France", # Your region
61
+ timezone="Europe/Paris", # Your timezone
62
+ )
63
+
64
+ # To get the account and devices data attached to the account
65
+ await client.get_devices_data()
66
+ # Read the account data
67
+ print(client.account_data)
68
+ # Read the devices data
69
+ print(client.device_list)
70
+
71
+ # client.device_list[0] is the first device in the list in this example it's a Feeder
72
+ # Get the Feeder from the device list
73
+ my_feeder = client.device_list[0]
74
+
75
+ # Send command to the devices
76
+ ### Example 1 : Turn on the indicator light
77
+ await client.send_api_request(my_feeder, DeviceCommand.UPDATE_SETTING, {"lightMode": 1})
78
+
79
+ ### Example 2 : Feed the pet
80
+ await client.send_api_request(my_feeder, FeederCommand.MANUAL_FEED, {"amount": 1})
81
+
82
+
83
+ if __name__ == "__main__":
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ ## Contributing
88
+
89
+ Contributions are welcome! Please open an issue or submit a pull request.
90
+
91
+ ## License
92
+
93
+ This project is licensed under the MIT License. See the LICENSE file for details.
@@ -0,0 +1 @@
1
+ """Pypetkit: A Python library for interfacing with PetKit"""
@@ -0,0 +1,355 @@
1
+ """Pypetkit Client: A Python library for interfacing with PetKit"""
2
+
3
+ from datetime import datetime, timedelta
4
+ from enum import StrEnum
5
+ import hashlib
6
+ from http import HTTPMethod
7
+ import logging
8
+
9
+ import aiohttp
10
+ from aiohttp import ContentTypeError
11
+
12
+ from pypetkitapi.command import ACTIONS_MAP
13
+ from pypetkitapi.const import (
14
+ DEVICES_FEEDER,
15
+ DEVICES_LITTER_BOX,
16
+ DEVICES_WATER_FOUNTAIN,
17
+ ERR_KEY,
18
+ LOGIN_DATA,
19
+ RES_KEY,
20
+ Header,
21
+ PetkitEndpoint,
22
+ PetkitURL,
23
+ )
24
+ from pypetkitapi.containers import AccountData, Device, RegionInfo, SessionInfo
25
+ from pypetkitapi.exceptions import PypetkitError
26
+ from pypetkitapi.feeder_container import Feeder
27
+ from pypetkitapi.litter_container import Litter
28
+ from pypetkitapi.water_fountain_container import WaterFountain
29
+
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+
33
+ class PetKitClient:
34
+ """Petkit Client"""
35
+
36
+ _base_url: str
37
+ _session: SessionInfo | None = None
38
+ _servers_list: list[RegionInfo] = []
39
+ account_data: list[AccountData] = []
40
+ device_list: list[Feeder | Litter | WaterFountain] = []
41
+
42
+ def __init__(
43
+ self,
44
+ username: str,
45
+ password: str,
46
+ region: str,
47
+ timezone: str,
48
+ ) -> None:
49
+ """Initialize the PetKit Client."""
50
+ self.username = username
51
+ self.password = password
52
+ self.region = region
53
+ self.timezone = timezone
54
+
55
+ async def _generate_header(self) -> dict[str, str]:
56
+ """Create header for interaction with devices."""
57
+ session_id = self._session.id if self._session is not None else ""
58
+ return {
59
+ "Accept": Header.ACCEPT.value,
60
+ "Accept-Language": Header.ACCEPT_LANG,
61
+ "Accept-Encoding": Header.ENCODING,
62
+ "Content-Type": Header.CONTENT_TYPE,
63
+ "User-Agent": Header.AGENT,
64
+ "X-Img-Version": Header.IMG_VERSION,
65
+ "X-Locale": Header.LOCALE,
66
+ "F-Session": session_id,
67
+ "X-Session": session_id,
68
+ "X-Client": Header.CLIENT,
69
+ "X-Hour": Header.HOUR,
70
+ "X-TimezoneId": Header.TIMEZONE_ID,
71
+ "X-Api-Version": Header.API_VERSION,
72
+ "X-Timezone": Header.TIMEZONE,
73
+ }
74
+
75
+ async def _get_api_server_list(self) -> None:
76
+ """Get the list of API servers and set the base URL."""
77
+ _LOGGER.debug("Getting API server list")
78
+ prep_req = PrepReq(base_url=PetkitURL.REGION_SRV)
79
+ response = await prep_req.request(
80
+ method=HTTPMethod.GET,
81
+ url="",
82
+ headers=await self._generate_header(),
83
+ )
84
+ _LOGGER.debug("API server list: %s", response)
85
+ self._servers_list = [
86
+ RegionInfo(**region) for region in response.get("list", [])
87
+ ]
88
+
89
+ async def _get_base_url(self) -> None:
90
+ """Find the region server for the specified region."""
91
+ await self._get_api_server_list()
92
+ _LOGGER.debug("Finding region server for region: %s", self.region)
93
+
94
+ regional_server = next(
95
+ (server for server in self._servers_list if server.name == self.region),
96
+ None,
97
+ )
98
+
99
+ if regional_server:
100
+ _LOGGER.debug(
101
+ "Found server %s for region : %s", regional_server, self.region
102
+ )
103
+ self._base_url = regional_server.gateway
104
+ return
105
+ _LOGGER.debug("Region %s not found in server list", self.region)
106
+
107
+ async def request_login_code(self) -> bool:
108
+ """Request a login code to be sent to the user's email."""
109
+ _LOGGER.debug("Requesting login code for username: %s", self.username)
110
+ prep_req = PrepReq(base_url=self._base_url)
111
+ response = await prep_req.request(
112
+ method=HTTPMethod.GET,
113
+ url=PetkitEndpoint.GET_LOGIN_CODE,
114
+ params={"username": self.username},
115
+ headers=await self._generate_header(),
116
+ )
117
+ if response:
118
+ _LOGGER.info("Login code sent to user's email")
119
+ return True
120
+ return False
121
+
122
+ async def login(self, valid_code: str | None = None) -> None:
123
+ """Login to the PetKit service and retrieve the appropriate server."""
124
+ # Retrieve the list of servers
125
+ await self._get_base_url()
126
+
127
+ # Prepare the data to send
128
+ data = LOGIN_DATA.copy()
129
+ data["encrypt"] = "1"
130
+ data["region"] = self.region
131
+ data["username"] = self.username
132
+
133
+ if valid_code:
134
+ _LOGGER.debug("Login method: using valid code")
135
+ data["validCode"] = valid_code
136
+ else:
137
+ _LOGGER.debug("Login method: using password")
138
+ pwd = hashlib.md5(self.password.encode()).hexdigest() # noqa: S324
139
+ data["password"] = pwd # noqa: S324
140
+
141
+ # Send the login request
142
+ prep_req = PrepReq(base_url=self._base_url)
143
+ response = await prep_req.request(
144
+ method=HTTPMethod.POST,
145
+ url=PetkitEndpoint.LOGIN,
146
+ data=data,
147
+ headers=await self._generate_header(),
148
+ )
149
+ session_data = response["session"]
150
+ self._session = SessionInfo(**session_data)
151
+
152
+ async def refresh_session(self) -> None:
153
+ """Refresh the session."""
154
+ _LOGGER.debug("Refreshing session")
155
+ prep_req = PrepReq(base_url=self._base_url)
156
+ response = await prep_req.request(
157
+ method=HTTPMethod.POST,
158
+ url=PetkitEndpoint.REFRESH_SESSION,
159
+ headers=await self._generate_header(),
160
+ )
161
+ session_data = response["session"]
162
+ self._session = SessionInfo(**session_data)
163
+
164
+ async def validate_session(self) -> None:
165
+ """Check if the session is still valid and refresh or re-login if necessary."""
166
+ if self._session is None:
167
+ await self.login()
168
+ return
169
+
170
+ created_at = datetime.strptime(
171
+ self._session.created_at,
172
+ "%Y-%m-%dT%H:%M:%S.%f%z",
173
+ )
174
+ current_time = datetime.now(tz=created_at.tzinfo)
175
+ token_age = current_time - created_at
176
+ max_age = timedelta(seconds=self._session.expires_in)
177
+ half_max_age = max_age / 2
178
+
179
+ if token_age > max_age:
180
+ _LOGGER.debug("Token expired, re-logging in")
181
+ await self.login()
182
+ elif half_max_age < token_age <= max_age:
183
+ _LOGGER.debug("Token still OK, but refreshing session")
184
+ await self.refresh_session()
185
+ return
186
+
187
+ async def _get_account_data(self) -> None:
188
+ """Get the account data from the PetKit service."""
189
+ await self.validate_session()
190
+ _LOGGER.debug("Fetching account data")
191
+ prep_req = PrepReq(base_url=self._base_url)
192
+ response = await prep_req.request(
193
+ method=HTTPMethod.GET,
194
+ url=PetkitEndpoint.FAMILY_LIST,
195
+ headers=await self._generate_header(),
196
+ )
197
+ self.account_data = [AccountData(**account) for account in response]
198
+
199
+ async def get_devices_data(self) -> None:
200
+ """Get the devices data from the PetKit servers."""
201
+ if not self.account_data:
202
+ await self._get_account_data()
203
+
204
+ device_list: list[Device] = []
205
+ for account in self.account_data:
206
+ _LOGGER.debug("Fetching devices data for account: %s", account)
207
+ if account.device_list:
208
+ device_list.extend(account.device_list)
209
+
210
+ _LOGGER.info("%s devices found for this account", len(device_list))
211
+ for device in device_list:
212
+ _LOGGER.debug("Fetching devices data: %s", device)
213
+ device_type = device.device_type.lower()
214
+ # TODO: Fetch device records
215
+ if device_type in DEVICES_FEEDER:
216
+ await self._fetch_device_data(device, Feeder)
217
+ elif device_type in DEVICES_LITTER_BOX:
218
+ await self._fetch_device_data(device, Litter)
219
+ elif device_type in DEVICES_WATER_FOUNTAIN:
220
+ await self._fetch_device_data(device, WaterFountain)
221
+ else:
222
+ _LOGGER.warning("Unknown device type: %s", device_type)
223
+
224
+ async def _fetch_device_data(
225
+ self,
226
+ device: Device,
227
+ data_class: type[Feeder | Litter | WaterFountain],
228
+ ) -> None:
229
+ """Fetch the device data from the PetKit servers."""
230
+ await self.validate_session()
231
+ endpoint = data_class.get_endpoint(device.device_type)
232
+ device_type = device.device_type.lower()
233
+ query_param = data_class.query_param(device.device_id)
234
+
235
+ prep_req = PrepReq(base_url=self._base_url)
236
+ response = await prep_req.request(
237
+ method=HTTPMethod.GET,
238
+ url=f"{device_type}/{endpoint}",
239
+ params=query_param,
240
+ headers=await self._generate_header(),
241
+ )
242
+ device_data = data_class(**response)
243
+ device_data.device_type = device.device_type # Add the device_type attribute
244
+ _LOGGER.info("Adding device type: %s", device.device_type)
245
+ self.device_list.append(device_data)
246
+
247
+ async def send_api_request(
248
+ self,
249
+ device: Feeder | Litter | WaterFountain,
250
+ action: StrEnum,
251
+ setting: dict | None = None,
252
+ ) -> None:
253
+ """Control the device using the PetKit API."""
254
+
255
+ _LOGGER.debug(
256
+ "Control API: %s %s %s",
257
+ action,
258
+ setting,
259
+ device,
260
+ )
261
+
262
+ if device.device_type:
263
+ device_type = device.device_type.lower()
264
+ else:
265
+ raise PypetkitError(
266
+ "Device type is not available, and is mandatory for sending commands."
267
+ )
268
+
269
+ if action not in ACTIONS_MAP:
270
+ raise PypetkitError(f"Action {action} not supported.")
271
+
272
+ action_info = ACTIONS_MAP[action]
273
+ if device_type not in action_info.supported_device:
274
+ raise PypetkitError(
275
+ f"Device type {device.device_type} not supported for action {action}."
276
+ )
277
+
278
+ if callable(action_info.endpoint):
279
+ endpoint = action_info.endpoint(device)
280
+ else:
281
+ endpoint = action_info.endpoint
282
+ url = f"{device.device_type.lower()}/{endpoint}"
283
+
284
+ headers = await self._generate_header()
285
+
286
+ # Use the lambda to generate params
287
+ if setting is not None:
288
+ params = action_info.params(device, setting)
289
+ else:
290
+ params = action_info.params(device)
291
+
292
+ prep_req = PrepReq(base_url=self._base_url)
293
+ await prep_req.request(
294
+ method=HTTPMethod.POST,
295
+ url=url,
296
+ data=params,
297
+ headers=headers,
298
+ )
299
+
300
+
301
+ class PrepReq:
302
+ """Prepare the request to the PetKit API."""
303
+
304
+ def __init__(self, base_url: str, base_headers: dict | None = None) -> None:
305
+ """Initialize the request."""
306
+ self.base_url = base_url
307
+ self.base_headers = base_headers or {}
308
+
309
+ async def request(
310
+ self,
311
+ method: str,
312
+ url: str,
313
+ params=None,
314
+ data=None,
315
+ headers=None,
316
+ ) -> dict:
317
+ """Make a request to the PetKit API."""
318
+ _url = "/".join(s.strip("/") for s in [self.base_url, url])
319
+ _headers = {**self.base_headers, **(headers or {})}
320
+ _LOGGER.debug(
321
+ "Request: %s %s Params: %s Data: %s Headers: %s",
322
+ method,
323
+ _url,
324
+ params,
325
+ data,
326
+ _headers,
327
+ )
328
+ async with aiohttp.ClientSession() as session:
329
+ try:
330
+ async with session.request(
331
+ method,
332
+ _url,
333
+ params=params,
334
+ data=data,
335
+ headers=_headers,
336
+ ) as resp:
337
+ response = await resp.json()
338
+ if ERR_KEY in response:
339
+ error_msg = response[ERR_KEY].get("msg", "Unknown error")
340
+ raise PypetkitError(f"Request failed: {error_msg}")
341
+ if RES_KEY in response:
342
+ _LOGGER.debug("Request response: %s", response)
343
+ return response[RES_KEY]
344
+ raise PypetkitError("Unexpected response format")
345
+ except ContentTypeError:
346
+ """If we get an error, lets log everything for debugging."""
347
+ try:
348
+ resp_json = await resp.json(content_type=None)
349
+ _LOGGER.info("Resp: %s", resp_json)
350
+ except ContentTypeError as err_2:
351
+ _LOGGER.info(err_2)
352
+ resp_raw = await resp.read()
353
+ _LOGGER.info("Resp raw: %s", resp_raw)
354
+ # Still raise the err so that it's clear it failed.
355
+ raise