pypetkitapi 0.5.4__py3-none-any.whl → 1.1.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/client.py CHANGED
@@ -12,18 +12,21 @@ from aiohttp import ContentTypeError
12
12
 
13
13
  from pypetkitapi.command import ACTIONS_MAP
14
14
  from pypetkitapi.const import (
15
+ DEVICE_DATA,
16
+ DEVICE_RECORDS,
15
17
  DEVICES_FEEDER,
16
18
  DEVICES_LITTER_BOX,
17
19
  DEVICES_WATER_FOUNTAIN,
18
20
  ERR_KEY,
19
21
  LOGIN_DATA,
22
+ PET_DATA,
20
23
  RES_KEY,
21
24
  SUCCESS_KEY,
22
25
  Header,
26
+ PetkitDomain,
23
27
  PetkitEndpoint,
24
- PetkitURL,
25
28
  )
26
- from pypetkitapi.containers import AccountData, Device, RegionInfo, SessionInfo
29
+ from pypetkitapi.containers import AccountData, Device, Pet, RegionInfo, SessionInfo
27
30
  from pypetkitapi.exceptions import (
28
31
  PetkitAuthenticationError,
29
32
  PetkitInvalidHTTPResponseCodeError,
@@ -32,9 +35,9 @@ from pypetkitapi.exceptions import (
32
35
  PetkitTimeoutError,
33
36
  PypetkitError,
34
37
  )
35
- from pypetkitapi.feeder_container import Feeder
36
- from pypetkitapi.litter_container import Litter
37
- from pypetkitapi.water_fountain_container import WaterFountain
38
+ from pypetkitapi.feeder_container import Feeder, FeederRecord
39
+ from pypetkitapi.litter_container import Litter, LitterRecord
40
+ from pypetkitapi.water_fountain_container import WaterFountain, WaterFountainRecord
38
41
 
39
42
  _LOGGER = logging.getLogger(__name__)
40
43
 
@@ -46,8 +49,11 @@ class PetKitClient:
46
49
  _session: SessionInfo | None = None
47
50
  _servers_list: list[RegionInfo] = []
48
51
  account_data: list[AccountData] = []
49
- # TODO : Adding pet as entity ?
50
- device_list: dict[int, Feeder | Litter | WaterFountain] = {}
52
+ petkit_entities: dict[
53
+ str,
54
+ dict[int, Feeder | Litter | WaterFountain | Pet]
55
+ | dict[int, FeederRecord | LitterRecord | WaterFountainRecord],
56
+ ]
51
57
 
52
58
  def __init__(
53
59
  self,
@@ -55,85 +61,50 @@ class PetKitClient:
55
61
  password: str,
56
62
  region: str,
57
63
  timezone: str,
64
+ session: aiohttp.ClientSession | None = None,
58
65
  ) -> None:
59
66
  """Initialize the PetKit Client."""
60
67
  self.username = username
61
68
  self.password = password
62
69
  self.region = region.lower()
63
70
  self.timezone = timezone
64
-
65
- async def _generate_header(self) -> dict[str, str]:
66
- """Create header for interaction with devices."""
67
- session_id = self._session.id if self._session is not None else ""
68
-
69
- return {
70
- "Accept": Header.ACCEPT.value,
71
- "Accept-Language": Header.ACCEPT_LANG,
72
- "Accept-Encoding": Header.ENCODING,
73
- "Content-Type": Header.CONTENT_TYPE,
74
- "User-Agent": Header.AGENT,
75
- "X-Img-Version": Header.IMG_VERSION,
76
- "X-Locale": Header.LOCALE,
77
- "F-Session": session_id,
78
- "X-Session": session_id,
79
- "X-Client": Header.CLIENT,
80
- "X-Hour": Header.HOUR,
81
- "X-TimezoneId": Header.TIMEZONE_ID,
82
- "X-Api-Version": Header.API_VERSION,
83
- "X-Timezone": Header.TIMEZONE,
84
- }
85
-
86
- async def _get_api_server_list(self) -> None:
87
- """Get the list of API servers and set the base URL."""
88
- _LOGGER.debug("Getting API server list")
89
- prep_req = PrepReq(base_url=PetkitURL.REGION_SRV)
90
- response = await prep_req.request(
91
- method=HTTPMethod.GET,
92
- url="",
93
- headers=await self._generate_header(),
71
+ self._session = None
72
+ self.petkit_entities = {DEVICE_RECORDS: {}, DEVICE_DATA: {}, PET_DATA: {}}
73
+ self.aiohttp_session = session or aiohttp.ClientSession()
74
+ self.req = PrepReq(
75
+ base_url=PetkitDomain.PASSPORT_PETKIT, session=self.aiohttp_session
94
76
  )
95
- _LOGGER.debug("API server list: %s", response)
96
- self._servers_list = [
97
- RegionInfo(**region) for region in response.get("list", [])
98
- ]
99
77
 
100
78
  async def _get_base_url(self) -> None:
101
- """Find the region server for the specified region."""
102
- await self._get_api_server_list()
103
- _LOGGER.debug("Finding region server for region: %s", self.region)
79
+ """Get the list of API servers, filter by region, and return the matching server."""
80
+ _LOGGER.debug("Getting API server list")
104
81
 
105
- # TODO : Improve this
106
- if self.region == "china":
107
- self._base_url = PetkitURL.CHINA_SRV
82
+ if self.region.lower() == "china":
83
+ self._base_url = PetkitDomain.CHINA_SRV
108
84
  return
109
85
 
110
- regional_server = next(
111
- (
112
- server
113
- for server in self._servers_list
114
- if server.name.lower() == self.region
115
- or server.id.lower() == self.region
116
- ),
117
- None,
86
+ response = await self.req.request(
87
+ method=HTTPMethod.GET,
88
+ url=PetkitEndpoint.REGION_SERVERS,
118
89
  )
90
+ _LOGGER.debug("API server list: %s", response)
119
91
 
120
- if regional_server:
121
- _LOGGER.debug(
122
- "Using server %s for region : %s", regional_server, self.region
123
- )
124
- self._base_url = regional_server.gateway
125
- return
92
+ # Filter the servers by region
93
+ for region in response.get("list", []):
94
+ server = RegionInfo(**region)
95
+ if server.name.lower() == self.region or server.id.lower() == self.region:
96
+ self.req.base_url = server.gateway
97
+ _LOGGER.debug("Found matching server: %s", server)
98
+ return
126
99
  raise PetkitRegionalServerNotFoundError(self.region)
127
100
 
128
101
  async def request_login_code(self) -> bool:
129
102
  """Request a login code to be sent to the user's email."""
130
103
  _LOGGER.debug("Requesting login code for username: %s", self.username)
131
- prep_req = PrepReq(base_url=self._base_url)
132
- response = await prep_req.request(
104
+ response = await self.req.request(
133
105
  method=HTTPMethod.GET,
134
106
  url=PetkitEndpoint.GET_LOGIN_CODE,
135
107
  params={"username": self.username},
136
- headers=await self._generate_header(),
137
108
  )
138
109
  if response:
139
110
  _LOGGER.info("Login code sent to user's email")
@@ -145,7 +116,7 @@ class PetKitClient:
145
116
  # Retrieve the list of servers
146
117
  await self._get_base_url()
147
118
 
148
- _LOGGER.debug("Logging in to PetKit server")
119
+ _LOGGER.info("Logging in to PetKit server")
149
120
 
150
121
  # Prepare the data to send
151
122
  data = LOGIN_DATA.copy()
@@ -162,12 +133,10 @@ class PetKitClient:
162
133
  data["password"] = pwd # noqa: S324
163
134
 
164
135
  # Send the login request
165
- prep_req = PrepReq(base_url=self._base_url)
166
- response = await prep_req.request(
136
+ response = await self.req.request(
167
137
  method=HTTPMethod.POST,
168
138
  url=PetkitEndpoint.LOGIN,
169
139
  data=data,
170
- headers=await self._generate_header(),
171
140
  )
172
141
  session_data = response["session"]
173
142
  self._session = SessionInfo(**session_data)
@@ -175,11 +144,9 @@ class PetKitClient:
175
144
  async def refresh_session(self) -> None:
176
145
  """Refresh the session."""
177
146
  _LOGGER.debug("Refreshing session")
178
- prep_req = PrepReq(base_url=self._base_url)
179
- response = await prep_req.request(
147
+ response = await self.req.request(
180
148
  method=HTTPMethod.POST,
181
149
  url=PetkitEndpoint.REFRESH_SESSION,
182
- headers=await self._generate_header(),
183
150
  )
184
151
  session_data = response["session"]
185
152
  self._session = SessionInfo(**session_data)
@@ -205,76 +172,137 @@ class PetKitClient:
205
172
  elif half_max_age < token_age <= max_age:
206
173
  _LOGGER.debug("Token still OK, but refreshing session")
207
174
  await self.refresh_session()
208
- return
175
+
176
+ async def get_session_id(self) -> dict:
177
+ """Return the session ID."""
178
+ if self._session is None:
179
+ raise PypetkitError("Session is not initialized.")
180
+ return {"F-Session": self._session.id, "X-Session": self._session.id}
209
181
 
210
182
  async def _get_account_data(self) -> None:
211
183
  """Get the account data from the PetKit service."""
212
184
  await self.validate_session()
213
185
  _LOGGER.debug("Fetching account data")
214
- prep_req = PrepReq(base_url=self._base_url)
215
- response = await prep_req.request(
186
+ response = await self.req.request(
216
187
  method=HTTPMethod.GET,
217
188
  url=PetkitEndpoint.FAMILY_LIST,
218
- headers=await self._generate_header(),
189
+ headers=await self.get_session_id(),
219
190
  )
220
191
  self.account_data = [AccountData(**account) for account in response]
221
192
 
193
+ # Add pets to device_list
194
+ for account in self.account_data:
195
+ if account.pet_list:
196
+ for pet in account.pet_list:
197
+ self.petkit_entities[PET_DATA][pet.pet_id] = pet
198
+
222
199
  async def get_devices_data(self) -> None:
223
200
  """Get the devices data from the PetKit servers."""
224
201
  start_time = datetime.now()
225
202
  if not self.account_data:
226
203
  await self._get_account_data()
227
204
 
205
+ tasks = []
228
206
  device_list: list[Device] = []
207
+
229
208
  for account in self.account_data:
230
209
  _LOGGER.debug("Fetching devices data for account: %s", account)
231
210
  if account.device_list:
232
211
  device_list.extend(account.device_list)
233
212
 
234
- _LOGGER.debug("Fetch %s devices for this account", len(device_list))
235
-
236
- tasks = []
237
- for device in device_list:
238
- _LOGGER.debug("Fetching devices data: %s", device)
239
- device_type = device.device_type.lower()
240
- if device_type in DEVICES_FEEDER:
241
- tasks.append(self._fetch_device_data(device, Feeder))
242
- elif device_type in DEVICES_LITTER_BOX:
243
- tasks.append(self._fetch_device_data(device, Litter))
244
- elif device_type in DEVICES_WATER_FOUNTAIN:
245
- tasks.append(self._fetch_device_data(device, WaterFountain))
246
- else:
247
- _LOGGER.warning("Unknown device type: %s", device_type)
213
+ _LOGGER.debug("Fetch %s devices for this account", len(device_list))
214
+
215
+ for device in device_list:
216
+ _LOGGER.debug("Fetching devices data: %s", device)
217
+ device_type = device.device_type.lower()
218
+ device_id = device.device_id
219
+ if device_type in DEVICES_FEEDER:
220
+ # Add tasks for feeders
221
+ tasks.append(self._fetch_device_data(account, device_id, Feeder))
222
+ tasks.append(
223
+ self._fetch_device_data(account, device_id, FeederRecord)
224
+ )
225
+ elif device_type in DEVICES_LITTER_BOX:
226
+ # Add tasks for litter boxes
227
+ tasks.append(self._fetch_device_data(account, device_id, Litter))
228
+ tasks.append(
229
+ self._fetch_device_data(account, device_id, LitterRecord)
230
+ )
231
+ elif device_type in DEVICES_WATER_FOUNTAIN:
232
+ # Add tasks for water fountains
233
+ tasks.append(
234
+ self._fetch_device_data(account, device_id, WaterFountain)
235
+ )
236
+ tasks.append(
237
+ self._fetch_device_data(account, device_id, WaterFountainRecord)
238
+ )
239
+ else:
240
+ _LOGGER.warning("Unknown device type: %s", device_type)
248
241
  await asyncio.gather(*tasks)
249
242
 
250
243
  end_time = datetime.now()
251
244
  total_time = end_time - start_time
252
- _LOGGER.debug("Petkit fetch took : %s", total_time)
245
+ _LOGGER.info("OK Petkit data fetched in : %s", total_time)
253
246
 
254
247
  async def _fetch_device_data(
255
248
  self,
256
- device: Device,
257
- data_class: type[Feeder | Litter | WaterFountain],
249
+ account: AccountData,
250
+ device_id: int,
251
+ data_class: type[
252
+ Feeder
253
+ | Litter
254
+ | WaterFountain
255
+ | FeederRecord
256
+ | LitterRecord
257
+ | WaterFountainRecord
258
+ ],
258
259
  ) -> None:
259
260
  """Fetch the device data from the PetKit servers."""
260
261
  await self.validate_session()
261
- endpoint = data_class.get_endpoint(device.device_type)
262
+ device = None
263
+
264
+ if account.device_list:
265
+ device = next(
266
+ (
267
+ device
268
+ for device in account.device_list
269
+ if device.device_id == device_id
270
+ ),
271
+ None,
272
+ )
273
+ if device is None:
274
+ _LOGGER.error("Device not found: id=%s", device_id)
275
+ return
262
276
  device_type = device.device_type.lower()
263
- query_param = data_class.query_param(device.device_id)
264
277
 
265
- prep_req = PrepReq(base_url=self._base_url)
266
- response = await prep_req.request(
267
- method=HTTPMethod.GET,
278
+ endpoint = data_class.get_endpoint(device_type)
279
+ query_param = data_class.query_param(account, device.device_id)
280
+
281
+ response = await self.req.request(
282
+ method=HTTPMethod.POST,
268
283
  url=f"{device_type}/{endpoint}",
269
284
  params=query_param,
270
- headers=await self._generate_header(),
285
+ headers=await self.get_session_id(),
271
286
  )
272
- device_data = data_class(**response)
273
- device_data.device_type = device.device_type # Add the device_type attribute
274
- _LOGGER.debug(
275
- "Reading device type : %s (id=%s)", device.device_type, device.device_id
276
- )
277
- self.device_list[device.device_id] = device_data
287
+
288
+ # Check if the response is a list or a dict
289
+ if isinstance(response, list):
290
+ device_data = [data_class(**item) for item in response]
291
+ elif isinstance(response, dict):
292
+ device_data = data_class(**response)
293
+ else:
294
+ _LOGGER.error("Unexpected response type: %s", type(response))
295
+ return
296
+
297
+ if isinstance(device_data, list):
298
+ for item in device_data:
299
+ item.device_type = device_type
300
+ else:
301
+ device_data.device_type = device_type
302
+
303
+ _LOGGER.debug("Reading device type : %s (id=%s)", device_type, device_id)
304
+
305
+ self.petkit_entities[data_class.data_type][device_id] = device_data
278
306
 
279
307
  async def send_api_request(
280
308
  self,
@@ -283,7 +311,8 @@ class PetKitClient:
283
311
  setting: dict | None = None,
284
312
  ) -> None:
285
313
  """Control the device using the PetKit API."""
286
- device = self.device_list.get(device_id)
314
+ device_dict = self.petkit_entities.get(DEVICE_DATA, {})
315
+ device = device_dict.get(device_id)
287
316
  if not device:
288
317
  raise PypetkitError(f"Device with ID {device_id} not found.")
289
318
 
@@ -295,42 +324,44 @@ class PetKitClient:
295
324
  setting,
296
325
  )
297
326
 
327
+ # Check if the device type is supported
298
328
  if device.device_type:
299
329
  device_type = device.device_type.lower()
300
330
  else:
301
331
  raise PypetkitError(
302
332
  "Device type is not available, and is mandatory for sending commands."
303
333
  )
304
-
334
+ # Check if the action is supported
305
335
  if action not in ACTIONS_MAP:
306
336
  raise PypetkitError(f"Action {action} not supported.")
307
337
 
308
338
  action_info = ACTIONS_MAP[action]
339
+ _LOGGER.debug(action)
340
+ _LOGGER.debug(action_info)
309
341
  if device_type not in action_info.supported_device:
310
342
  raise PypetkitError(
311
343
  f"Device type {device.device_type} not supported for action {action}."
312
344
  )
313
-
345
+ # Get the endpoint
314
346
  if callable(action_info.endpoint):
315
347
  endpoint = action_info.endpoint(device)
348
+ _LOGGER.debug("Endpoint from callable")
316
349
  else:
317
350
  endpoint = action_info.endpoint
351
+ _LOGGER.debug("Endpoint field")
318
352
  url = f"{device.device_type.lower()}/{endpoint}"
319
353
 
320
- headers = await self._generate_header()
321
-
322
- # Use the lambda to generate params
354
+ # Get the parameters
323
355
  if setting is not None:
324
356
  params = action_info.params(device, setting)
325
357
  else:
326
358
  params = action_info.params(device)
327
359
 
328
- prep_req = PrepReq(base_url=self._base_url)
329
- res = await prep_req.request(
360
+ res = await self.req.request(
330
361
  method=HTTPMethod.POST,
331
362
  url=url,
332
363
  data=params,
333
- headers=headers,
364
+ headers=await self.get_session_id(),
334
365
  )
335
366
  if res in (SUCCESS_KEY, RES_KEY):
336
367
  # TODO : Manage to get the response from manual feeding
@@ -338,14 +369,39 @@ class PetKitClient:
338
369
  else:
339
370
  _LOGGER.error("Command execution failed")
340
371
 
372
+ async def close(self) -> None:
373
+ """Close the aiohttp session if it was created by the client."""
374
+ if self.aiohttp_session:
375
+ await self.aiohttp_session.close()
376
+
341
377
 
342
378
  class PrepReq:
343
379
  """Prepare the request to the PetKit API."""
344
380
 
345
- def __init__(self, base_url: str, base_headers: dict | None = None) -> None:
381
+ def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None:
346
382
  """Initialize the request."""
347
383
  self.base_url = base_url
348
- self.base_headers = base_headers or {}
384
+ self.session = session
385
+ self.base_headers = self._generate_header()
386
+
387
+ @staticmethod
388
+ def _generate_header() -> dict[str, str]:
389
+ """Create header for interaction with API endpoint."""
390
+
391
+ return {
392
+ "Accept": Header.ACCEPT.value,
393
+ "Accept-Language": Header.ACCEPT_LANG,
394
+ "Accept-Encoding": Header.ENCODING,
395
+ "Content-Type": Header.CONTENT_TYPE,
396
+ "User-Agent": Header.AGENT,
397
+ "X-Img-Version": Header.IMG_VERSION,
398
+ "X-Locale": Header.LOCALE,
399
+ "X-Client": Header.CLIENT,
400
+ "X-Hour": Header.HOUR,
401
+ "X-TimezoneId": Header.TIMEZONE_ID,
402
+ "X-Api-Version": Header.API_VERSION,
403
+ "X-Timezone": Header.TIMEZONE,
404
+ }
349
405
 
350
406
  async def request(
351
407
  self,
@@ -366,34 +422,21 @@ class PrepReq:
366
422
  data,
367
423
  _headers,
368
424
  )
369
- async with aiohttp.ClientSession() as session:
370
- try:
371
- async with session.request(
372
- method,
373
- _url,
374
- params=params,
375
- data=data,
376
- headers=_headers,
377
- ) as resp:
378
- return await self._handle_response(resp, _url)
379
- except ContentTypeError:
380
- """If we get an error, lets log everything for debugging."""
381
- try:
382
- resp_json = await resp.json(content_type=None)
383
- _LOGGER.info("Resp: %s", resp_json)
384
- except ContentTypeError as err_2:
385
- _LOGGER.info(err_2)
386
- resp_raw = await resp.read()
387
- _LOGGER.info("Resp raw: %s", resp_raw)
388
- # Still raise the err so that it's clear it failed.
389
- raise
390
- except TimeoutError:
391
- raise PetkitTimeoutError("The request timed out") from None
425
+ try:
426
+ async with self.session.request(
427
+ method,
428
+ _url,
429
+ params=params,
430
+ data=data,
431
+ headers=_headers,
432
+ ) as resp:
433
+ return await self._handle_response(resp, _url)
434
+ except aiohttp.ClientConnectorError as e:
435
+ raise PetkitTimeoutError(f"Cannot connect to host: {e}") from e
392
436
 
393
437
  @staticmethod
394
438
  async def _handle_response(response: aiohttp.ClientResponse, url: str) -> dict:
395
439
  """Handle the response from the PetKit API."""
396
-
397
440
  try:
398
441
  response.raise_for_status()
399
442
  except aiohttp.ClientResponseError as e:
@@ -408,6 +451,7 @@ class PrepReq:
408
451
  "Response is not in JSON format"
409
452
  ) from None
410
453
 
454
+ # Check for errors in the response
411
455
  if ERR_KEY in response_json:
412
456
  error_msg = response_json[ERR_KEY].get("msg", "Unknown error")
413
457
  if any(
@@ -421,6 +465,7 @@ class PrepReq:
421
465
  raise PetkitAuthenticationError(f"Login failed: {error_msg}")
422
466
  raise PypetkitError(f"Request failed: {error_msg}")
423
467
 
468
+ # Check for success in the response
424
469
  if RES_KEY in response_json:
425
470
  return response_json[RES_KEY]
426
471
 
pypetkitapi/command.py CHANGED
@@ -53,7 +53,7 @@ class LitterCommand(StrEnum):
53
53
  class PetCommand(StrEnum):
54
54
  """PetCommand"""
55
55
 
56
- UPDATE_SETTING = "update_setting"
56
+ PET_UPDATE_SETTING = "pet_update_setting"
57
57
 
58
58
 
59
59
  class FountainCommand(StrEnum):
@@ -235,15 +235,6 @@ ACTIONS_MAP = {
235
235
  },
236
236
  supported_device=[D3],
237
237
  ),
238
- LitterCommand.POWER: CmdData(
239
- endpoint=PetkitEndpoint.CONTROL_DEVICE,
240
- params=lambda device, setting: {
241
- "id": device.id,
242
- "kv": json.dumps(setting),
243
- "type": "power",
244
- },
245
- supported_device=[T3, T4, T5, T6],
246
- ),
247
238
  LitterCommand.CONTROL_DEVICE: CmdData(
248
239
  endpoint=PetkitEndpoint.CONTROL_DEVICE,
249
240
  params=lambda device, command: {
@@ -253,7 +244,7 @@ ACTIONS_MAP = {
253
244
  },
254
245
  supported_device=[T3, T4, T5, T6],
255
246
  ),
256
- PetCommand.UPDATE_SETTING: CmdData(
247
+ PetCommand.PET_UPDATE_SETTING: CmdData(
257
248
  endpoint=PetkitEndpoint.CONTROL_DEVICE,
258
249
  params=lambda pet, setting: {
259
250
  "petId": pet,
pypetkitapi/const.py CHANGED
@@ -9,6 +9,10 @@ RES_KEY = "result"
9
9
  ERR_KEY = "error"
10
10
  SUCCESS_KEY = "success"
11
11
 
12
+ DEVICE_RECORDS = "deviceRecords"
13
+ DEVICE_DATA = "deviceData"
14
+ PET_DATA = "petData"
15
+
12
16
  # PetKit Models
13
17
  FEEDER = "feeder"
14
18
  FEEDER_MINI = "feedermini"
@@ -31,10 +35,10 @@ DEVICES_WATER_FOUNTAIN = [W5, CTW3]
31
35
  ALL_DEVICES = [*DEVICES_LITTER_BOX, *DEVICES_FEEDER, *DEVICES_WATER_FOUNTAIN]
32
36
 
33
37
 
34
- class PetkitURL(StrEnum):
38
+ class PetkitDomain(StrEnum):
35
39
  """Petkit URL constants"""
36
40
 
37
- REGION_SRV = "https://passport.petkt.com/v1/regionservers"
41
+ PASSPORT_PETKIT = "https://passport.petkt.com/"
38
42
  CHINA_SRV = "https://api.petkit.cn/6/"
39
43
 
40
44
 
@@ -58,7 +62,7 @@ class Header(StrEnum):
58
62
  AGENT = "okhttp/3.12.11"
59
63
  CLIENT = f"{Client.PLATFORM_TYPE}({Client.OS_VERSION};{Client.MODEL_NAME})"
60
64
  TIMEZONE = "1.0"
61
- TIMEZONE_ID = "Europe/Paris" # TODO: Make this dynamic, check if this really matters (record hours?)
65
+ TIMEZONE_ID = "Europe/Paris" # TODO: Make this dynamic
62
66
  LOCALE = "en-US"
63
67
  IMG_VERSION = "1.0"
64
68
  HOUR = "24"
@@ -71,7 +75,7 @@ CLIENT_NFO = {
71
75
  "platform": Client.PLATFORM_TYPE.value,
72
76
  "source": Client.SOURCE.value,
73
77
  "timezone": Header.TIMEZONE.value, # TODO: Make this dynamic
74
- "timezoneId": Header.TIMEZONE_ID.value, # TODO: Make this dynamic
78
+ "timezoneId": Header.TIMEZONE_ID.value,
75
79
  "version": Header.API_VERSION.value,
76
80
  }
77
81
 
@@ -84,11 +88,14 @@ LOGIN_DATA = {
84
88
  class PetkitEndpoint(StrEnum):
85
89
  """Petkit Endpoint constants"""
86
90
 
91
+ REGION_SERVERS = "v1/regionservers"
87
92
  LOGIN = "user/login"
88
93
  GET_LOGIN_CODE = "user/sendcodeforquicklogin"
89
94
  REFRESH_SESSION = "user/refreshsession"
90
95
  FAMILY_LIST = "group/family/list"
91
96
  REFRESH_HOME_V2 = "refreshHomeV2"
97
+
98
+ # Common to many device
92
99
  DEVICE_DETAIL = "device_detail"
93
100
  DEVICE_DATA = "deviceData"
94
101
  GET_DEVICE_RECORD = "getDeviceRecord"
@@ -103,6 +110,7 @@ class PetkitEndpoint(StrEnum):
103
110
 
104
111
  # Fountain & Litter Box
105
112
  CONTROL_DEVICE = "controlDevice"
113
+ GET_WORK_RECORD = "getWorkRecord"
106
114
 
107
115
  # Litter Box
108
116
  DEODORANT_RESET = "deodorantReset"
pypetkitapi/containers.py CHANGED
@@ -66,9 +66,10 @@ class Pet(BaseModel):
66
66
  """
67
67
 
68
68
  avatar: str | None = None
69
- created_at: int | None = Field(None, alias="createdAt")
70
- pet_id: int | None = Field(None, alias="petId")
69
+ created_at: int = Field(alias="createdAt")
70
+ pet_id: int = Field(alias="petId")
71
71
  pet_name: str | None = Field(None, alias="petName")
72
+ device_type: str = "pet"
72
73
 
73
74
 
74
75
  class User(BaseModel):
@@ -1,12 +1,12 @@
1
1
  """Dataclasses for feeder data."""
2
2
 
3
- from collections.abc import Callable
3
+ from datetime import datetime
4
4
  from typing import Any, ClassVar
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from pypetkitapi.const import PetkitEndpoint
9
- from pypetkitapi.containers import CloudProduct, FirmwareDetail, Wifi
8
+ from pypetkitapi.const import DEVICE_DATA, DEVICE_RECORDS, PetkitEndpoint
9
+ from pypetkitapi.containers import AccountData, CloudProduct, FirmwareDetail, Wifi
10
10
 
11
11
 
12
12
  class FeedItem(BaseModel):
@@ -170,8 +170,7 @@ class ManualFeed(BaseModel):
170
170
  class Feeder(BaseModel):
171
171
  """Dataclass for feeder data."""
172
172
 
173
- url_endpoint: ClassVar[PetkitEndpoint] = PetkitEndpoint.DEVICE_DETAIL
174
- query_param: ClassVar[Callable] = lambda device_id: {"id": device_id}
173
+ data_type: ClassVar[str] = DEVICE_DATA
175
174
 
176
175
  auto_upgrade: int | None = Field(None, alias="autoUpgrade")
177
176
  bt_mac: str | None = Field(None, alias="btMac")
@@ -202,7 +201,12 @@ class Feeder(BaseModel):
202
201
  @classmethod
203
202
  def get_endpoint(cls, device_type: str) -> str:
204
203
  """Get the endpoint URL for the given device type."""
205
- return cls.url_endpoint.value
204
+ return PetkitEndpoint.DEVICE_DETAIL
205
+
206
+ @classmethod
207
+ def query_param(cls, account: AccountData, device_id: int) -> dict:
208
+ """Generate query parameters including request_date."""
209
+ return {"id": device_id}
206
210
 
207
211
 
208
212
  class EventState(BaseModel):
@@ -217,8 +221,8 @@ class EventState(BaseModel):
217
221
  surplus_standard: int | None = Field(None, alias="surplusStandard")
218
222
 
219
223
 
220
- class FeederRecord(BaseModel):
221
- """Dataclass for feeder record data."""
224
+ class RecordsItems(BaseModel):
225
+ """Dataclass for records items data."""
222
226
 
223
227
  aes_key: str | None = Field(None, alias="aesKey")
224
228
  duration: int | None = None
@@ -259,3 +263,40 @@ class FeederRecord(BaseModel):
259
263
  src: int | None = None
260
264
  state: EventState | None = None
261
265
  status: int | None = None
266
+
267
+
268
+ class RecordsType(BaseModel):
269
+ """Dataclass for records type data."""
270
+
271
+ add_amount1: int | None = Field(None, alias="addAmount1")
272
+ add_amount2: int | None = Field(None, alias="addAmount2")
273
+ day: int | None = None
274
+ device_id: int | None = Field(None, alias="deviceId")
275
+ eat_count: int | None = Field(None, alias="eatCount")
276
+ items: list[RecordsItems] | None = None
277
+
278
+
279
+ class FeederRecord(BaseModel):
280
+ """Dataclass for feeder record data."""
281
+
282
+ data_type: ClassVar[str] = DEVICE_RECORDS
283
+
284
+ eat: list[RecordsType] | None = None
285
+ feed: list[RecordsType] | None = None
286
+ move: list[RecordsType] | None = None
287
+ pet: list[RecordsType] | None = None
288
+ device_type: str | None = Field(None, alias="deviceType")
289
+
290
+ @classmethod
291
+ def get_endpoint(cls, device_type: str) -> str:
292
+ """Get the endpoint URL for the given device type."""
293
+ return PetkitEndpoint.GET_DEVICE_RECORD
294
+
295
+ @classmethod
296
+ def query_param(
297
+ cls, account: AccountData, device_id: int, request_date: str | None = None
298
+ ) -> dict:
299
+ """Generate query parameters including request_date."""
300
+ if request_date is None:
301
+ request_date = datetime.now().strftime("%Y%m%d")
302
+ return {"days": int(request_date), "deviceId": device_id}
@@ -1,12 +1,12 @@
1
1
  """Dataclasses for Litter."""
2
2
 
3
- from collections.abc import Callable
3
+ from datetime import datetime
4
4
  from typing import Any, ClassVar
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from pypetkitapi.const import PetkitEndpoint
9
- from pypetkitapi.containers import CloudProduct, FirmwareDetail, Wifi
8
+ from pypetkitapi.const import DEVICE_DATA, DEVICE_RECORDS, PetkitEndpoint
9
+ from pypetkitapi.containers import AccountData, CloudProduct, FirmwareDetail, Wifi
10
10
 
11
11
 
12
12
  class SettingsLitter(BaseModel):
@@ -147,8 +147,7 @@ class Litter(BaseModel):
147
147
  Supported devices = T4, T6
148
148
  """
149
149
 
150
- url_endpoint: ClassVar[PetkitEndpoint] = PetkitEndpoint.DEVICE_DETAIL
151
- query_param: ClassVar[Callable] = lambda device_id: {"id": device_id}
150
+ data_type: ClassVar[str] = DEVICE_DATA
152
151
 
153
152
  auto_upgrade: int | None = Field(None, alias="autoUpgrade")
154
153
  bt_mac: str | None = Field(None, alias="btMac")
@@ -187,4 +186,64 @@ class Litter(BaseModel):
187
186
  @classmethod
188
187
  def get_endpoint(cls, device_type: str) -> str:
189
188
  """Get the endpoint URL for the given device type."""
190
- return cls.url_endpoint.value
189
+ return PetkitEndpoint.DEVICE_DETAIL
190
+
191
+ @classmethod
192
+ def query_param(cls, account: AccountData, device_id: int) -> dict:
193
+ """Generate query parameters including request_date."""
194
+ return {"id": device_id}
195
+
196
+
197
+ class Content(BaseModel):
198
+ """Dataclass for content data."""
199
+
200
+ box: int | None = None
201
+ box_full: bool | None = Field(None, alias="boxFull")
202
+ litter_percent: int | None = Field(None, alias="litterPercent")
203
+ result: int | None = None
204
+ start_reason: int | None = Field(None, alias="startReason")
205
+ start_time: int | None = Field(None, alias="startTime")
206
+
207
+
208
+ class SubContent(BaseModel):
209
+ """Dataclass for sub-content data."""
210
+
211
+ content: Content | None = None
212
+ device_id: int | None = Field(None, alias="deviceId")
213
+ enum_event_type: str | None = Field(None, alias="enumEventType")
214
+ event_type: int | None = Field(None, alias="eventType")
215
+ sub_content: list[Any] | None = Field(None, alias="subContent")
216
+ timestamp: int | None = None
217
+ user_id: str | None = Field(None, alias="userId")
218
+
219
+
220
+ class LitterRecord(BaseModel):
221
+ """Dataclass for feeder record data."""
222
+
223
+ data_type: ClassVar[str] = DEVICE_RECORDS
224
+
225
+ avatar: str | None = None
226
+ content: dict[str, Any] | None = None
227
+ device_id: int | None = Field(None, alias="deviceId")
228
+ enum_event_type: str | None = Field(None, alias="enumEventType")
229
+ event_type: int | None = Field(None, alias="eventType")
230
+ pet_id: str | None = Field(None, alias="petId")
231
+ pet_name: str | None = Field(None, alias="petName")
232
+ sub_content: list[SubContent] | None = Field(None, alias="subContent")
233
+ timestamp: int | None = None
234
+ user_id: str | None = Field(None, alias="userId")
235
+ device_type: str | None = Field(None, alias="deviceType")
236
+
237
+ @classmethod
238
+ def get_endpoint(cls, device_type: str) -> str:
239
+ """Get the endpoint URL for the given device type."""
240
+ return PetkitEndpoint.GET_DEVICE_RECORD
241
+
242
+ @classmethod
243
+ def query_param(
244
+ cls, account: AccountData, device_id: int, request_date: str | None = None
245
+ ) -> dict:
246
+ """Generate query parameters including request_date."""
247
+ if request_date is None:
248
+ request_date = datetime.now().strftime("%Y%m%d")
249
+ return {"date": int(request_date), "deviceId": device_id}
@@ -1,11 +1,12 @@
1
1
  """Dataclasses for Water Fountain."""
2
2
 
3
- from collections.abc import Callable
3
+ from datetime import datetime
4
4
  from typing import Any, ClassVar
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from pypetkitapi.const import PetkitEndpoint
8
+ from pypetkitapi.const import DEVICE_DATA, DEVICE_RECORDS, PetkitEndpoint
9
+ from pypetkitapi.containers import AccountData
9
10
 
10
11
 
11
12
  class Electricity(BaseModel):
@@ -89,8 +90,7 @@ class WaterFountain(BaseModel):
89
90
  Supported devices = CTW3
90
91
  """
91
92
 
92
- url_endpoint: ClassVar[PetkitEndpoint] = PetkitEndpoint.DEVICE_DATA
93
- query_param: ClassVar[Callable] = lambda device_id: {"id": device_id}
93
+ data_type: ClassVar[str] = DEVICE_DATA
94
94
 
95
95
  breakdown_warning: int | None = Field(None, alias="breakdownWarning")
96
96
  created_at: str | None = Field(None, alias="createdAt")
@@ -132,4 +132,41 @@ class WaterFountain(BaseModel):
132
132
  @classmethod
133
133
  def get_endpoint(cls, device_type: str) -> str:
134
134
  """Get the endpoint URL for the given device type."""
135
- return cls.url_endpoint.value
135
+ return PetkitEndpoint.DEVICE_DATA
136
+
137
+ @classmethod
138
+ def query_param(cls, account: AccountData, device_id: int) -> dict:
139
+ """Generate query parameters including request_date."""
140
+ return {"id": device_id}
141
+
142
+
143
+ class WaterFountainRecord(BaseModel):
144
+ """Dataclass for feeder record data."""
145
+
146
+ data_type: ClassVar[str] = DEVICE_RECORDS
147
+
148
+ day_time: int | None = Field(None, alias="dayTime")
149
+ stay_time: int | None = Field(None, alias="stayTime")
150
+ work_time: int | None = Field(None, alias="workTime")
151
+ device_type: str | None = Field(None, alias="deviceType")
152
+
153
+ @classmethod
154
+ def get_endpoint(cls, device_type: str) -> str:
155
+ """Get the endpoint URL for the given device type."""
156
+ return PetkitEndpoint.GET_WORK_RECORD
157
+
158
+ @classmethod
159
+ def query_param(
160
+ cls, account: AccountData, device_id: int, request_date: str | None = None
161
+ ) -> dict:
162
+ """Generate query parameters including request_date."""
163
+ if not account.user_list or not account.user_list[0]:
164
+ raise ValueError("The account does not have a valid user_list.")
165
+
166
+ if request_date is None:
167
+ request_date = datetime.now().strftime("%Y%m%d")
168
+ return {
169
+ "day": int(request_date),
170
+ "deviceId": device_id,
171
+ "userId": account.user_list[0].user_id,
172
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pypetkitapi
3
- Version: 0.5.4
3
+ Version: 1.1.0
4
4
  Summary: Python client for PetKit API
5
5
  Home-page: https://github.com/Jezza34000/pypetkit
6
6
  License: MIT
@@ -20,19 +20,23 @@ Description-Content-Type: text/markdown
20
20
  ---
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/pypetkitapi.svg)][pypi_]
23
- [![Python Version](https://img.shields.io/pypi/pyversions/pypetkitapi)][python version]
23
+ [![Python Version](https://img.shields.io/pypi/pyversions/pypetkitapi)][python version] [![Actions status](https://github.com/Jezza34000/py-petkit-api/workflows/CI/badge.svg)](https://github.com/Jezza34000/py-petkit-api/actions)
24
+
25
+ ---
26
+
27
+ [![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) [![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)
28
+
29
+ [![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)
30
+ [![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)
31
+ [![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)
24
32
 
25
33
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit]
26
34
  [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black]
27
35
  [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy.readthedocs.io/en/stable/)
28
36
  [![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)
29
- [![Actions status](https://github.com/Jezza34000/py-petkit-api/workflows/CI/badge.svg)](https://github.com/Jezza34000/py-petkit-api/actions)
30
37
 
31
38
  ---
32
39
 
33
- [![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)
34
- [![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)
35
-
36
40
  [pypi_]: https://pypi.org/project/pypetkitapi/
37
41
  [python version]: https://pypi.org/project/pypetkitapi
38
42
  [pre-commit]: https://github.com/pre-commit/pre-commit
@@ -59,38 +63,42 @@ pip install pypetkitapi
59
63
  ## Usage Example:
60
64
 
61
65
  ```python
62
- import asyncio
63
- import logging
66
+ import aiohttp
64
67
  from pypetkitapi.client import PetKitClient
68
+ from pypetkitapi.command import DeviceCommand, FeederCommand, LBCommand, LBAction, LitterCommand
65
69
 
66
70
  logging.basicConfig(level=logging.DEBUG)
67
71
 
68
-
69
72
  async def main():
70
- client = PetKitClient(
71
- username="username", # Your PetKit account username or id
72
- password="password", # Your PetKit account password
73
- region="France", # Your region or country code (e.g. FR, US, etc.)
74
- timezone="Europe/Paris", # Your timezone
75
- )
76
-
77
- # To get the account and devices data attached to the account
78
- await client.get_devices_data()
79
-
80
- # Read the account data
81
- print(client.account_data)
82
-
83
- # Read the devices data
84
- print(client.device_list)
85
-
86
- # Send command to the devices
87
- ### Example 1 : Turn on the indicator light
88
- ### Device_ID, Command, Payload
89
- await client.send_api_request(012346789, DeviceCommand.UPDATE_SETTING, {"lightMode": 1})
90
-
91
- ### Example 2 : Feed the pet
92
- ### Device_ID, Command, Payload
93
- await client.send_api_request(0123467, FeederCommand.MANUAL_FEED, {"amount": 1})
73
+ async with aiohttp.ClientSession() as session:
74
+ client = PetKitClient(
75
+ username="username", # Your PetKit account username or id
76
+ password="password", # Your PetKit account password
77
+ region="FR", # Your region or country code (e.g. FR, US, etc.)
78
+ timezone="Europe/Paris", # Your timezone
79
+ session=session,
80
+ )
81
+
82
+ await client.get_devices_data()
83
+
84
+ # Read the account data
85
+ print(client.account_data)
86
+
87
+ # Read the devices data
88
+ print(client.petkit_entities)
89
+
90
+ # Send command to the devices
91
+ ### Example 1 : Turn on the indicator light
92
+ ### Device_ID, Command, Payload
93
+ await client.send_api_request(123456789, DeviceCommand.UPDATE_SETTING, {"lightMode": 1})
94
+
95
+ ### Example 2 : Feed the pet
96
+ ### Device_ID, Command, Payload
97
+ await client.send_api_request(123456789, FeederCommand.MANUAL_FEED, {"amount": 1})
98
+
99
+ ### Example 3 : Start the cleaning process
100
+ ### Device_ID, Command, Payload
101
+ await client.send_api_request(123456789, LitterCommand.CONTROL_DEVICE, {LBAction.START: LBCommand.CLEANING})
94
102
 
95
103
 
96
104
  if __name__ == "__main__":
@@ -0,0 +1,14 @@
1
+ pypetkitapi/__init__.py,sha256=eVpyGMD3tkYtiHUkdKEeNSZhQlZ4woI2Y5oVoV7CwXM,61
2
+ pypetkitapi/client.py,sha256=lC5kWLDcZcB5WHZeoyg0RagEKzK6F0Vk_H8Be_1MyDM,16493
3
+ pypetkitapi/command.py,sha256=gw3_J_oZHuuGLk66P8uRSqSrySjYa8ArpKaPHi2ybCw,7155
4
+ pypetkitapi/const.py,sha256=Mxgezd9sy-LtpgjYuVKb9ii9HSUnz8FVyKx1GXbXBAU,3291
5
+ pypetkitapi/containers.py,sha256=m7vzWcJG0U1EPftuBF6OB8eTVRhCoA2DFqekxI6LozI,3428
6
+ pypetkitapi/exceptions.py,sha256=NWmpsI2ewC4HaIeu_uFwCeuPIHIJxZBzjoCP7aNwvhs,1139
7
+ pypetkitapi/feeder_container.py,sha256=XpZqeQ8E4_ud3juxDBKGTMfmCfvc9QYcJInacmbWK-o,12785
8
+ pypetkitapi/litter_container.py,sha256=TK2-_yg3EmT0RQzMLurud2PHwa2P-o2KGyV9m_SQ7qw,11651
9
+ pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pypetkitapi/water_fountain_container.py,sha256=xmqYuxyppRLhqi7cF4JNRprhgmz3KKrbi4iv942wNaU,6566
11
+ pypetkitapi-1.1.0.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
12
+ pypetkitapi-1.1.0.dist-info/METADATA,sha256=9OvaTvc__catDl-YG3HWqk9ycquCVymNfAS6TrEQtBk,4590
13
+ pypetkitapi-1.1.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
14
+ pypetkitapi-1.1.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- pypetkitapi/__init__.py,sha256=eVpyGMD3tkYtiHUkdKEeNSZhQlZ4woI2Y5oVoV7CwXM,61
2
- pypetkitapi/client.py,sha256=yj-jf6H2lWsKhRpqo2s3mbfTfXUj9oCCqzcwMhVjAx4,14936
3
- pypetkitapi/command.py,sha256=dlLOPQsDcO2X0tO4fJlJgQ7QVcNRvsBswc1WwMjxYxE,7424
4
- pypetkitapi/const.py,sha256=B4uf3RGEkKmpVOo69DRP9g8hF_bodCA7vohnPjnrPFs,3183
5
- pypetkitapi/containers.py,sha256=4O79O9HZn4kksQysXGXcqVwvkdxbvhs1pr-0WRNCANc,3425
6
- pypetkitapi/exceptions.py,sha256=NWmpsI2ewC4HaIeu_uFwCeuPIHIJxZBzjoCP7aNwvhs,1139
7
- pypetkitapi/feeder_container.py,sha256=6a1Y_mTSGuD8qK1YqmiCfxPbZl84bcoIIP_d5g-UctQ,11381
8
- pypetkitapi/litter_container.py,sha256=baeIihndFfU5F3kxtyO8Dm1qx1mPuMAUIYRH60CV3GM,9447
9
- pypetkitapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pypetkitapi/water_fountain_container.py,sha256=LcCTDjk7eSbnF7e38xev3D5mCv5wwJ6go8WGGBv-CaU,5278
11
- pypetkitapi-0.5.4.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
12
- pypetkitapi-0.5.4.dist-info/METADATA,sha256=0rULLocj_iIqYQhyXrZoudOQihTXgxqYKwdvgbvxM4w,3611
13
- pypetkitapi-0.5.4.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
14
- pypetkitapi-0.5.4.dist-info/RECORD,,