python-aidot 0.3.54b4__tar.gz → 0.3.56__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.
Files changed (24) hide show
  1. {python_aidot-0.3.54b4/python_aidot.egg-info → python_aidot-0.3.56}/PKG-INFO +4 -2
  2. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/aes_utils.py +8 -4
  3. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/client.py +36 -36
  4. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/device_client.py +31 -27
  5. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/discover.py +14 -9
  6. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/login_const.py +16 -16
  7. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/models/device_client_model.py +3 -1
  8. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/models/discover_model.py +2 -1
  9. {python_aidot-0.3.54b4 → python_aidot-0.3.56/python_aidot.egg-info}/PKG-INFO +4 -2
  10. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/python_aidot.egg-info/SOURCES.txt +2 -1
  11. python_aidot-0.3.56/python_aidot.egg-info/requires.txt +4 -0
  12. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/setup.cfg +1 -1
  13. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/setup.py +26 -24
  14. python_aidot-0.3.56/tests/test_client.py +147 -0
  15. python_aidot-0.3.54b4/python_aidot.egg-info/requires.txt +0 -2
  16. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/LICENSE +0 -0
  17. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/README.md +0 -0
  18. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/__init__.py +0 -0
  19. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/const.py +0 -0
  20. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/exceptions.py +0 -0
  21. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/models/__init__.py +0 -0
  22. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/aidot/models/device_model.py +0 -0
  23. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/python_aidot.egg-info/dependency_links.txt +0 -0
  24. {python_aidot-0.3.54b4 → python_aidot-0.3.56}/python_aidot.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.54b4
3
+ Version: 0.3.56
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -9,8 +9,10 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
- Requires-Dist: requests
13
12
  Requires-Dist: aiohttp
13
+ Requires-Dist: cryptography
14
+ Requires-Dist: dacite
15
+ Requires-Dist: requests
14
16
  Dynamic: author
15
17
  Dynamic: classifier
16
18
  Dynamic: description
@@ -29,18 +29,22 @@ def aes_decrypt(ciphertext, key):
29
29
  return plaintext.decode()
30
30
 
31
31
 
32
- def aes_decrypt_to_json(ciphertext: bytes, key: Optional[bytes] = None) -> dict[str, Any]:
32
+ def aes_decrypt_to_json(
33
+ ciphertext: bytes, key: Optional[bytes] = None
34
+ ) -> dict[str, Any]:
33
35
  """Decrypt AES encrypted data and parse to JSON.
34
-
36
+
35
37
  Args:
36
38
  ciphertext: AES encrypted data
37
39
  key: AES key (optional, if None, assumes data is already decrypted)
38
-
40
+
39
41
  Returns:
40
42
  Parsed JSON dict
41
43
  """
42
44
  if key:
43
45
  decrypted_data = aes_decrypt(ciphertext, key)
44
46
  else:
45
- decrypted_data = ciphertext.decode() if isinstance(ciphertext, bytes) else ciphertext
47
+ decrypted_data = (
48
+ ciphertext.decode() if isinstance(ciphertext, bytes) else ciphertext
49
+ )
46
50
  return json.loads(decrypted_data)
@@ -36,8 +36,7 @@ from .const import (
36
36
  SUPPORTED_COUNTRYS,
37
37
  DEFAULT_COUNTRY_CODE,
38
38
  CONF_IS_OWNER,
39
- CONF_TYPE,
40
- CONF_AES_KEY,
39
+ CONF_LOGIN_INFO,
41
40
  ServerErrorCode,
42
41
  )
43
42
 
@@ -60,17 +59,6 @@ def rsa_password_encrypt(message: str) -> str:
60
59
 
61
60
 
62
61
  class AidotClient:
63
- _base_url: str = API_URL_TEMPLATE.format(region=DEFAULT_REGION)
64
- _region: str = DEFAULT_REGION
65
- session: Optional[ClientSession] = None
66
- username: str = ""
67
- password: str = ""
68
- country_name: str = DEFAULT_COUNTRY_NAME
69
- country_code: str = DEFAULT_COUNTRY_CODE
70
- login_info: dict[str, Any] = {}
71
- _device_clients: dict[str, DeviceClient]
72
- _discover: Discover = None
73
-
74
62
  def __init__(
75
63
  self,
76
64
  session: Optional[ClientSession],
@@ -79,12 +67,18 @@ class AidotClient:
79
67
  password: str | None = None,
80
68
  token: dict | None = None,
81
69
  ) -> None:
82
- _LOGGER.info("Client Version: v0.3.54b3")
70
+ _LOGGER.info("Client Version: v0.3.56")
83
71
  self.session = session
84
72
  self.username = username
85
73
  self.password = password
86
- self.country_code = country_code
74
+ self.country_code = country_code or DEFAULT_COUNTRY_CODE
75
+ self.country_name = DEFAULT_COUNTRY_NAME
76
+ self._region = DEFAULT_REGION
77
+ self._base_url = API_URL_TEMPLATE.format(region=self._region)
78
+ self.login_info: dict[str, Any] = {}
87
79
  self._device_clients = {}
80
+ self._discover: Discover | None = None
81
+ self._token_fresh_cb = None
88
82
  for item in SUPPORTED_COUNTRYS:
89
83
  if item["id"] == self.country_code:
90
84
  self.country_name = item["name"]
@@ -97,7 +91,7 @@ class AidotClient:
97
91
  # 新版本: config_entry.data
98
92
  if token.get(CONF_ID) is None and token.get(CONF_LOGIN_INFO) is not None:
99
93
  token = token.get(CONF_LOGIN_INFO)
100
-
94
+
101
95
  self.login_info = token.copy()
102
96
  self.username = token[CONF_USERNAME]
103
97
  self.password = token[CONF_PASSWORD]
@@ -105,6 +99,7 @@ class AidotClient:
105
99
  self.country_name = token[CONF_COUNTRY]
106
100
  self._base_url = API_URL_TEMPLATE.format(region=self._region)
107
101
  self.setup_discover()
102
+
108
103
  def set_token_fresh_cb(self, callback) -> None:
109
104
  self._token_fresh_cb = callback
110
105
 
@@ -113,7 +108,7 @@ class AidotClient:
113
108
 
114
109
  def update_password(self, password: str) -> None:
115
110
  self.password = password
116
-
111
+
117
112
  async def get_terminal_id(self) -> str:
118
113
  file_path = Path.home() / ".aidot_terminal_id"
119
114
 
@@ -127,7 +122,7 @@ class AidotClient:
127
122
  file_path.write_text(raw_id)
128
123
  return raw_id
129
124
  except OSError:
130
- return 'gvz3gjae10l4zii00t7y0'
125
+ return "gvz3gjae10l4zii00t7y0"
131
126
 
132
127
  raw_id = await asyncio.to_thread(_read_or_create)
133
128
  return hashlib.md5(raw_id.encode()).hexdigest()
@@ -150,6 +145,7 @@ class AidotClient:
150
145
  "UTC": "UTC+8",
151
146
  }
152
147
 
148
+ response_data: dict[str, Any] = {}
153
149
  try:
154
150
  response = await self.session.post(url, headers=headers, json=data)
155
151
  response_data = await response.json()
@@ -160,11 +156,11 @@ class AidotClient:
160
156
  self.login_info[CONF_COUNTRY] = self.country_name
161
157
  self.setup_discover()
162
158
  return self.login_info
163
- except aiohttp.ClientError as e:
164
- _LOGGER.error(f"async_post_login ClientError: {e}")
165
- if response_data[CONF_CODE] == ServerErrorCode.USER_PWD_INCORRECT:
166
- raise AidotUserOrPassIncorrect
167
- raise Exception
159
+ except aiohttp.ClientError as err:
160
+ _LOGGER.error("async_post_login ClientError: %s", err)
161
+ if response_data.get(CONF_CODE) == ServerErrorCode.USER_PWD_INCORRECT:
162
+ raise AidotUserOrPassIncorrect from err
163
+ raise
168
164
 
169
165
  async def async_refresh_token(self) -> dict[str, Any]:
170
166
  url = f"{self._base_url}/users/refreshToken"
@@ -173,6 +169,7 @@ class AidotClient:
173
169
  CONF_REFRESH_TOKEN: self.login_info[CONF_REFRESH_TOKEN],
174
170
  }
175
171
 
172
+ response_data: dict[str, Any] = {}
176
173
  try:
177
174
  response = await self.session.post(url, headers=headers, json=data)
178
175
  response_data = await response.json()
@@ -184,11 +181,11 @@ class AidotClient:
184
181
  if self._token_fresh_cb:
185
182
  self._token_fresh_cb()
186
183
  return response_data
187
- except aiohttp.ClientError as e:
188
- _LOGGER.error(f"async_refresh_token ClientError: {e} {response_data}")
189
- if response_data[CONF_CODE] == ServerErrorCode.LOGIN_INVALID:
190
- raise AidotAuthFailed
191
- return None
184
+ except aiohttp.ClientError as err:
185
+ _LOGGER.error("async_refresh_token ClientError: %s %s", err, response_data)
186
+ if response_data.get(CONF_CODE) == ServerErrorCode.LOGIN_INVALID:
187
+ raise AidotAuthFailed from err
188
+ raise
192
189
 
193
190
  async def async_session_get(
194
191
  self, params: str, headers: str | None = None
@@ -209,21 +206,21 @@ class AidotClient:
209
206
  response_data = await response.json()
210
207
  response.raise_for_status()
211
208
  return response_data
212
- except aiohttp.ClientError as e:
213
- _LOGGER.error(f"async_get ClientError: {e} {response_data}")
209
+ except aiohttp.ClientError as err:
210
+ _LOGGER.error("async_get ClientError: %s %s", err, response_data)
214
211
  code = response_data.get(CONF_CODE)
215
212
  if code == ServerErrorCode.TOKEN_EXPIRED:
216
213
  try:
217
214
  await self.async_refresh_token()
218
215
  return await self.async_session_get(params)
219
- except AidotAuthFailed:
220
- raise AidotAuthFailed
216
+ except AidotAuthFailed as auth_err:
217
+ raise AidotAuthFailed from auth_err
221
218
  elif (
222
219
  code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041
223
220
  ):
224
221
  self.login_info[CONF_ACCESS_TOKEN] = None
225
- raise AidotAuthFailed
226
- return aiohttp.ClientError
222
+ raise AidotAuthFailed from err
223
+ raise
227
224
 
228
225
  async def async_get_products(self, product_ids: str) -> list[dict[str, Any]]:
229
226
  """Get device list."""
@@ -253,6 +250,8 @@ class AidotClient:
253
250
  final_device_list.extend(device_list)
254
251
 
255
252
  # get product_list
253
+ if not final_device_list:
254
+ return {CONF_DEVICE_LIST: []}
256
255
  productIds = ",".join([item[CONF_PRODUCT_ID] for item in final_device_list])
257
256
  product_list = await self.async_get_products(productIds)
258
257
 
@@ -260,7 +259,7 @@ class AidotClient:
260
259
  for device in final_device_list:
261
260
  if device[CONF_PRODUCT_ID] == product[CONF_ID]:
262
261
  device[CONF_PRODUCT] = product
263
-
262
+
264
263
  except Exception as e:
265
264
  raise e
266
265
  return {CONF_DEVICE_LIST: final_device_list}
@@ -289,7 +288,8 @@ class AidotClient:
289
288
  if self._discover is not None:
290
289
  return
291
290
 
292
- _LOGGER.warning(f"setup_discover")
291
+ _LOGGER.warning("setup_discover")
292
+
293
293
  def _discover_callback(dev_id, event: dict[str, str]) -> None:
294
294
  device_ip = event[CONF_IPADDRESS]
295
295
  device_client: DeviceClient = self._device_clients.get(dev_id)
@@ -7,16 +7,19 @@ import time
7
7
  import json
8
8
  import asyncio
9
9
  import logging
10
- from datetime import datetime
11
10
  from typing import Any
12
11
 
13
- from .exceptions import AidotNotLogin
14
12
  from .aes_utils import aes_encrypt, aes_decrypt_to_json
15
- from .models.device_client_model import PingRequest, LoginRequest, LoginPayload, DeviceResponse, DeviceAttr, DeviceActionRequest
13
+ from .models.device_client_model import (
14
+ PingRequest,
15
+ LoginRequest,
16
+ LoginPayload,
17
+ DeviceResponse,
18
+ DeviceAttr,
19
+ DeviceActionRequest,
20
+ )
16
21
  from .const import (
17
22
  CONF_AES_KEY,
18
- CONF_ASCNUMBER,
19
- CONF_ATTR,
20
23
  CONF_CCT,
21
24
  CONF_HARDWARE_VERSION,
22
25
  CONF_ID,
@@ -29,13 +32,10 @@ from .const import (
29
32
  CONF_ON_OFF,
30
33
  CONF_DIMMING,
31
34
  CONF_PASSWORD,
32
- CONF_PAYLOAD,
33
35
  CONF_PRODUCT,
34
36
  CONF_PROPERTIES,
35
37
  CONF_RGBW,
36
38
  CONF_SERVICE_MODULES,
37
- CONF_ACK,
38
- CONF_CODE,
39
39
  CONF_GET_DEV_ATTR_REQ,
40
40
  CONF_SET_DEV_ATTR_REQ,
41
41
  Identity,
@@ -67,7 +67,7 @@ class DeviceStatusData:
67
67
  self.rgdb = 0xFF000000 # Red in int: 4278190080
68
68
  else:
69
69
  self.rgdb = rgbw_value
70
-
70
+
71
71
  rgbw = ctypes.c_uint32(self.rgdb).value
72
72
  r = (rgbw >> 24) & 0xFF
73
73
  g = (rgbw >> 16) & 0xFF
@@ -128,6 +128,7 @@ class DeviceClient(object):
128
128
  heart_time = 30
129
129
  ping_data = PingRequest().to_dict()
130
130
  _TAG: str = "DeviceClient"
131
+
131
132
  @property
132
133
  def connect_and_login(self) -> bool:
133
134
  return self._connect_and_login
@@ -153,7 +154,7 @@ class DeviceClient(object):
153
154
  self.device_id = device.get(CONF_ID)
154
155
  self._simpleVersion = device.get("simpleVersion")
155
156
  self._TAG = f"{self.device_id}"
156
- if self.info.model_id == 'lk.WIFI-RGBWLight-D0006':
157
+ if self.info.model_id == "lk.WIFI-RGBWLight-D0006":
157
158
  self.ping_data = None
158
159
  self.heart_time = 10
159
160
 
@@ -183,7 +184,6 @@ class DeviceClient(object):
183
184
  if self._connecting is not True and self._connect_and_login is not True:
184
185
  self._login_task = asyncio.create_task(self.async_login())
185
186
 
186
-
187
187
  async def async_login(self) -> None:
188
188
  if self._ip_address is None:
189
189
  return
@@ -203,16 +203,15 @@ class DeviceClient(object):
203
203
  packet = magic + _msgtype + bodysize + send_data
204
204
 
205
205
  return packet
206
-
206
+
207
207
  def _notify_status_update(self) -> None:
208
208
  if self.on_status_update:
209
209
  self.on_status_update(self.status)
210
-
211
210
 
212
211
  async def login(self) -> None:
213
212
  login_seq = str(int(time.time() * 1000) + self._login_uuid)[-9:]
214
213
  self._login_uuid += 1
215
-
214
+
216
215
  login_request = LoginRequest(
217
216
  seq=login_seq,
218
217
  srcAddr=self.user_id,
@@ -231,7 +230,7 @@ class DeviceClient(object):
231
230
  body = await self.reader.readexactly(bodysize)
232
231
  json_data = aes_decrypt_to_json(body, self.aes_key)
233
232
  _LOGGER.warning(f"{self._TAG}:login result: {json_data}")
234
-
233
+
235
234
  response = DeviceResponse.from_json(json_data)
236
235
  if response.ack.code != 200:
237
236
  # 登录失败
@@ -243,8 +242,7 @@ class DeviceClient(object):
243
242
  self.status.online = True
244
243
  self._notify_status_update()
245
244
  self._receive_task = asyncio.create_task(
246
- self.receive_data(),
247
- name=f"aidot_receive_{self.device_id}"
245
+ self.receive_data(), name=f"aidot_receive_{self.device_id}"
248
246
  )
249
247
  if self._ping_timer:
250
248
  self._ping_timer.cancel()
@@ -263,14 +261,18 @@ class DeviceClient(object):
263
261
  try:
264
262
  header = await self.reader.readexactly(8)
265
263
  magic, msgtype, bodysize = struct.unpack(">HHI", header)
266
- self.ping_count = 0 #有读到数据就把ping清零
264
+ self.ping_count = 0 # 有读到数据就把ping清零
267
265
  body = await self.reader.readexactly(bodysize)
268
266
  json_data = aes_decrypt_to_json(body, self.aes_key)
269
267
  _LOGGER.warning(f"{self._TAG}:reveive_data : {json_data}")
270
268
  except asyncio.CancelledError:
271
269
  _LOGGER.debug(f"{self._TAG}:Receive task cancelled")
272
270
  raise
273
- except (BrokenPipeError, ConnectionResetError, asyncio.IncompleteReadError) as e:
271
+ except (
272
+ BrokenPipeError,
273
+ ConnectionResetError,
274
+ asyncio.IncompleteReadError,
275
+ ) as e:
274
276
  _LOGGER.error(f"{self._TAG}:read status error {e}")
275
277
  asyncio.get_running_loop().call_soon(
276
278
  lambda: asyncio.create_task(self.reset())
@@ -283,14 +285,14 @@ class DeviceClient(object):
283
285
  response = DeviceResponse.from_json(json_data)
284
286
  if response.service == "test":
285
287
  continue
286
-
288
+
287
289
  if response.payload:
288
290
  if response.payload.ascNumber:
289
291
  self.ascNumber = response.payload.ascNumber
290
292
  if response.payload.attr:
291
293
  self.status.update(response.payload.attr)
292
294
  self._notify_status_update()
293
-
295
+
294
296
  def _schedule_ping(self):
295
297
  loop = asyncio.get_running_loop()
296
298
  loop.create_task(self.send_ping_action())
@@ -298,10 +300,10 @@ class DeviceClient(object):
298
300
 
299
301
  async def send_dev_attr(self, dev_attr) -> None:
300
302
  if not self._connect_and_login:
301
- raise ConnectionError('Device offline')
302
- if not self.status.on and not CONF_ON_OFF in dev_attr:
303
+ raise ConnectionError("Device offline")
304
+ if not self.status.on and CONF_ON_OFF not in dev_attr:
303
305
  self.status.on = True
304
- attr[CONF_ON_OFF] = 1
306
+ dev_attr[CONF_ON_OFF] = 1
305
307
  await self.send_action(dev_attr, CONF_SET_DEV_ATTR_REQ)
306
308
 
307
309
  async def async_turn_off(self) -> None:
@@ -333,7 +335,7 @@ class DeviceClient(object):
333
335
  seq="ha93" + str(self.seq_num).zfill(5),
334
336
  simpleVersion=self._simpleVersion,
335
337
  )
336
-
338
+
337
339
  action = action_request.to_dict()
338
340
  _LOGGER.warning(f"{self.device_id} send_action {action}")
339
341
  try:
@@ -357,11 +359,13 @@ class DeviceClient(object):
357
359
  return -1
358
360
  if self._connect_and_login is False:
359
361
  return -1
360
-
362
+
361
363
  self.ping_count += 1
362
364
  if self.ping_data is not None:
363
365
  _LOGGER.warning(f"{self.device_id} send_ping {self.ping_data}")
364
- self.writer.write(self.get_send_packet(json.dumps(self.ping_data).encode(), 2))
366
+ self.writer.write(
367
+ self.get_send_packet(json.dumps(self.ping_data).encode(), 2)
368
+ )
365
369
  await self.writer.drain()
366
370
  else:
367
371
  _LOGGER.warning(f"{self.device_id} send_ping {self.syncProperties}")
@@ -1,6 +1,5 @@
1
1
  import socket
2
2
  import json
3
- import time
4
3
  import logging
5
4
  import asyncio
6
5
  from typing import Any
@@ -11,14 +10,15 @@ from .exceptions import AidotOSError
11
10
  from aidot.models.discover_model import DiscoverResponse, DiscoverRequest
12
11
 
13
12
  _LOGGER = logging.getLogger(__name__)
14
- _DISCOVER_FAST = 6 # 启动时快速发现
15
- _DISCOVER_SLOW = 120 # 稳定后慢速维持
13
+ _DISCOVER_FAST = 6 # 启动时快速发现
14
+ _DISCOVER_SLOW = 120 # 稳定后慢速维持
15
+
16
16
 
17
17
  class BroadcastProtocol:
18
18
  _is_closed = False
19
19
 
20
20
  def __init__(self, callback, user_id) -> None:
21
- self.aes_key = bytearray(b"T54uednca587".ljust(32, b'\x00'))
21
+ self.aes_key = bytearray(b"T54uednca587".ljust(32, b"\x00"))
22
22
 
23
23
  self._discover_cb = callback
24
24
  self.user_id = user_id
@@ -79,13 +79,18 @@ class Discover:
79
79
  self.discovered_device = {}
80
80
  self._login_info = login_info
81
81
  self._callback = callback
82
-
82
+
83
83
  async def try_create_broadcast(self) -> None:
84
84
  if self._broadcast_protocol is not None:
85
85
  return
86
86
  try:
87
- protocol = BroadcastProtocol(self._discover_callback, self._login_info[CONF_ID])
88
- self._transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
87
+ protocol = BroadcastProtocol(
88
+ self._discover_callback, self._login_info[CONF_ID]
89
+ )
90
+ (
91
+ self._transport,
92
+ _,
93
+ ) = await asyncio.get_running_loop().create_datagram_endpoint(
89
94
  lambda: protocol,
90
95
  local_addr=("0.0.0.0", 0),
91
96
  )
@@ -99,14 +104,14 @@ class Discover:
99
104
  self._schedule_broadcast()
100
105
 
101
106
  def _schedule_broadcast(self) -> None:
102
- _LOGGER.warning(f"_schedule_broadcast")
107
+ _LOGGER.warning("_schedule_broadcast")
103
108
  # 前几次快速发现,之后慢速
104
109
  if self._fast_discover_count > 0:
105
110
  interval = _DISCOVER_FAST
106
111
  self._fast_discover_count -= 1
107
112
  else:
108
113
  interval = _DISCOVER_SLOW
109
-
114
+
110
115
  loop = asyncio.get_running_loop()
111
116
  asyncio.create_task(self._do_broadcast())
112
117
  self._timer_handle = loop.call_later(interval, self._schedule_broadcast)
@@ -1,16 +1,16 @@
1
- """Constants for the aidot login."""
2
-
3
- APP_ID = "1383974540041977857"
4
-
5
- # API URL template - use .format(region="us") to construct
6
- API_URL_TEMPLATE = "https://prod-{region}-api.arnoo.com/v17"
7
- DEFAULT_REGION = "us"
8
-
9
- PUBLIC_KEY_PEM = b"""
10
- -----BEGIN PUBLIC KEY-----
11
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
12
- p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
13
- c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
14
- kXMSjXOldI/290b9BQIDAQAB
15
- -----END PUBLIC KEY-----
16
- """
1
+ """Constants for the aidot login."""
2
+
3
+ APP_ID = "1383974540041977857"
4
+
5
+ # API URL template - use .format(region="us") to construct
6
+ API_URL_TEMPLATE = "https://prod-{region}-api.arnoo.com/v17"
7
+ DEFAULT_REGION = "us"
8
+
9
+ PUBLIC_KEY_PEM = b"""
10
+ -----BEGIN PUBLIC KEY-----
11
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
12
+ p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
13
+ c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
14
+ kXMSjXOldI/290b9BQIDAQAB
15
+ -----END PUBLIC KEY-----
16
+ """
@@ -56,6 +56,7 @@ class LoginPayload:
56
56
  """Auto-generate timestamp if not provided."""
57
57
  if self.timestamp is None:
58
58
  from datetime import datetime
59
+
59
60
  self.timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
60
61
 
61
62
 
@@ -74,6 +75,7 @@ class LoginRequest:
74
75
  """Convert to dictionary."""
75
76
  return asdict(self)
76
77
 
78
+
77
79
  @dataclass
78
80
  class DeviceAck:
79
81
  """Device response ack."""
@@ -162,6 +164,7 @@ class DeviceActionRequest:
162
164
  """Auto-generate timestamp if not provided."""
163
165
  if self.tst == 0:
164
166
  import time
167
+
165
168
  self.tst = int(time.time() * 1000)
166
169
 
167
170
  def to_dict(self) -> dict[str, Any]:
@@ -207,4 +210,3 @@ class DeviceActionRequest:
207
210
  ),
208
211
  deviceId=device_id,
209
212
  )
210
-
@@ -1,4 +1,5 @@
1
1
  """Models for AiDot discover."""
2
+
2
3
  import time
3
4
  from dataclasses import dataclass, asdict, field
4
5
  from typing import Any, Optional
@@ -94,7 +95,7 @@ class DiscoverRequest:
94
95
  tst=current_timestamp_milliseconds,
95
96
  payload=DiscoverRequestPayload(
96
97
  timestamp=str(current_timestamp_milliseconds)
97
- )
98
+ ),
98
99
  )
99
100
 
100
101
  def to_dict(self) -> dict[str, Any]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.54b4
3
+ Version: 0.3.56
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -9,8 +9,10 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
- Requires-Dist: requests
13
12
  Requires-Dist: aiohttp
13
+ Requires-Dist: cryptography
14
+ Requires-Dist: dacite
15
+ Requires-Dist: requests
14
16
  Dynamic: author
15
17
  Dynamic: classifier
16
18
  Dynamic: description
@@ -18,4 +18,5 @@ python_aidot.egg-info/PKG-INFO
18
18
  python_aidot.egg-info/SOURCES.txt
19
19
  python_aidot.egg-info/dependency_links.txt
20
20
  python_aidot.egg-info/requires.txt
21
- python_aidot.egg-info/top_level.txt
21
+ python_aidot.egg-info/top_level.txt
22
+ tests/test_client.py
@@ -0,0 +1,4 @@
1
+ aiohttp
2
+ cryptography
3
+ dacite
4
+ requests
@@ -1,5 +1,5 @@
1
1
  [metadata]
2
- desciption-file = README.md
2
+ description_file = README.md
3
3
 
4
4
  [egg_info]
5
5
  tag_build =
@@ -1,24 +1,26 @@
1
- import setuptools
2
-
3
- with open("README.md", "r") as fh:
4
- long_description = fh.read()
5
-
6
- setuptools.setup(
7
- name="python-aidot",
8
- version="0.3.54b4",
9
- author="aidotdev2024",
10
- url='https://github.com/Aidot-Development-Team/python-aidot',
11
- description="aidot control wifi lights",
12
- long_description=long_description,
13
- long_description_content_type="text/markdown",
14
- packages=setuptools.find_packages(),
15
- install_requires=[
16
- "requests",
17
- "aiohttp",
18
- ],
19
- classifiers=(
20
- "Programming Language :: Python :: 3.12",
21
- "License :: OSI Approved :: MIT License",
22
- "Operating System :: OS Independent",
23
- ),
24
- )
1
+ import setuptools
2
+
3
+ with open("README.md", "r") as fh:
4
+ long_description = fh.read()
5
+
6
+ setuptools.setup(
7
+ name="python-aidot",
8
+ version="0.3.56",
9
+ author="aidotdev2024",
10
+ url="https://github.com/Aidot-Development-Team/python-aidot",
11
+ description="aidot control wifi lights",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ packages=setuptools.find_packages(),
15
+ install_requires=[
16
+ "aiohttp",
17
+ "cryptography",
18
+ "dacite",
19
+ "requests",
20
+ ],
21
+ classifiers=(
22
+ "Programming Language :: Python :: 3.12",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: OS Independent",
25
+ ),
26
+ )
@@ -0,0 +1,147 @@
1
+ """Tests for the AiDot client."""
2
+
3
+ import unittest
4
+ from typing import Any
5
+
6
+ import aiohttp
7
+
8
+ from aidot.client import AidotClient
9
+ from aidot.const import (
10
+ CONF_ACCESS_TOKEN,
11
+ CONF_COUNTRY,
12
+ CONF_ID,
13
+ CONF_PASSWORD,
14
+ CONF_REFRESH_TOKEN,
15
+ CONF_REGION,
16
+ CONF_USERNAME,
17
+ )
18
+
19
+
20
+ class FakeResponse:
21
+ """Minimal aiohttp response test double."""
22
+
23
+ def __init__(self, data: dict[str, Any]) -> None:
24
+ self._data = data
25
+
26
+ async def json(self) -> dict[str, Any]:
27
+ """Return the response payload."""
28
+ return self._data
29
+
30
+ def raise_for_status(self) -> None:
31
+ """Represent a successful response."""
32
+
33
+
34
+ class FakeSession:
35
+ """Record requests made by one client."""
36
+
37
+ def __init__(self, response: dict[str, Any]) -> None:
38
+ self._response = response
39
+ self.post_calls: list[tuple[str, dict[str, str], dict[str, Any]]] = []
40
+
41
+ async def post(
42
+ self,
43
+ url: str,
44
+ *,
45
+ headers: dict[str, str],
46
+ json: dict[str, Any],
47
+ ) -> FakeResponse:
48
+ """Record a POST request."""
49
+ self.post_calls.append((url, headers, json))
50
+ return FakeResponse(self._response)
51
+
52
+
53
+ class FailingSession:
54
+ """Raise a typed connection error for every request."""
55
+
56
+ async def post(
57
+ self,
58
+ url: str,
59
+ *,
60
+ headers: dict[str, str],
61
+ json: dict[str, Any],
62
+ ) -> FakeResponse:
63
+ """Simulate a network failure."""
64
+ raise aiohttp.ClientConnectionError
65
+
66
+
67
+ def create_token(
68
+ *, username: str, region: str, access_token: str, refresh_token: str
69
+ ) -> dict[str, Any]:
70
+ """Create stored login data for a client."""
71
+ return {
72
+ CONF_ID: None,
73
+ CONF_USERNAME: username,
74
+ CONF_PASSWORD: "password",
75
+ CONF_COUNTRY: "United States",
76
+ CONF_REGION: region,
77
+ CONF_ACCESS_TOKEN: access_token,
78
+ CONF_REFRESH_TOKEN: refresh_token,
79
+ }
80
+
81
+
82
+ class AidotClientTest(unittest.IsolatedAsyncioTestCase):
83
+ """Verify client state is isolated by account."""
84
+
85
+ async def test_multiple_accounts_keep_state_isolated(self) -> None:
86
+ """Refreshing one account does not update another account."""
87
+ first_session = FakeSession(
88
+ {
89
+ CONF_ACCESS_TOKEN: "first-new-access",
90
+ CONF_REFRESH_TOKEN: "first-new-refresh",
91
+ }
92
+ )
93
+ second_session = FakeSession(
94
+ {
95
+ CONF_ACCESS_TOKEN: "second-new-access",
96
+ CONF_REFRESH_TOKEN: "second-new-refresh",
97
+ }
98
+ )
99
+ first_client = AidotClient(
100
+ session=first_session,
101
+ token=create_token(
102
+ username="first@example.com",
103
+ region="us",
104
+ access_token="first-access",
105
+ refresh_token="first-refresh",
106
+ ),
107
+ )
108
+ second_client = AidotClient(
109
+ session=second_session,
110
+ token=create_token(
111
+ username="second@example.com",
112
+ region="eu",
113
+ access_token="second-access",
114
+ refresh_token="second-refresh",
115
+ ),
116
+ )
117
+ refreshed: list[str] = []
118
+ first_client.set_token_fresh_cb(lambda: refreshed.append("first"))
119
+ second_client.set_token_fresh_cb(lambda: refreshed.append("second"))
120
+
121
+ await first_client.async_refresh_token()
122
+
123
+ self.assertIsNot(first_client.login_info, second_client.login_info)
124
+ self.assertEqual(first_client.login_info[CONF_ACCESS_TOKEN], "first-new-access")
125
+ self.assertEqual(
126
+ first_client.login_info[CONF_REFRESH_TOKEN], "first-new-refresh"
127
+ )
128
+ self.assertEqual(second_client.login_info[CONF_ACCESS_TOKEN], "second-access")
129
+ self.assertEqual(second_client.login_info[CONF_REFRESH_TOKEN], "second-refresh")
130
+ self.assertEqual(second_session.post_calls, [])
131
+ self.assertEqual(refreshed, ["first"])
132
+
133
+ async def test_login_preserves_connection_error(self) -> None:
134
+ """Login keeps aiohttp connection errors typed for callers."""
135
+ client = AidotClient(
136
+ session=FailingSession(),
137
+ country_code="US",
138
+ username="user@example.com",
139
+ password="password",
140
+ )
141
+
142
+ with self.assertRaises(aiohttp.ClientConnectionError):
143
+ await client.async_post_login()
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -1,2 +0,0 @@
1
- requests
2
- aiohttp
File without changes
File without changes