python-aidot 0.3.55__py3-none-any.whl → 0.3.56__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.
aidot/aes_utils.py ADDED
@@ -0,0 +1,50 @@
1
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
2
+ from cryptography.hazmat.backends import default_backend
3
+ from cryptography.hazmat.primitives import padding
4
+ import json
5
+ from typing import Any, Optional
6
+
7
+
8
+ def aes_encrypt(plaintext, key):
9
+ padder = padding.PKCS7(algorithms.AES.block_size).padder()
10
+ padded_data = padder.update(plaintext) + padder.finalize()
11
+
12
+ cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
13
+ encryptor = cipher.encryptor()
14
+
15
+ ciphertext = encryptor.update(padded_data) + encryptor.finalize()
16
+
17
+ return ciphertext
18
+
19
+
20
+ def aes_decrypt(ciphertext, key):
21
+ cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
22
+ decryptor = cipher.decryptor()
23
+
24
+ decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
25
+
26
+ unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
27
+ plaintext = unpadder.update(decrypted_data) + unpadder.finalize()
28
+
29
+ return plaintext.decode()
30
+
31
+
32
+ def aes_decrypt_to_json(
33
+ ciphertext: bytes, key: Optional[bytes] = None
34
+ ) -> dict[str, Any]:
35
+ """Decrypt AES encrypted data and parse to JSON.
36
+
37
+ Args:
38
+ ciphertext: AES encrypted data
39
+ key: AES key (optional, if None, assumes data is already decrypted)
40
+
41
+ Returns:
42
+ Parsed JSON dict
43
+ """
44
+ if key:
45
+ decrypted_data = aes_decrypt(ciphertext, key)
46
+ else:
47
+ decrypted_data = (
48
+ ciphertext.decode() if isinstance(ciphertext, bytes) else ciphertext
49
+ )
50
+ return json.loads(decrypted_data)
aidot/client.py CHANGED
@@ -2,43 +2,63 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import base64
6
+ import aiohttp
5
7
  from aiohttp import ClientSession
6
8
  from typing import Any, Optional
9
+ from cryptography.hazmat.backends import default_backend
10
+ from cryptography.hazmat.primitives import serialization
11
+ from cryptography.hazmat.primitives.asymmetric import padding
7
12
  import uuid
8
13
  from pathlib import Path
9
14
  import hashlib
10
-
11
- from .utils import rsa_encrypt
15
+ from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
12
16
  from .device_client import DeviceClient
13
- from .models.auth_model import UserInformation, LoginRequest
14
- from .models.device_model import DeviceModel
15
- from .api.cloud_api import CloudApi
16
17
  from .discover import Discover
17
- from .const import PUBLIC_KEY_PEM
18
+ from .login_const import APP_ID, PUBLIC_KEY_PEM, API_URL_TEMPLATE, DEFAULT_REGION
18
19
  from .const import (
20
+ CONF_ACCESS_TOKEN,
21
+ CONF_APP_ID,
22
+ CONF_CODE,
23
+ CONF_COUNTRY,
24
+ CONF_DEVICE_LIST,
19
25
  CONF_ID,
20
26
  CONF_IPADDRESS,
21
- CONF_LOGIN_INFO,
22
- CONF_DEVICE_LIST,
27
+ CONF_PASSWORD,
23
28
  CONF_PRODUCT,
24
29
  CONF_PRODUCT_ID,
25
- CONF_IS_OWNER,
30
+ CONF_REFRESH_TOKEN,
31
+ CONF_REGION,
32
+ CONF_TERMINAL,
33
+ CONF_TOKEN,
34
+ CONF_USERNAME,
35
+ DEFAULT_COUNTRY_NAME,
26
36
  SUPPORTED_COUNTRYS,
27
- CONF_TYPE,
28
- CONF_AES_KEY,
37
+ DEFAULT_COUNTRY_CODE,
38
+ CONF_IS_OWNER,
39
+ CONF_LOGIN_INFO,
40
+ ServerErrorCode,
29
41
  )
30
42
 
31
43
  _LOGGER = logging.getLogger(__name__)
32
44
 
33
45
 
34
- class AidotClient:
35
- """AiDot client for managing devices and authentication."""
46
+ def rsa_password_encrypt(message: str) -> str:
47
+ """Get password rsa encrypt."""
48
+ public_key = serialization.load_pem_public_key(
49
+ PUBLIC_KEY_PEM, backend=default_backend()
50
+ )
51
+
52
+ encrypted = public_key.encrypt(
53
+ message.encode("utf-8"),
54
+ padding.PKCS1v15(),
55
+ )
36
56
 
37
- _device_clients: dict[str, DeviceClient]
38
- user_info: UserInformation = None
39
- _token_fresh_cb: Optional[callable] = None
40
- _products: dict[str, dict[str, Any]] = {}
57
+ encrypted_base64 = base64.b64encode(encrypted).decode("utf-8")
58
+ return encrypted_base64
41
59
 
60
+
61
+ class AidotClient:
42
62
  def __init__(
43
63
  self,
44
64
  session: Optional[ClientSession],
@@ -47,55 +67,49 @@ class AidotClient:
47
67
  password: str | None = None,
48
68
  token: dict | None = None,
49
69
  ) -> None:
50
- _LOGGER.info("Client Version: v0.3.55")
51
- self.country_code = country_code
70
+ _LOGGER.info("Client Version: v0.3.56")
71
+ self.session = session
72
+ self.username = username
73
+ self.password = password
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] = {}
52
79
  self._device_clients = {}
53
- self.user_info = UserInformation(
54
- username=username, password=password, country_code=country_code
55
- )
56
-
57
- # Set region and country from country_code
80
+ self._discover: Discover | None = None
81
+ self._token_fresh_cb = None
58
82
  for item in SUPPORTED_COUNTRYS:
59
- if item[CONF_ID] == self.country_code:
60
- self.user_info.country = item["name"]
61
- self.user_info.region = item["region"].lower()
83
+ if item["id"] == self.country_code:
84
+ self.country_name = item["name"]
85
+ self._region = item["region"].lower()
86
+ self._base_url = API_URL_TEMPLATE.format(region=self._region)
62
87
  break
63
-
64
- # Handle token (existing login info)
65
88
  if token is not None:
89
+ # ✅ 兼容性处理: v1.0.8 数据结构迁移到 v1.1.3
90
+ # 旧版本: config_entry.data[CONF_LOGIN_INFO]
91
+ # 新版本: config_entry.data
66
92
  if token.get(CONF_ID) is None and token.get(CONF_LOGIN_INFO) is not None:
67
93
  token = token.get(CONF_LOGIN_INFO)
68
- self.user_info.update_from_json(token)
69
-
70
- self._cloud_api = CloudApi(
71
- session=session,
72
- user_info=self.user_info,
73
- auth_failed_callback=self._on_auth_failed,
74
- token_refreshed_callback=self._on_token_refreshed,
75
- )
76
- self.setup_discover()
77
94
 
78
- @property
79
- def login_info(self) -> dict:
80
- return self.user_info.to_dict()
81
-
82
- def _on_auth_failed(self) -> None:
83
- """Handle authentication failed event from CloudApi."""
84
- _LOGGER.warning("Authentication failed, clearing user info")
85
- self.user_info.accessToken = ""
86
-
87
- def _on_token_refreshed(self) -> None:
88
- """Handle token refreshed event from CloudApi."""
89
- _LOGGER.debug("Token refreshed successfully")
90
- if self._token_fresh_cb:
91
- self._token_fresh_cb()
95
+ self.login_info = token.copy()
96
+ self.username = token[CONF_USERNAME]
97
+ self.password = token[CONF_PASSWORD]
98
+ self._region = token[CONF_REGION]
99
+ self.country_name = token[CONF_COUNTRY]
100
+ self._base_url = API_URL_TEMPLATE.format(region=self._region)
101
+ self.setup_discover()
92
102
 
93
103
  def set_token_fresh_cb(self, callback) -> None:
94
- """Set callback for token refresh events."""
95
104
  self._token_fresh_cb = callback
96
105
 
106
+ def get_identifier(self) -> str:
107
+ return f"{self._region}-{self.username}"
108
+
109
+ def update_password(self, password: str) -> None:
110
+ self.password = password
111
+
97
112
  async def get_terminal_id(self) -> str:
98
- """Get or create terminal ID for device identification."""
99
113
  file_path = Path.home() / ".aidot_terminal_id"
100
114
 
101
115
  def _read_or_create() -> str:
@@ -115,95 +129,186 @@ class AidotClient:
115
129
 
116
130
  async def async_post_login(self) -> dict[str, Any]:
117
131
  """Login the user input allows us to connect."""
118
- terminal_id = await self.get_terminal_id()
119
-
120
- login_request = LoginRequest.create(
121
- username=self.user_info.username,
122
- encrypted_password=rsa_encrypt(self.user_info.password, PUBLIC_KEY_PEM),
123
- country_name=self.user_info.country,
124
- terminal_id=terminal_id,
125
- )
126
-
127
- response_data = await self._cloud_api.login(login_request.to_dict())
128
- self.user_info.update_from_json(response_data)
129
- self.setup_discover()
130
- return self.user_info.to_dict()
132
+ url = f"{self._base_url}/users/loginWithFreeVerification"
133
+ headers = {CONF_APP_ID: APP_ID, CONF_TERMINAL: "app"}
134
+ # f"{region}:{self.country_name.strip()}",
135
+ terminalId = await self.get_terminal_id()
136
+ if terminalId is None:
137
+ terminalId = "gvz3gjae10l4zii00t7y0"
138
+ data = {
139
+ "countryKey": f"region:{self.country_name.strip()}",
140
+ "username": self.username,
141
+ "password": rsa_password_encrypt(self.password),
142
+ "terminalId": terminalId,
143
+ "webVersion": "0.5.0",
144
+ "area": "Asia/Shanghai",
145
+ "UTC": "UTC+8",
146
+ }
147
+
148
+ response_data: dict[str, Any] = {}
149
+ try:
150
+ response = await self.session.post(url, headers=headers, json=data)
151
+ response_data = await response.json()
152
+ response.raise_for_status()
153
+ self.login_info = response_data
154
+ self.login_info[CONF_PASSWORD] = self.password
155
+ self.login_info[CONF_REGION] = self._region
156
+ self.login_info[CONF_COUNTRY] = self.country_name
157
+ self.setup_discover()
158
+ return self.login_info
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
164
+
165
+ async def async_refresh_token(self) -> dict[str, Any]:
166
+ url = f"{self._base_url}/users/refreshToken"
167
+ headers = {CONF_APP_ID: APP_ID, CONF_TERMINAL: "app"}
168
+ data = {
169
+ CONF_REFRESH_TOKEN: self.login_info[CONF_REFRESH_TOKEN],
170
+ }
171
+
172
+ response_data: dict[str, Any] = {}
173
+ try:
174
+ response = await self.session.post(url, headers=headers, json=data)
175
+ response_data = await response.json()
176
+ response.raise_for_status()
177
+ self.login_info[CONF_ACCESS_TOKEN] = response_data[CONF_ACCESS_TOKEN]
178
+ if response_data[CONF_REFRESH_TOKEN] is not None:
179
+ self.login_info[CONF_REFRESH_TOKEN] = response_data[CONF_REFRESH_TOKEN]
180
+ _LOGGER.debug(f"refresh token: {response_data}")
181
+ if self._token_fresh_cb:
182
+ self._token_fresh_cb()
183
+ return response_data
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
189
+
190
+ async def async_session_get(
191
+ self, params: str, headers: str | None = None
192
+ ) -> dict[str, Any]:
193
+ url = f"{self._base_url}{params}"
194
+ token = self.login_info[CONF_ACCESS_TOKEN]
195
+ if token is None:
196
+ raise AidotAuthFailed()
197
+ if headers is None:
198
+ headers = {
199
+ CONF_TERMINAL: "app",
200
+ CONF_TOKEN: token,
201
+ CONF_APP_ID: APP_ID,
202
+ }
203
+ response_data = {}
204
+ try:
205
+ response = await self.session.get(url, headers=headers)
206
+ response_data = await response.json()
207
+ response.raise_for_status()
208
+ return response_data
209
+ except aiohttp.ClientError as err:
210
+ _LOGGER.error("async_get ClientError: %s %s", err, response_data)
211
+ code = response_data.get(CONF_CODE)
212
+ if code == ServerErrorCode.TOKEN_EXPIRED:
213
+ try:
214
+ await self.async_refresh_token()
215
+ return await self.async_session_get(params)
216
+ except AidotAuthFailed as auth_err:
217
+ raise AidotAuthFailed from auth_err
218
+ elif (
219
+ code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041
220
+ ):
221
+ self.login_info[CONF_ACCESS_TOKEN] = None
222
+ raise AidotAuthFailed from err
223
+ raise
224
+
225
+ async def async_get_products(self, product_ids: str) -> list[dict[str, Any]]:
226
+ """Get device list."""
227
+ params = f"/products/{product_ids}"
228
+ return await self.async_session_get(params)
229
+
230
+ async def async_get_devices(self, house_id: str) -> list[dict[str, Any]]:
231
+ """Get device list."""
232
+ params = f"/devices?houseId={house_id}"
233
+ return await self.async_session_get(params)
234
+
235
+ async def async_get_houses(self) -> list[dict[str, Any]]:
236
+ """Get house list."""
237
+ params = "/houses"
238
+ return await self.async_session_get(params)
131
239
 
132
240
  async def async_get_all_device(self) -> dict[str, Any]:
133
- """Get all devices for the user."""
134
- filter_devices: dict[str, Any] = {}
135
- filter_product_ids: set[str] = set()
241
+ final_device_list: list[dict[str, Any]] = []
136
242
  try:
137
- houses = await self._cloud_api.get_houses() or []
243
+ houses = await self.async_get_houses()
138
244
  for house in houses:
139
245
  if house.get(CONF_IS_OWNER) is False:
140
246
  continue
141
- device_list = await self._cloud_api.get_devices(house[CONF_ID]) or []
142
- for device in device_list:
143
- filter_device: dict[str, Any] = None
144
- if (
145
- device.get(CONF_TYPE) == "light"
146
- and device.get(CONF_AES_KEY, [None])[0] is not None
147
- ):
148
- filter_device = device
149
- if filter_device is not None:
150
- filter_devices[device[CONF_ID]] = device
151
- filter_product_ids.add(device[CONF_PRODUCT_ID])
152
-
153
- # Get product info and merge into devices
154
- if filter_product_ids:
155
- product_ids = ",".join(filter_product_ids)
156
- product_list = await self._cloud_api.get_products(product_ids) or []
157
- product_map = {p[CONF_ID]: p for p in product_list}
158
- for device in filter_devices.values():
159
- device[CONF_PRODUCT] = product_map.get(device[CONF_PRODUCT_ID])
247
+ # get device_list
248
+ device_list = await self.async_get_devices(house[CONF_ID])
249
+ if device_list:
250
+ final_device_list.extend(device_list)
251
+
252
+ # get product_list
253
+ if not final_device_list:
254
+ return {CONF_DEVICE_LIST: []}
255
+ productIds = ",".join([item[CONF_PRODUCT_ID] for item in final_device_list])
256
+ product_list = await self.async_get_products(productIds)
257
+
258
+ for product in product_list:
259
+ for device in final_device_list:
260
+ if device[CONF_PRODUCT_ID] == product[CONF_ID]:
261
+ device[CONF_PRODUCT] = product
160
262
 
161
263
  except Exception as e:
162
264
  raise e
163
- return filter_devices
265
+ return {CONF_DEVICE_LIST: final_device_list}
164
266
 
165
267
  def get_device_client(self, device: dict[str, Any]) -> DeviceClient:
166
- """Get or create device client for a device."""
167
- _device: DeviceModel = DeviceModel.from_json(data=device)
168
- device_client: DeviceClient = self._device_clients.get(_device.id)
268
+ device_id = device.get(CONF_ID)
269
+ device_client: DeviceClient = self._device_clients.get(device_id)
169
270
  if device_client is None:
170
- device_client = DeviceClient(_device, self.user_info)
171
- self._device_clients[_device.id] = device_client
172
-
173
- ip = Discover.DISCOVERED_DEVICE.get(_device.id)
174
- device_client.update_ip_address(ip)
271
+ device_client = DeviceClient(device, self.login_info)
272
+ self._device_clients[device_id] = device_client
273
+ if self._discover is not None:
274
+ ip = self._discover.discovered_device.get(device_id)
275
+ device_client.update_ip_address(ip)
175
276
  return device_client
176
277
 
177
278
  async def remove_device_client(self, dev_id: str) -> None:
178
- """Remove and close device client."""
179
279
  device_client: DeviceClient = self._device_clients.get(dev_id)
180
280
  if device_client is not None:
181
281
  await device_client.close()
182
282
  del self._device_clients[dev_id]
183
283
 
184
284
  def setup_discover(self) -> None:
185
- """Initialize device discovery after login."""
186
- if not self.user_info.id:
285
+ """初始化完成后调用,启动设备发现"""
286
+ if self.login_info.get(CONF_ID) is None:
287
+ return
288
+ if self._discover is not None:
187
289
  return
188
290
 
189
291
  _LOGGER.warning("setup_discover")
190
292
 
191
- def _discover_callback(dev_id: str, event: dict[str, str]) -> None:
293
+ def _discover_callback(dev_id, event: dict[str, str]) -> None:
192
294
  device_ip = event[CONF_IPADDRESS]
193
295
  device_client: DeviceClient = self._device_clients.get(dev_id)
194
296
  if device_client is not None:
195
297
  device_client.update_ip_address(device_ip)
196
298
 
197
- Discover.set_call_back(_discover_callback)
198
- Discover.set_user_info(self.user_info)
299
+ self._discover = Discover(self.login_info, _discover_callback)
300
+ self._discover.start_repeat_broadcast()
199
301
 
200
302
  async def async_close(self) -> None:
201
- """Close client and cleanup resources."""
303
+ """关闭客户端,清理资源"""
304
+ if self._discover is not None:
305
+ self._discover.close()
306
+ self._discover = None
202
307
  for client in self._device_clients.values():
203
308
  await client.close()
204
309
  self._device_clients.clear()
205
310
 
206
311
  async def async_cleanup(self) -> None:
207
- """Cleanup all resources."""
312
+ """清理所有资源"""
208
313
  _LOGGER.debug("async_cleanup")
209
314
  await self.async_close()
aidot/const.py CHANGED
@@ -230,17 +230,3 @@ class ServerErrorCode(IntEnum):
230
230
  TOKEN_EXPIRED = 21026
231
231
  LOGIN_INVALID = 21025
232
232
  USER_PWD_INCORRECT = 560080
233
-
234
-
235
- # Login / API constants
236
- APP_ID = "1383974540041977857"
237
- API_URL_TEMPLATE = "https://prod-{region}-api.arnoo.com/v17"
238
- DEFAULT_REGION = "us"
239
- PUBLIC_KEY_PEM = b"""
240
- -----BEGIN PUBLIC KEY-----
241
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
242
- p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
243
- c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
244
- kXMSjXOldI/290b9BQIDAQAB
245
- -----END PUBLIC KEY-----
246
- """