python-aidot 0.3.55__tar.gz → 0.3.57__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 (37) hide show
  1. {python_aidot-0.3.55/python_aidot.egg-info → python_aidot-0.3.57}/PKG-INFO +4 -2
  2. python_aidot-0.3.57/aidot/aes_utils.py +50 -0
  3. python_aidot-0.3.57/aidot/client.py +541 -0
  4. {python_aidot-0.3.55 → python_aidot-0.3.57}/aidot/const.py +8 -14
  5. python_aidot-0.3.57/aidot/device_client.py +469 -0
  6. python_aidot-0.3.57/aidot/discover.py +138 -0
  7. python_aidot-0.3.57/aidot/login_const.py +16 -0
  8. python_aidot-0.3.57/aidot/models/__init__.py +43 -0
  9. {python_aidot-0.3.55 → python_aidot-0.3.57}/aidot/models/device_client_model.py +88 -83
  10. python_aidot-0.3.57/aidot/models/device_model.py +140 -0
  11. {python_aidot-0.3.55 → python_aidot-0.3.57/python_aidot.egg-info}/PKG-INFO +4 -2
  12. {python_aidot-0.3.55 → python_aidot-0.3.57}/python_aidot.egg-info/SOURCES.txt +3 -8
  13. python_aidot-0.3.57/python_aidot.egg-info/requires.txt +4 -0
  14. {python_aidot-0.3.55 → python_aidot-0.3.57}/setup.cfg +1 -1
  15. {python_aidot-0.3.55 → python_aidot-0.3.57}/setup.py +25 -23
  16. python_aidot-0.3.57/tests/test_client.py +147 -0
  17. python_aidot-0.3.55/aidot/api/__init__.py +0 -0
  18. python_aidot-0.3.55/aidot/api/cloud_api.py +0 -151
  19. python_aidot-0.3.55/aidot/client.py +0 -209
  20. python_aidot-0.3.55/aidot/device_client.py +0 -478
  21. python_aidot-0.3.55/aidot/discover.py +0 -138
  22. python_aidot-0.3.55/aidot/models/__init__.py +0 -1
  23. python_aidot-0.3.55/aidot/models/auth_model.py +0 -170
  24. python_aidot-0.3.55/aidot/models/base_model.py +0 -22
  25. python_aidot-0.3.55/aidot/models/device_model.py +0 -170
  26. python_aidot-0.3.55/aidot/utils/__init__.py +0 -12
  27. python_aidot-0.3.55/aidot/utils/async_timer.py +0 -142
  28. python_aidot-0.3.55/aidot/utils/crypto.py +0 -91
  29. python_aidot-0.3.55/python_aidot.egg-info/requires.txt +0 -2
  30. python_aidot-0.3.55/tests/test_cloud_api.py +0 -143
  31. {python_aidot-0.3.55 → python_aidot-0.3.57}/LICENSE +0 -0
  32. {python_aidot-0.3.55 → python_aidot-0.3.57}/README.md +0 -0
  33. {python_aidot-0.3.55 → python_aidot-0.3.57}/aidot/__init__.py +0 -0
  34. {python_aidot-0.3.55 → python_aidot-0.3.57}/aidot/exceptions.py +0 -0
  35. {python_aidot-0.3.55 → python_aidot-0.3.57}/aidot/models/discover_model.py +0 -0
  36. {python_aidot-0.3.55 → python_aidot-0.3.57}/python_aidot.egg-info/dependency_links.txt +0 -0
  37. {python_aidot-0.3.55 → python_aidot-0.3.57}/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.55
3
+ Version: 0.3.57
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
@@ -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)
@@ -0,0 +1,541 @@
1
+ """The aidot integration."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import base64
6
+ import random
7
+ import aiohttp
8
+ from aiohttp import ClientSession
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
13
+ import uuid
14
+ from pathlib import Path
15
+ import hashlib
16
+ from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
17
+ from .device_client import DeviceClient
18
+ from .models.device_model import EffectResp, FavoriteEffectPrimitive
19
+ from .discover import Discover
20
+ from .login_const import APP_ID, PUBLIC_KEY_PEM, API_URL_TEMPLATE, DEFAULT_REGION
21
+ from .const import (
22
+ CONF_ACCESS_TOKEN,
23
+ CONF_APP_ID,
24
+ CONF_CODE,
25
+ CONF_COUNTRY,
26
+ CONF_DEVICE_LIST,
27
+ CONF_ID,
28
+ CONF_IPADDRESS,
29
+ CONF_PASSWORD,
30
+ CONF_PRODUCT,
31
+ CONF_PRODUCT_ID,
32
+ CONF_REFRESH_TOKEN,
33
+ CONF_REGION,
34
+ CONF_TERMINAL,
35
+ CONF_TOKEN,
36
+ CONF_USERNAME,
37
+ DEFAULT_COUNTRY_NAME,
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,
47
+ CONF_TYPE,
48
+ CONF_AES_KEY,
49
+ CONF_FIRMWARE_VERSION,
50
+ CONF_PRIMITIVE,
51
+ )
52
+
53
+ _LOGGER = logging.getLogger(__name__)
54
+
55
+
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
69
+
70
+
71
+ class AidotClient:
72
+ def __init__(
73
+ self,
74
+ session: Optional[ClientSession],
75
+ country_code: str | None = None,
76
+ username: str | None = None,
77
+ password: str | None = None,
78
+ token: dict | None = None,
79
+ ) -> None:
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] = {}
89
+ self._device_clients = {}
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
95
+ for item in SUPPORTED_COUNTRYS:
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)
100
+ break
101
+ if token is not None:
102
+ # ✅ 兼容性处理: v1.0.8 数据结构迁移到 v1.1.3
103
+ # 旧版本: config_entry.data[CONF_LOGIN_INFO]
104
+ # 新版本: config_entry.data
105
+ if token.get(CONF_ID) is None and token.get(CONF_LOGIN_INFO) is not None:
106
+ token = token.get(CONF_LOGIN_INFO)
107
+
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)
114
+ self.setup_discover()
115
+
116
+ def set_token_fresh_cb(self, callback) -> None:
117
+ self._token_fresh_cb = callback
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
+
125
+ async def get_terminal_id(self) -> str:
126
+ file_path = Path.home() / ".aidot_terminal_id"
127
+
128
+ def _read_or_create() -> str:
129
+ try:
130
+ if file_path.exists():
131
+ return file_path.read_text().strip()
132
+ node = uuid.getnode()
133
+ is_random = (node >> 40) & 1
134
+ raw_id = str(uuid.uuid4()) if is_random else format(node, "x")
135
+ file_path.write_text(raw_id)
136
+ return raw_id
137
+ except OSError:
138
+ return "gvz3gjae10l4zii00t7y0"
139
+
140
+ raw_id = await asyncio.to_thread(_read_or_create)
141
+ return hashlib.md5(raw_id.encode()).hexdigest()
142
+
143
+ async def async_post_login(self) -> dict[str, Any]:
144
+ """Login the user input allows us to connect."""
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
317
+
318
+ params = (
319
+ f"/models/{device[CONF_MODEL_ID]}/v3.0/effectModes?libraryId=aidot.preset"
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")
367
+
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
458
+
459
+ async def async_get_all_device(self) -> dict[str, Any]:
460
+ final_device_list: list[dict[str, Any]] = []
461
+ try:
462
+ houses = await self.async_get_houses()
463
+ for house in houses:
464
+ if house.get(CONF_IS_OWNER) is False:
465
+ continue
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)
489
+
490
+ except Exception as e:
491
+ raise e
492
+ return {CONF_DEVICE_LIST: final_device_list}
493
+
494
+ def get_device_client(self, device: dict[str, Any]) -> DeviceClient:
495
+ device_id = device.get(CONF_ID)
496
+ device_client: DeviceClient = self._device_clients.get(device_id)
497
+ if device_client is None:
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)
503
+ return device_client
504
+
505
+ async def remove_device_client(self, dev_id: str) -> None:
506
+ device_client: DeviceClient = self._device_clients.get(dev_id)
507
+ if device_client is not None:
508
+ await device_client.close()
509
+ del self._device_clients[dev_id]
510
+
511
+ def setup_discover(self) -> None:
512
+ """初始化完成后调用,启动设备发现"""
513
+ if self.login_info.get(CONF_ID) is None:
514
+ return
515
+ if self._discover is not None:
516
+ return
517
+
518
+ _LOGGER.warning("setup_discover")
519
+
520
+ def _discover_callback(dev_id, event: dict[str, str]) -> None:
521
+ device_ip = event[CONF_IPADDRESS]
522
+ device_client: DeviceClient = self._device_clients.get(dev_id)
523
+ if device_client is not None:
524
+ device_client.update_ip_address(device_ip)
525
+
526
+ self._discover = Discover(self.login_info, _discover_callback)
527
+ self._discover.start_repeat_broadcast()
528
+
529
+ async def async_close(self) -> None:
530
+ """关闭客户端,清理资源"""
531
+ if self._discover is not None:
532
+ self._discover.close()
533
+ self._discover = None
534
+ for client in self._device_clients.values():
535
+ await client.close()
536
+ self._device_clients.clear()
537
+
538
+ async def async_cleanup(self) -> None:
539
+ """清理所有资源"""
540
+ _LOGGER.debug("async_cleanup")
541
+ await self.async_close()
@@ -192,6 +192,7 @@ CONF_LOGIN_INFO = "loginInfo"
192
192
  CONF_AES_KEY = "aesKey"
193
193
  CONF_MODEL_ID = "modelId"
194
194
  CONF_HARDWARE_VERSION = "hardwareVersion"
195
+ CONF_FIRMWARE_VERSION = "firmwareVersion"
195
196
  CONF_SERVICE_MODULES = "serviceModules"
196
197
  CONF_IDENTITY = "identity"
197
198
  CONF_PROPERTIES = "properties"
@@ -206,10 +207,17 @@ CONF_ON_OFF = "OnOff"
206
207
  CONF_DIMMING = "Dimming"
207
208
  CONF_RGBW = "RGBW"
208
209
  CONF_CCT = "CCT"
210
+ CONF_EFFECT_MODE = "EffectMode"
209
211
  CONF_ACK = "ack"
210
212
  CONF_IS_OWNER = "isOwner"
211
213
  CONF_GET_DEV_ATTR_REQ = "getDevAttrReq"
212
214
  CONF_SET_DEV_ATTR_REQ = "setDevAttrReq"
215
+ CONF_DIYS = "diys"
216
+ CONF_PRESETS = "presets"
217
+ CONF_FAV_PRESETS = "favoritePresets"
218
+ CONF_VERSION = "version"
219
+ CONF_LIGHT_SCRIPT_FLAGS = "lightScriptFlags"
220
+ CONF_PRIMITIVE = "primitive"
213
221
 
214
222
 
215
223
  class Identity(StrEnum):
@@ -230,17 +238,3 @@ class ServerErrorCode(IntEnum):
230
238
  TOKEN_EXPIRED = 21026
231
239
  LOGIN_INVALID = 21025
232
240
  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
- """