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