python-aidot 0.3.55__py3-none-any.whl → 0.3.58__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,73 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import base64
6
+ import random
7
+ import aiohttp
5
8
  from aiohttp import ClientSession
6
9
  from typing import Any, Optional
10
+ from cryptography.hazmat.backends import default_backend
11
+ from cryptography.hazmat.primitives import serialization
12
+ from cryptography.hazmat.primitives.asymmetric import padding
7
13
  import uuid
8
14
  from pathlib import Path
9
15
  import hashlib
10
-
11
- from .utils import rsa_encrypt
16
+ from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
12
17
  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
18
+ from .models.device_model import EffectResp, FavoriteEffectPrimitive
16
19
  from .discover import Discover
17
- from .const import PUBLIC_KEY_PEM
20
+ from .login_const import APP_ID, PUBLIC_KEY_PEM, API_URL_TEMPLATE, DEFAULT_REGION
18
21
  from .const import (
22
+ CONF_ACCESS_TOKEN,
23
+ CONF_APP_ID,
24
+ CONF_CODE,
25
+ CONF_COUNTRY,
26
+ CONF_DEVICE_LIST,
19
27
  CONF_ID,
20
28
  CONF_IPADDRESS,
21
- CONF_LOGIN_INFO,
22
- CONF_DEVICE_LIST,
29
+ CONF_PASSWORD,
23
30
  CONF_PRODUCT,
24
31
  CONF_PRODUCT_ID,
25
- CONF_IS_OWNER,
32
+ CONF_REFRESH_TOKEN,
33
+ CONF_REGION,
34
+ CONF_TERMINAL,
35
+ CONF_TOKEN,
36
+ CONF_USERNAME,
37
+ DEFAULT_COUNTRY_NAME,
26
38
  SUPPORTED_COUNTRYS,
39
+ DEFAULT_COUNTRY_CODE,
40
+ CONF_IS_OWNER,
41
+ CONF_LOGIN_INFO,
42
+ ServerErrorCode,
43
+ CONF_MODEL_ID,
44
+ CONF_PROPERTIES,
45
+ CONF_LIGHT_SCRIPT_FLAGS,
46
+ CONF_PRESETS,
27
47
  CONF_TYPE,
28
48
  CONF_AES_KEY,
49
+ CONF_FIRMWARE_VERSION,
50
+ CONF_PRIMITIVE,
29
51
  )
30
52
 
31
53
  _LOGGER = logging.getLogger(__name__)
32
54
 
33
55
 
34
- class AidotClient:
35
- """AiDot client for managing devices and authentication."""
56
+ def rsa_password_encrypt(message: str) -> str:
57
+ """Get password rsa encrypt."""
58
+ public_key = serialization.load_pem_public_key(
59
+ PUBLIC_KEY_PEM, backend=default_backend()
60
+ )
61
+
62
+ encrypted = public_key.encrypt(
63
+ message.encode("utf-8"),
64
+ padding.PKCS1v15(),
65
+ )
66
+
67
+ encrypted_base64 = base64.b64encode(encrypted).decode("utf-8")
68
+ return encrypted_base64
36
69
 
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]] = {}
41
70
 
71
+ class AidotClient:
42
72
  def __init__(
43
73
  self,
44
74
  session: Optional[ClientSession],
@@ -47,55 +77,52 @@ class AidotClient:
47
77
  password: str | None = None,
48
78
  token: dict | None = None,
49
79
  ) -> None:
50
- _LOGGER.info("Client Version: v0.3.55")
51
- self.country_code = country_code
80
+ _LOGGER.info("Client Version: v0.3.57")
81
+ self.session = session
82
+ self.username = username
83
+ self.password = password
84
+ self.country_code = country_code or DEFAULT_COUNTRY_CODE
85
+ self.country_name = DEFAULT_COUNTRY_NAME
86
+ self._region = DEFAULT_REGION
87
+ self._base_url = API_URL_TEMPLATE.format(region=self._region)
88
+ self.login_info: dict[str, Any] = {}
52
89
  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
90
+ self._effect_mode_params_cache: dict[
91
+ tuple[str, str, str, bool], dict[str, Any]
92
+ ] = {}
93
+ self._discover: Discover | None = None
94
+ self._token_fresh_cb = None
58
95
  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()
96
+ if item["id"] == self.country_code:
97
+ self.country_name = item["name"]
98
+ self._region = item["region"].lower()
99
+ self._base_url = API_URL_TEMPLATE.format(region=self._region)
62
100
  break
63
-
64
- # Handle token (existing login info)
65
101
  if token is not None:
102
+ # ✅ 兼容性处理: v1.0.8 数据结构迁移到 v1.1.3
103
+ # 旧版本: config_entry.data[CONF_LOGIN_INFO]
104
+ # 新版本: config_entry.data
66
105
  if token.get(CONF_ID) is None and token.get(CONF_LOGIN_INFO) is not None:
67
106
  token = token.get(CONF_LOGIN_INFO)
68
- self.user_info.update_from_json(token)
69
107
 
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
- )
108
+ self.login_info = token.copy()
109
+ self.username = token[CONF_USERNAME]
110
+ self.password = token[CONF_PASSWORD]
111
+ self._region = token[CONF_REGION]
112
+ self.country_name = token[CONF_COUNTRY]
113
+ self._base_url = API_URL_TEMPLATE.format(region=self._region)
76
114
  self.setup_discover()
77
115
 
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()
92
-
93
116
  def set_token_fresh_cb(self, callback) -> None:
94
- """Set callback for token refresh events."""
95
117
  self._token_fresh_cb = callback
96
118
 
119
+ def get_identifier(self) -> str:
120
+ return f"{self._region}-{self.username}"
121
+
122
+ def update_password(self, password: str) -> None:
123
+ self.password = password
124
+
97
125
  async def get_terminal_id(self) -> str:
98
- """Get or create terminal ID for device identification."""
99
126
  file_path = Path.home() / ".aidot_terminal_id"
100
127
 
101
128
  def _read_or_create() -> str:
@@ -115,95 +142,400 @@ class AidotClient:
115
142
 
116
143
  async def async_post_login(self) -> dict[str, Any]:
117
144
  """Login the user input allows us to connect."""
118
- terminal_id = await self.get_terminal_id()
145
+ url = f"{self._base_url}/users/loginWithFreeVerification"
146
+ headers = {CONF_APP_ID: APP_ID, CONF_TERMINAL: "app"}
147
+ # f"{region}:{self.country_name.strip()}",
148
+ terminalId = await self.get_terminal_id()
149
+ if terminalId is None:
150
+ terminalId = "gvz3gjae10l4zii00t7y0"
151
+ data = {
152
+ "countryKey": f"region:{self.country_name.strip()}",
153
+ "username": self.username,
154
+ "password": rsa_password_encrypt(self.password),
155
+ "terminalId": terminalId,
156
+ "webVersion": "0.5.0",
157
+ "area": "Asia/Shanghai",
158
+ "UTC": "UTC+8",
159
+ }
160
+
161
+ response_data: dict[str, Any] = {}
162
+ try:
163
+ response = await self.session.post(url, headers=headers, json=data)
164
+ response_data = await response.json()
165
+ response.raise_for_status()
166
+ self.login_info = response_data
167
+ self.login_info[CONF_PASSWORD] = self.password
168
+ self.login_info[CONF_REGION] = self._region
169
+ self.login_info[CONF_COUNTRY] = self.country_name
170
+ self.setup_discover()
171
+ return self.login_info
172
+ except aiohttp.ClientError as err:
173
+ _LOGGER.error("async_post_login ClientError: %s", err)
174
+ if response_data.get(CONF_CODE) == ServerErrorCode.USER_PWD_INCORRECT:
175
+ raise AidotUserOrPassIncorrect from err
176
+ raise
177
+
178
+ async def async_refresh_token(self) -> dict[str, Any]:
179
+ url = f"{self._base_url}/users/refreshToken"
180
+ headers = {CONF_APP_ID: APP_ID, CONF_TERMINAL: "app"}
181
+ data = {
182
+ CONF_REFRESH_TOKEN: self.login_info[CONF_REFRESH_TOKEN],
183
+ }
184
+
185
+ response_data: dict[str, Any] = {}
186
+ try:
187
+ response = await self.session.post(url, headers=headers, json=data)
188
+ response_data = await response.json()
189
+ response.raise_for_status()
190
+ self.login_info[CONF_ACCESS_TOKEN] = response_data[CONF_ACCESS_TOKEN]
191
+ if response_data[CONF_REFRESH_TOKEN] is not None:
192
+ self.login_info[CONF_REFRESH_TOKEN] = response_data[CONF_REFRESH_TOKEN]
193
+ _LOGGER.debug(f"refresh token: {response_data}")
194
+ if self._token_fresh_cb:
195
+ self._token_fresh_cb()
196
+ return response_data
197
+ except aiohttp.ClientError as err:
198
+ _LOGGER.error("async_refresh_token ClientError: %s %s", err, response_data)
199
+ if response_data.get(CONF_CODE) == ServerErrorCode.LOGIN_INVALID:
200
+ raise AidotAuthFailed from err
201
+ raise
202
+
203
+ async def async_session_get(
204
+ self, params: str, headers: str | None = None
205
+ ) -> dict[str, Any]:
206
+ url = f"{self._base_url}{params}"
207
+ token = self.login_info[CONF_ACCESS_TOKEN]
208
+ if token is None:
209
+ raise AidotAuthFailed()
210
+ if headers is None:
211
+ headers = {
212
+ CONF_TERMINAL: "app",
213
+ CONF_TOKEN: token,
214
+ CONF_APP_ID: APP_ID,
215
+ }
216
+ response_data = {}
217
+ try:
218
+ response = await self.session.get(url, headers=headers)
219
+ response_data = await response.json()
220
+ response.raise_for_status()
221
+ return response_data
222
+ except aiohttp.ClientError as err:
223
+ _LOGGER.error("async_get ClientError: %s %s", err, response_data)
224
+ code = response_data.get(CONF_CODE)
225
+ if code == ServerErrorCode.TOKEN_EXPIRED:
226
+ try:
227
+ await self.async_refresh_token()
228
+ return await self.async_session_get(params)
229
+ except AidotAuthFailed as auth_err:
230
+ raise AidotAuthFailed from auth_err
231
+ elif (
232
+ code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041
233
+ ):
234
+ self.login_info[CONF_ACCESS_TOKEN] = None
235
+ raise AidotAuthFailed from err
236
+ raise
237
+
238
+ async def async_session_post(
239
+ self,
240
+ params: str,
241
+ data: Any,
242
+ headers: dict[str, str] | None = None,
243
+ ) -> dict[str, Any]:
244
+ """Post data to AiDot API."""
245
+ url = f"{self._base_url}{params}"
246
+ token = self.login_info[CONF_ACCESS_TOKEN]
247
+ if token is None:
248
+ raise AidotAuthFailed()
249
+ if headers is None:
250
+ headers = {
251
+ CONF_TERMINAL: "app",
252
+ CONF_TOKEN: token,
253
+ CONF_APP_ID: APP_ID,
254
+ }
255
+ response_data = {}
256
+ try:
257
+ response = await self.session.post(url, headers=headers, json=data)
258
+ response_data = await response.json()
259
+ response.raise_for_status()
260
+ return response_data
261
+ except aiohttp.ClientError as err:
262
+ _LOGGER.error("async_post ClientError: %s %s", err, response_data)
263
+ code = response_data.get(CONF_CODE)
264
+ if code == ServerErrorCode.TOKEN_EXPIRED:
265
+ try:
266
+ await self.async_refresh_token()
267
+ return await self.async_session_post(params, data)
268
+ except AidotAuthFailed as auth_err:
269
+ raise AidotAuthFailed from auth_err
270
+ elif (
271
+ code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041
272
+ ):
273
+ self.login_info[CONF_ACCESS_TOKEN] = None
274
+ raise AidotAuthFailed from err
275
+ raise
276
+
277
+ async def async_get_products(self, product_ids: str) -> list[dict[str, Any]]:
278
+ """Get device list."""
279
+ params = f"/products/{product_ids}"
280
+ return await self.async_session_get(params)
281
+
282
+ async def async_get_devices(self, house_id: str) -> list[dict[str, Any]]:
283
+ """Get device list."""
284
+ params = f"/devices?houseId={house_id}"
285
+ return await self.async_session_get(params)
286
+
287
+ async def async_get_houses(self) -> list[dict[str, Any]]:
288
+ """Get house list."""
289
+ params = "/houses"
290
+ return await self.async_session_get(params)
291
+
292
+ async def async_get_diy_list(
293
+ self, device: dict[str, Any]
294
+ ) -> list[FavoriteEffectPrimitive]:
295
+ """Get favorite list."""
296
+ params = f"/devices/{device[CONF_ID]}/v4.0/favoriteEffectMode"
297
+ resp = await self.async_session_get(params)
298
+ return EffectResp.from_json(resp).primitive
299
+
300
+ async def async_get_fav_presets(
301
+ self, device: dict[str, Any]
302
+ ) -> list[FavoriteEffectPrimitive]:
303
+ """Get favorite list."""
304
+ # https://prod-us-api.arnoo.com/v35/devices/8a040e2233a243e7a07eb6a1b7a210a7/v4.0/favoriteEffectMode?libraryId=aidot.preset
305
+
306
+ params = (
307
+ f"/devices/{device[CONF_ID]}/v4.0/favoriteEffectMode?libraryId=aidot.preset"
308
+ )
309
+ resp = await self.async_session_get(params)
310
+ return EffectResp.from_json(resp).primitive
311
+
312
+ async def async_get_presets(
313
+ self, device: dict[str, Any]
314
+ ) -> list[FavoriteEffectPrimitive]:
315
+ """Get preset list."""
316
+ # https://prod-us-api.arnoo.com/v35/models/LK.light.A001855/v3.0/effectModes?libraryId=aidot.preset&version=3.30.09&lightScriptFlags=0002140503140500000062
119
317
 
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,
318
+ params = (
319
+ f"/models/{device[CONF_MODEL_ID]}/v3.0/effectModes?libraryId=aidot.preset"
125
320
  )
321
+ properties = device.get(CONF_PROPERTIES, {})
322
+ light_script_flags = properties.get(CONF_LIGHT_SCRIPT_FLAGS)
323
+ if light_script_flags:
324
+ params = (
325
+ f"{params}&version={device[CONF_FIRMWARE_VERSION]}"
326
+ f"&lightScriptFlags={light_script_flags}"
327
+ )
328
+ resp = await self.async_session_get(params)
329
+ return EffectResp.from_json(resp).primitive
330
+
331
+ async def async_get_effect_mode_params(
332
+ self,
333
+ device_id: str,
334
+ primitive: FavoriteEffectPrimitive,
335
+ ) -> dict[str, Any]:
336
+ """Get effect mode params."""
337
+ primitive_effect_id = primitive.primitiveEffectId
338
+ favorite_id = primitive.favoriteId
339
+ library_id = primitive.libraryId
340
+ if favorite_id is not None:
341
+ effect_query = f"favoriteIds={favorite_id}"
342
+ effect_cache_id = favorite_id
343
+ elif primitive_effect_id is not None:
344
+ effect_query = f"primitiveEffectIds={primitive_effect_id}"
345
+ effect_cache_id = primitive_effect_id
346
+ else:
347
+ raise ValueError(
348
+ "Effect primitive must have favoriteId or primitiveEffectId"
349
+ )
350
+ if library_id is None:
351
+ raise ValueError("Effect primitive must have libraryId")
352
+
353
+ cache_key = (device_id, effect_cache_id, library_id, primitive.isOldParams)
354
+ if cache_key in self._effect_mode_params_cache:
355
+ return self._effect_mode_params_cache[cache_key]
356
+
357
+ params = (
358
+ f"/devices/{device_id}/v3.0/effectModeParams"
359
+ f"?{effect_query}"
360
+ f"&isOldParams={primitive.isOldParams}"
361
+ f"&libraryId={library_id}"
362
+ )
363
+ resp = await self.async_session_get(params)
364
+ effect_primitives = resp.get(CONF_PRIMITIVE) or []
365
+ if not effect_primitives:
366
+ raise ValueError("Effect mode params response has no primitive")
126
367
 
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()
368
+ effect_params = effect_primitives[0]
369
+ self._effect_mode_params_cache[cache_key] = effect_params
370
+ return effect_params
371
+
372
+ async def async_execute_diff_command(
373
+ self,
374
+ device_id: str,
375
+ primitive: FavoriteEffectPrimitive,
376
+ ) -> dict[str, Any]:
377
+ """Execute diff command."""
378
+ # effect_params = await self.async_get_effect_mode_params(device_id, primitive)
379
+ data = [
380
+ {
381
+ "devId": device_id,
382
+ "effectUniqueID": primitive.primitiveEffectId,
383
+ "action": "runLScript",
384
+ "in": [
385
+ {
386
+ "sessionId": random.randint(1, 2_147_483_647),
387
+ # "params": effect_params["params"],
388
+ }
389
+ ],
390
+ }
391
+ ]
392
+ return await self.async_session_post("/devices/execute/diffCommand", data)
393
+
394
+ async def async_get_all_effects(
395
+ self, device: dict[str, Any]
396
+ ) -> dict[str, FavoriteEffectPrimitive]:
397
+ """Get all effects list."""
398
+ diy_list: list[FavoriteEffectPrimitive] = []
399
+ fav_preset: list[FavoriteEffectPrimitive] = []
400
+ preset_list: list[FavoriteEffectPrimitive] = []
401
+
402
+ try:
403
+ diy_list = await self.async_get_diy_list(device)
404
+ except Exception as err:
405
+ _LOGGER.warning(
406
+ "Failed to get DIY effects for %s: %s", device[CONF_ID], err
407
+ )
408
+
409
+ try:
410
+ fav_preset = await self.async_get_fav_presets(device)
411
+ except Exception as err:
412
+ _LOGGER.warning(
413
+ "Failed to get favorite preset effects for %s: %s",
414
+ device[CONF_ID],
415
+ err,
416
+ )
417
+
418
+ try:
419
+ preset_list = await self.async_get_presets(device)
420
+ except Exception as err:
421
+ _LOGGER.warning(
422
+ "Failed to get preset effects for %s: %s", device[CONF_ID], err
423
+ )
424
+
425
+ fav_preset_ids = {
426
+ item.primitiveEffectId
427
+ for item in fav_preset
428
+ if item.primitiveEffectId is not None
429
+ }
430
+ preset_list = [
431
+ item for item in preset_list if item.primitiveEffectId not in fav_preset_ids
432
+ ]
433
+ return self._merge_effects_by_unique_name(diy_list, fav_preset, preset_list)
434
+
435
+ @staticmethod
436
+ def _merge_effects_by_unique_name(
437
+ *effect_lists: list[FavoriteEffectPrimitive],
438
+ ) -> dict[str, FavoriteEffectPrimitive]:
439
+ """Merge effect lists into a dict with unique display names."""
440
+ effects: dict[str, FavoriteEffectPrimitive] = {}
441
+ name_counts: dict[str, int] = {}
442
+
443
+ for effect_list in effect_lists:
444
+ for effect in effect_list:
445
+ name = (
446
+ effect.name
447
+ or effect.primitiveEffectId
448
+ or effect.favoriteId
449
+ or "Effect"
450
+ )
451
+ name_counts[name] = name_counts.get(name, 0) + 1
452
+ unique_name = (
453
+ name if name_counts[name] == 1 else f"{name} ({name_counts[name]})"
454
+ )
455
+ effects[unique_name] = effect
456
+
457
+ return effects
131
458
 
132
459
  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()
460
+ final_device_list: list[dict[str, Any]] = []
136
461
  try:
137
- houses = await self._cloud_api.get_houses() or []
462
+ houses = await self.async_get_houses()
138
463
  for house in houses:
139
464
  if house.get(CONF_IS_OWNER) is False:
140
465
  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])
466
+ # get device_list
467
+ device_list = await self.async_get_devices(house[CONF_ID])
468
+ if device_list:
469
+ final_device_list.extend(device_list)
470
+
471
+ # get product_list
472
+ if not final_device_list:
473
+ return {CONF_DEVICE_LIST: []}
474
+ productIds = ",".join([item[CONF_PRODUCT_ID] for item in final_device_list])
475
+ product_list = await self.async_get_products(productIds)
476
+
477
+ for product in product_list:
478
+ for device in final_device_list:
479
+ if device[CONF_PRODUCT_ID] == product[CONF_ID]:
480
+ device[CONF_PRODUCT] = product
481
+
482
+ for device in final_device_list:
483
+ if (
484
+ device[CONF_TYPE] == "light"
485
+ and CONF_AES_KEY in device
486
+ and device[CONF_AES_KEY][0] is not None
487
+ ):
488
+ device[CONF_PRESETS] = await self.async_get_all_effects(device)
160
489
 
161
490
  except Exception as e:
162
491
  raise e
163
- return filter_devices
492
+ return {CONF_DEVICE_LIST: final_device_list}
164
493
 
165
494
  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)
495
+ device_id = device.get(CONF_ID)
496
+ device_client: DeviceClient = self._device_clients.get(device_id)
169
497
  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)
498
+ device_client = DeviceClient(device, self.login_info, self)
499
+ self._device_clients[device_id] = device_client
500
+ if self._discover is not None:
501
+ ip = self._discover.discovered_device.get(device_id)
502
+ device_client.update_ip_address(ip)
175
503
  return device_client
176
504
 
177
505
  async def remove_device_client(self, dev_id: str) -> None:
178
- """Remove and close device client."""
179
506
  device_client: DeviceClient = self._device_clients.get(dev_id)
180
507
  if device_client is not None:
181
508
  await device_client.close()
182
509
  del self._device_clients[dev_id]
183
510
 
184
511
  def setup_discover(self) -> None:
185
- """Initialize device discovery after login."""
186
- if not self.user_info.id:
512
+ """初始化完成后调用,启动设备发现"""
513
+ if self.login_info.get(CONF_ID) is None:
514
+ return
515
+ if self._discover is not None:
187
516
  return
188
517
 
189
518
  _LOGGER.warning("setup_discover")
190
519
 
191
- def _discover_callback(dev_id: str, event: dict[str, str]) -> None:
520
+ def _discover_callback(dev_id, event: dict[str, str]) -> None:
192
521
  device_ip = event[CONF_IPADDRESS]
193
522
  device_client: DeviceClient = self._device_clients.get(dev_id)
194
523
  if device_client is not None:
195
524
  device_client.update_ip_address(device_ip)
196
525
 
197
- Discover.set_call_back(_discover_callback)
198
- Discover.set_user_info(self.user_info)
526
+ self._discover = Discover(self.login_info, _discover_callback)
527
+ self._discover.start_repeat_broadcast()
199
528
 
200
529
  async def async_close(self) -> None:
201
- """Close client and cleanup resources."""
530
+ """关闭客户端,清理资源"""
531
+ if self._discover is not None:
532
+ self._discover.close()
533
+ self._discover = None
202
534
  for client in self._device_clients.values():
203
535
  await client.close()
204
536
  self._device_clients.clear()
205
537
 
206
538
  async def async_cleanup(self) -> None:
207
- """Cleanup all resources."""
539
+ """清理所有资源"""
208
540
  _LOGGER.debug("async_cleanup")
209
541
  await self.async_close()