pypetkitapi 0.5.4__py3-none-any.whl → 1.0.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,6 +12,8 @@ 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,
@@ -20,10 +22,10 @@ from pypetkitapi.const import (
20
22
  RES_KEY,
21
23
  SUCCESS_KEY,
22
24
  Header,
25
+ PetkitDomain,
23
26
  PetkitEndpoint,
24
- PetkitURL,
25
27
  )
26
- from pypetkitapi.containers import AccountData, Device, RegionInfo, SessionInfo
28
+ from pypetkitapi.containers import AccountData, Device, Pet, RegionInfo, SessionInfo
27
29
  from pypetkitapi.exceptions import (
28
30
  PetkitAuthenticationError,
29
31
  PetkitInvalidHTTPResponseCodeError,
@@ -32,9 +34,9 @@ from pypetkitapi.exceptions import (
32
34
  PetkitTimeoutError,
33
35
  PypetkitError,
34
36
  )
35
- from pypetkitapi.feeder_container import Feeder
36
- from pypetkitapi.litter_container import Litter
37
- from pypetkitapi.water_fountain_container import WaterFountain
37
+ from pypetkitapi.feeder_container import Feeder, FeederRecord
38
+ from pypetkitapi.litter_container import Litter, LitterRecord
39
+ from pypetkitapi.water_fountain_container import WaterFountain, WaterFountainRecord
38
40
 
39
41
  _LOGGER = logging.getLogger(__name__)
40
42
 
@@ -46,8 +48,10 @@ class PetKitClient:
46
48
  _session: SessionInfo | None = None
47
49
  _servers_list: list[RegionInfo] = []
48
50
  account_data: list[AccountData] = []
49
- # TODO : Adding pet as entity ?
50
- device_list: dict[int, Feeder | Litter | WaterFountain] = {}
51
+ petkit_entities: dict[int, Feeder | Litter | WaterFountain | Pet] = {}
52
+ petkit_entities_records: dict[
53
+ int, FeederRecord | LitterRecord | WaterFountainRecord
54
+ ] = {}
51
55
 
52
56
  def __init__(
53
57
  self,
@@ -55,85 +59,49 @@ class PetKitClient:
55
59
  password: str,
56
60
  region: str,
57
61
  timezone: str,
62
+ session: aiohttp.ClientSession | None = None,
58
63
  ) -> None:
59
64
  """Initialize the PetKit Client."""
60
65
  self.username = username
61
66
  self.password = password
62
67
  self.region = region.lower()
63
68
  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(),
69
+ self._session = None
70
+ self.aiohttp_session = session or aiohttp.ClientSession()
71
+ self.req = PrepReq(
72
+ base_url=PetkitDomain.PASSPORT_PETKIT, session=self.aiohttp_session
94
73
  )
95
- _LOGGER.debug("API server list: %s", response)
96
- self._servers_list = [
97
- RegionInfo(**region) for region in response.get("list", [])
98
- ]
99
74
 
100
75
  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)
76
+ """Get the list of API servers, filter by region, and return the matching server."""
77
+ _LOGGER.debug("Getting API server list")
104
78
 
105
- # TODO : Improve this
106
- if self.region == "china":
107
- self._base_url = PetkitURL.CHINA_SRV
79
+ if self.region.lower() == "china":
80
+ self._base_url = PetkitDomain.CHINA_SRV
108
81
  return
109
82
 
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,
83
+ response = await self.req.request(
84
+ method=HTTPMethod.GET,
85
+ url=PetkitEndpoint.REGION_SERVERS,
118
86
  )
87
+ _LOGGER.debug("API server list: %s", response)
119
88
 
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
89
+ # Filter the servers by region
90
+ for region in response.get("list", []):
91
+ server = RegionInfo(**region)
92
+ if server.name.lower() == self.region or server.id.lower() == self.region:
93
+ self.req.base_url = server.gateway
94
+ _LOGGER.debug("Found matching server: %s", server)
95
+ return
126
96
  raise PetkitRegionalServerNotFoundError(self.region)
127
97
 
128
98
  async def request_login_code(self) -> bool:
129
99
  """Request a login code to be sent to the user's email."""
130
100
  _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(
101
+ response = await self.req.request(
133
102
  method=HTTPMethod.GET,
134
103
  url=PetkitEndpoint.GET_LOGIN_CODE,
135
104
  params={"username": self.username},
136
- headers=await self._generate_header(),
137
105
  )
138
106
  if response:
139
107
  _LOGGER.info("Login code sent to user's email")
@@ -145,7 +113,7 @@ class PetKitClient:
145
113
  # Retrieve the list of servers
146
114
  await self._get_base_url()
147
115
 
148
- _LOGGER.debug("Logging in to PetKit server")
116
+ _LOGGER.info("Logging in to PetKit server")
149
117
 
150
118
  # Prepare the data to send
151
119
  data = LOGIN_DATA.copy()
@@ -162,12 +130,10 @@ class PetKitClient:
162
130
  data["password"] = pwd # noqa: S324
163
131
 
164
132
  # Send the login request
165
- prep_req = PrepReq(base_url=self._base_url)
166
- response = await prep_req.request(
133
+ response = await self.req.request(
167
134
  method=HTTPMethod.POST,
168
135
  url=PetkitEndpoint.LOGIN,
169
136
  data=data,
170
- headers=await self._generate_header(),
171
137
  )
172
138
  session_data = response["session"]
173
139
  self._session = SessionInfo(**session_data)
@@ -175,11 +141,9 @@ class PetKitClient:
175
141
  async def refresh_session(self) -> None:
176
142
  """Refresh the session."""
177
143
  _LOGGER.debug("Refreshing session")
178
- prep_req = PrepReq(base_url=self._base_url)
179
- response = await prep_req.request(
144
+ response = await self.req.request(
180
145
  method=HTTPMethod.POST,
181
146
  url=PetkitEndpoint.REFRESH_SESSION,
182
- headers=await self._generate_header(),
183
147
  )
184
148
  session_data = response["session"]
185
149
  self._session = SessionInfo(**session_data)
@@ -207,74 +171,141 @@ class PetKitClient:
207
171
  await self.refresh_session()
208
172
  return
209
173
 
174
+ async def get_session_id(self) -> dict:
175
+ """Return the session ID."""
176
+ if self._session is None:
177
+ raise PypetkitError("Session is not initialized.")
178
+ return {"F-Session": self._session.id, "X-Session": self._session.id}
179
+
210
180
  async def _get_account_data(self) -> None:
211
181
  """Get the account data from the PetKit service."""
212
182
  await self.validate_session()
213
183
  _LOGGER.debug("Fetching account data")
214
- prep_req = PrepReq(base_url=self._base_url)
215
- response = await prep_req.request(
184
+ response = await self.req.request(
216
185
  method=HTTPMethod.GET,
217
186
  url=PetkitEndpoint.FAMILY_LIST,
218
- headers=await self._generate_header(),
187
+ headers=await self.get_session_id(),
219
188
  )
220
189
  self.account_data = [AccountData(**account) for account in response]
221
190
 
191
+ # Add pets to device_list
192
+ for account in self.account_data:
193
+ if account.pet_list:
194
+ for pet in account.pet_list:
195
+ self.petkit_entities[pet.pet_id] = pet
196
+
222
197
  async def get_devices_data(self) -> None:
223
198
  """Get the devices data from the PetKit servers."""
224
199
  start_time = datetime.now()
225
200
  if not self.account_data:
226
201
  await self._get_account_data()
227
202
 
203
+ tasks = []
228
204
  device_list: list[Device] = []
205
+
229
206
  for account in self.account_data:
230
207
  _LOGGER.debug("Fetching devices data for account: %s", account)
231
208
  if account.device_list:
232
209
  device_list.extend(account.device_list)
233
210
 
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)
211
+ _LOGGER.debug("Fetch %s devices for this account", len(device_list))
212
+
213
+ for device in device_list:
214
+ _LOGGER.debug("Fetching devices data: %s", device)
215
+ device_type = device.device_type.lower()
216
+ device_id = device.device_id
217
+ if device_type in DEVICES_FEEDER:
218
+ # Add tasks for feeders
219
+ tasks.append(self._fetch_device_data(account, device_id, Feeder))
220
+ tasks.append(
221
+ self._fetch_device_data(account, device_id, FeederRecord)
222
+ )
223
+ elif device_type in DEVICES_LITTER_BOX:
224
+ # Add tasks for litter boxes
225
+ tasks.append(self._fetch_device_data(account, device_id, Litter))
226
+ tasks.append(
227
+ self._fetch_device_data(account, device_id, LitterRecord)
228
+ )
229
+ elif device_type in DEVICES_WATER_FOUNTAIN:
230
+ # Add tasks for water fountains
231
+ tasks.append(
232
+ self._fetch_device_data(account, device_id, WaterFountain)
233
+ )
234
+ tasks.append(
235
+ self._fetch_device_data(account, device_id, WaterFountainRecord)
236
+ )
237
+ else:
238
+ _LOGGER.warning("Unknown device type: %s", device_type)
248
239
  await asyncio.gather(*tasks)
249
240
 
250
241
  end_time = datetime.now()
251
242
  total_time = end_time - start_time
252
- _LOGGER.debug("Petkit fetch took : %s", total_time)
243
+ _LOGGER.info("OK Petkit data fetched in : %s", total_time)
253
244
 
254
245
  async def _fetch_device_data(
255
246
  self,
256
- device: Device,
257
- data_class: type[Feeder | Litter | WaterFountain],
247
+ account: AccountData,
248
+ device_id: int,
249
+ data_class: type[
250
+ Feeder
251
+ | Litter
252
+ | WaterFountain
253
+ | FeederRecord
254
+ | LitterRecord
255
+ | WaterFountainRecord
256
+ ],
258
257
  ) -> None:
259
258
  """Fetch the device data from the PetKit servers."""
260
259
  await self.validate_session()
261
- endpoint = data_class.get_endpoint(device.device_type)
260
+ device = None
261
+
262
+ if account.device_list:
263
+ device = next(
264
+ (
265
+ device
266
+ for device in account.device_list
267
+ if device.device_id == device_id
268
+ ),
269
+ None,
270
+ )
271
+ if device is None:
272
+ _LOGGER.error("Device not found: id=%s", device_id)
273
+ return
262
274
  device_type = device.device_type.lower()
263
- query_param = data_class.query_param(device.device_id)
264
275
 
265
- prep_req = PrepReq(base_url=self._base_url)
266
- response = await prep_req.request(
267
- method=HTTPMethod.GET,
276
+ endpoint = data_class.get_endpoint(device_type)
277
+ query_param = data_class.query_param(account, device.device_id)
278
+
279
+ response = await self.req.request(
280
+ method=HTTPMethod.POST,
268
281
  url=f"{device_type}/{endpoint}",
269
282
  params=query_param,
270
- headers=await self._generate_header(),
271
- )
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
283
+ headers=await self.get_session_id(),
276
284
  )
277
- self.device_list[device.device_id] = device_data
285
+
286
+ # Check if the response is a list or a dict
287
+ if isinstance(response, list):
288
+ device_data = [data_class(**item) for item in response]
289
+ elif isinstance(response, dict):
290
+ device_data = data_class(**response)
291
+ else:
292
+ _LOGGER.error("Unexpected response type: %s", type(response))
293
+ return
294
+
295
+ if isinstance(device_data, list):
296
+ for item in device_data:
297
+ item.device_type = device_type
298
+ else:
299
+ device_data.device_type = device_type
300
+
301
+ _LOGGER.debug("Reading device type : %s (id=%s)", device_type, device_id)
302
+
303
+ if data_class.data_type == DEVICE_DATA:
304
+ self.petkit_entities[device_id] = device_data
305
+ elif data_class.data_type == DEVICE_RECORDS:
306
+ self.petkit_entities_records[device_id] = device_data
307
+ else:
308
+ _LOGGER.error("Unknown data type: %s", data_class.data_type)
278
309
 
279
310
  async def send_api_request(
280
311
  self,
@@ -283,7 +314,7 @@ class PetKitClient:
283
314
  setting: dict | None = None,
284
315
  ) -> None:
285
316
  """Control the device using the PetKit API."""
286
- device = self.device_list.get(device_id)
317
+ device = self.petkit_entities.get(device_id)
287
318
  if not device:
288
319
  raise PypetkitError(f"Device with ID {device_id} not found.")
289
320
 
@@ -295,42 +326,44 @@ class PetKitClient:
295
326
  setting,
296
327
  )
297
328
 
329
+ # Check if the device type is supported
298
330
  if device.device_type:
299
331
  device_type = device.device_type.lower()
300
332
  else:
301
333
  raise PypetkitError(
302
334
  "Device type is not available, and is mandatory for sending commands."
303
335
  )
304
-
336
+ # Check if the action is supported
305
337
  if action not in ACTIONS_MAP:
306
338
  raise PypetkitError(f"Action {action} not supported.")
307
339
 
308
340
  action_info = ACTIONS_MAP[action]
341
+ _LOGGER.debug(action)
342
+ _LOGGER.debug(action_info)
309
343
  if device_type not in action_info.supported_device:
310
344
  raise PypetkitError(
311
345
  f"Device type {device.device_type} not supported for action {action}."
312
346
  )
313
-
347
+ # Get the endpoint
314
348
  if callable(action_info.endpoint):
315
349
  endpoint = action_info.endpoint(device)
350
+ _LOGGER.debug("Endpoint from callable")
316
351
  else:
317
352
  endpoint = action_info.endpoint
353
+ _LOGGER.debug("Endpoint field")
318
354
  url = f"{device.device_type.lower()}/{endpoint}"
319
355
 
320
- headers = await self._generate_header()
321
-
322
- # Use the lambda to generate params
356
+ # Get the parameters
323
357
  if setting is not None:
324
358
  params = action_info.params(device, setting)
325
359
  else:
326
360
  params = action_info.params(device)
327
361
 
328
- prep_req = PrepReq(base_url=self._base_url)
329
- res = await prep_req.request(
362
+ res = await self.req.request(
330
363
  method=HTTPMethod.POST,
331
364
  url=url,
332
365
  data=params,
333
- headers=headers,
366
+ headers=await self.get_session_id(),
334
367
  )
335
368
  if res in (SUCCESS_KEY, RES_KEY):
336
369
  # TODO : Manage to get the response from manual feeding
@@ -338,14 +371,39 @@ class PetKitClient:
338
371
  else:
339
372
  _LOGGER.error("Command execution failed")
340
373
 
374
+ async def close(self) -> None:
375
+ """Close the aiohttp session if it was created by the client."""
376
+ if self.aiohttp_session:
377
+ await self.aiohttp_session.close()
378
+
341
379
 
342
380
  class PrepReq:
343
381
  """Prepare the request to the PetKit API."""
344
382
 
345
- def __init__(self, base_url: str, base_headers: dict | None = None) -> None:
383
+ def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None:
346
384
  """Initialize the request."""
347
385
  self.base_url = base_url
348
- self.base_headers = base_headers or {}
386
+ self.session = session
387
+ self.base_headers = self._generate_header()
388
+
389
+ @staticmethod
390
+ def _generate_header() -> dict[str, str]:
391
+ """Create header for interaction with API endpoint."""
392
+
393
+ return {
394
+ "Accept": Header.ACCEPT.value,
395
+ "Accept-Language": Header.ACCEPT_LANG,
396
+ "Accept-Encoding": Header.ENCODING,
397
+ "Content-Type": Header.CONTENT_TYPE,
398
+ "User-Agent": Header.AGENT,
399
+ "X-Img-Version": Header.IMG_VERSION,
400
+ "X-Locale": Header.LOCALE,
401
+ "X-Client": Header.CLIENT,
402
+ "X-Hour": Header.HOUR,
403
+ "X-TimezoneId": Header.TIMEZONE_ID,
404
+ "X-Api-Version": Header.API_VERSION,
405
+ "X-Timezone": Header.TIMEZONE,
406
+ }
349
407
 
350
408
  async def request(
351
409
  self,
@@ -366,34 +424,21 @@ class PrepReq:
366
424
  data,
367
425
  _headers,
368
426
  )
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
427
+ try:
428
+ async with self.session.request(
429
+ method,
430
+ _url,
431
+ params=params,
432
+ data=data,
433
+ headers=_headers,
434
+ ) as resp:
435
+ return await self._handle_response(resp, _url)
436
+ except aiohttp.ClientConnectorError as e:
437
+ raise PetkitTimeoutError(f"Cannot connect to host: {e}") from e
392
438
 
393
439
  @staticmethod
394
440
  async def _handle_response(response: aiohttp.ClientResponse, url: str) -> dict:
395
441
  """Handle the response from the PetKit API."""
396
-
397
442
  try:
398
443
  response.raise_for_status()
399
444
  except aiohttp.ClientResponseError as e:
@@ -408,6 +453,7 @@ class PrepReq:
408
453
  "Response is not in JSON format"
409
454
  ) from None
410
455
 
456
+ # Check for errors in the response
411
457
  if ERR_KEY in response_json:
412
458
  error_msg = response_json[ERR_KEY].get("msg", "Unknown error")
413
459
  if any(
@@ -421,6 +467,7 @@ class PrepReq:
421
467
  raise PetkitAuthenticationError(f"Login failed: {error_msg}")
422
468
  raise PypetkitError(f"Request failed: {error_msg}")
423
469
 
470
+ # Check for success in the response
424
471
  if RES_KEY in response_json:
425
472
  return response_json[RES_KEY]
426
473
 
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,9 @@ RES_KEY = "result"
9
9
  ERR_KEY = "error"
10
10
  SUCCESS_KEY = "success"
11
11
 
12
+ DEVICE_RECORDS = "deviceRecords"
13
+ DEVICE_DATA = "deviceData"
14
+
12
15
  # PetKit Models
13
16
  FEEDER = "feeder"
14
17
  FEEDER_MINI = "feedermini"
@@ -31,10 +34,10 @@ DEVICES_WATER_FOUNTAIN = [W5, CTW3]
31
34
  ALL_DEVICES = [*DEVICES_LITTER_BOX, *DEVICES_FEEDER, *DEVICES_WATER_FOUNTAIN]
32
35
 
33
36
 
34
- class PetkitURL(StrEnum):
37
+ class PetkitDomain(StrEnum):
35
38
  """Petkit URL constants"""
36
39
 
37
- REGION_SRV = "https://passport.petkt.com/v1/regionservers"
40
+ PASSPORT_PETKIT = "https://passport.petkt.com/"
38
41
  CHINA_SRV = "https://api.petkit.cn/6/"
39
42
 
40
43
 
@@ -58,7 +61,7 @@ class Header(StrEnum):
58
61
  AGENT = "okhttp/3.12.11"
59
62
  CLIENT = f"{Client.PLATFORM_TYPE}({Client.OS_VERSION};{Client.MODEL_NAME})"
60
63
  TIMEZONE = "1.0"
61
- TIMEZONE_ID = "Europe/Paris" # TODO: Make this dynamic, check if this really matters (record hours?)
64
+ TIMEZONE_ID = "Europe/Paris" # TODO: Make this dynamic
62
65
  LOCALE = "en-US"
63
66
  IMG_VERSION = "1.0"
64
67
  HOUR = "24"
@@ -71,7 +74,7 @@ CLIENT_NFO = {
71
74
  "platform": Client.PLATFORM_TYPE.value,
72
75
  "source": Client.SOURCE.value,
73
76
  "timezone": Header.TIMEZONE.value, # TODO: Make this dynamic
74
- "timezoneId": Header.TIMEZONE_ID.value, # TODO: Make this dynamic
77
+ "timezoneId": Header.TIMEZONE_ID.value,
75
78
  "version": Header.API_VERSION.value,
76
79
  }
77
80
 
@@ -84,11 +87,14 @@ LOGIN_DATA = {
84
87
  class PetkitEndpoint(StrEnum):
85
88
  """Petkit Endpoint constants"""
86
89
 
90
+ REGION_SERVERS = "v1/regionservers"
87
91
  LOGIN = "user/login"
88
92
  GET_LOGIN_CODE = "user/sendcodeforquicklogin"
89
93
  REFRESH_SESSION = "user/refreshsession"
90
94
  FAMILY_LIST = "group/family/list"
91
95
  REFRESH_HOME_V2 = "refreshHomeV2"
96
+
97
+ # Common to many device
92
98
  DEVICE_DETAIL = "device_detail"
93
99
  DEVICE_DATA = "deviceData"
94
100
  GET_DEVICE_RECORD = "getDeviceRecord"
@@ -103,6 +109,7 @@ class PetkitEndpoint(StrEnum):
103
109
 
104
110
  # Fountain & Litter Box
105
111
  CONTROL_DEVICE = "controlDevice"
112
+ GET_WORK_RECORD = "getWorkRecord"
106
113
 
107
114
  # Litter Box
108
115
  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.0.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=RCt3vxNJ14NKzwUpvL_M6xSYC4DfxEyDu7B8sJ7HFz0,16591
3
+ pypetkitapi/command.py,sha256=gw3_J_oZHuuGLk66P8uRSqSrySjYa8ArpKaPHi2ybCw,7155
4
+ pypetkitapi/const.py,sha256=EE84cCOEE6AMv3QuDJa8-FreCffhfq8nxPxqBpvB5XY,3270
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.0.0.dist-info/LICENSE,sha256=4FWnKolNLc1e3w6cVlT61YxfPh0DQNeQLN1CepKKSBg,1067
12
+ pypetkitapi-1.0.0.dist-info/METADATA,sha256=t1zmfg9-IZqpzBWybjhVTExCgkVJ50Vu8W-Twx8njiE,4590
13
+ pypetkitapi-1.0.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
14
+ pypetkitapi-1.0.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,,