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