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 +50 -0
- aidot/client.py +433 -101
- aidot/const.py +8 -14
- aidot/device_client.py +355 -364
- aidot/discover.py +68 -68
- aidot/login_const.py +16 -0
- aidot/models/__init__.py +42 -0
- aidot/models/device_client_model.py +88 -83
- aidot/models/device_model.py +93 -123
- {python_aidot-0.3.55.dist-info → python_aidot-0.3.58.dist-info}/METADATA +4 -2
- python_aidot-0.3.58.dist-info/RECORD +17 -0
- {python_aidot-0.3.55.dist-info → python_aidot-0.3.58.dist-info}/WHEEL +1 -1
- aidot/api/__init__.py +0 -0
- aidot/api/cloud_api.py +0 -151
- aidot/models/auth_model.py +0 -170
- aidot/models/base_model.py +0 -22
- aidot/utils/__init__.py +0 -12
- aidot/utils/async_timer.py +0 -142
- aidot/utils/crypto.py +0 -91
- python_aidot-0.3.55.dist-info/RECORD +0 -22
- {python_aidot-0.3.55.dist-info → python_aidot-0.3.58.dist-info}/licenses/LICENSE +0 -0
- {python_aidot-0.3.55.dist-info → python_aidot-0.3.58.dist-info}/top_level.txt +0 -0
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.
|
|
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 .
|
|
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
|
-
|
|
22
|
-
CONF_DEVICE_LIST,
|
|
29
|
+
CONF_PASSWORD,
|
|
23
30
|
CONF_PRODUCT,
|
|
24
31
|
CONF_PRODUCT_ID,
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
"""
|
|
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.
|
|
51
|
-
self.
|
|
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.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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[
|
|
60
|
-
self.
|
|
61
|
-
self.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
self.
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
device[
|
|
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
|
|
492
|
+
return {CONF_DEVICE_LIST: final_device_list}
|
|
164
493
|
|
|
165
494
|
def get_device_client(self, device: dict[str, Any]) -> DeviceClient:
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
171
|
-
self._device_clients[
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
"""
|
|
186
|
-
if
|
|
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
|
|
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.
|
|
198
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
539
|
+
"""清理所有资源"""
|
|
208
540
|
_LOGGER.debug("async_cleanup")
|
|
209
541
|
await self.async_close()
|