python-aidot 0.3.2__tar.gz → 0.3.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: python-aidot
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -2,7 +2,8 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
2
2
  from cryptography.hazmat.backends import default_backend
3
3
  from cryptography.hazmat.primitives import padding
4
4
 
5
- def aes_encrypt(plaintext, key):
5
+
6
+ def aes_encrypt(plaintext, key):
6
7
  padder = padding.PKCS7(algorithms.AES.block_size).padder()
7
8
  padded_data = padder.update(plaintext) + padder.finalize()
8
9
 
@@ -13,6 +14,7 @@ def aes_encrypt(plaintext, key):
13
14
 
14
15
  return ciphertext
15
16
 
17
+
16
18
  def aes_decrypt(ciphertext, key):
17
19
  cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
18
20
  decryptor = cipher.decryptor()
@@ -22,4 +24,4 @@ def aes_decrypt(ciphertext, key):
22
24
  unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
23
25
  plaintext = unpadder.update(decrypted_data) + unpadder.finalize()
24
26
 
25
- return plaintext.decode()
27
+ return plaintext.decode()
@@ -1,34 +1,39 @@
1
1
  """The aidot integration."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
- from typing import Any, Optional
5
- from aiohttp import ClientSession
6
5
  import base64
7
6
  import aiohttp
7
+ from aiohttp import ClientSession
8
+ from typing import Any, Optional
8
9
  from cryptography.hazmat.backends import default_backend
9
10
  from cryptography.hazmat.primitives import serialization
10
11
  from cryptography.hazmat.primitives.asymmetric import padding
12
+
13
+ from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
14
+ from .device_client import DeviceClient
15
+ from .discover import Discover
11
16
  from .login_const import APP_ID, PUBLIC_KEY_PEM, BASE_URL
12
17
  from .const import (
13
- SUPPORTED_COUNTRYS,
14
- DEFAULT_COUNTRY_NAME,
15
- CONF_PRODUCT_ID,
16
- CONF_ID,
17
- CONF_PRODUCT,
18
18
  CONF_ACCESS_TOKEN,
19
- CONF_REFRESH_TOKEN,
20
- CONF_TERMINAL,
21
19
  CONF_APP_ID,
22
- CONF_REGION,
20
+ CONF_CODE,
23
21
  CONF_COUNTRY,
24
- CONF_USERNAME,
22
+ CONF_DEVICE_LIST,
23
+ CONF_ID,
24
+ CONF_IPADDRESS,
25
25
  CONF_PASSWORD,
26
- CONF_CODE,
26
+ CONF_PRODUCT,
27
+ CONF_PRODUCT_ID,
28
+ CONF_REFRESH_TOKEN,
29
+ CONF_REGION,
30
+ CONF_TERMINAL,
27
31
  CONF_TOKEN,
28
- CONF_DEVICE_LIST,
29
- ServerErrorCode
32
+ CONF_USERNAME,
33
+ DEFAULT_COUNTRY_NAME,
34
+ SUPPORTED_COUNTRYS,
35
+ ServerErrorCode,
30
36
  )
31
- from .exceptions import AidotAuthFailed,AidotUserOrPassIncorrect
32
37
 
33
38
  _LOGGER = logging.getLogger(__name__)
34
39
 
@@ -56,6 +61,8 @@ class AidotClient:
56
61
  password: str = ""
57
62
  country_name: str = DEFAULT_COUNTRY_NAME
58
63
  login_info: dict[str, Any] = {}
64
+ _device_clients: dict[str:DeviceClient]
65
+ _discover: Discover = None
59
66
 
60
67
  def __init__(
61
68
  self,
@@ -69,13 +76,14 @@ class AidotClient:
69
76
  self.country_name = country_name
70
77
  self.username = username
71
78
  self.password = password
72
- self.login_info = token
79
+ self._device_clients = {}
73
80
  for item in SUPPORTED_COUNTRYS:
74
81
  if item["name"] == self.country_name:
75
82
  self._region = item["region"].lower()
76
83
  self._base_url = f"https://prod-{self._region}-api.arnoo.com/v17"
77
84
  break
78
85
  if token is not None:
86
+ self.login_info = token.copy()
79
87
  self.username = token[CONF_USERNAME]
80
88
  self.password = token[CONF_PASSWORD]
81
89
  self._region = token[CONF_REGION]
@@ -86,7 +94,7 @@ class AidotClient:
86
94
 
87
95
  def get_identifier(self) -> str:
88
96
  return f"{self._region}-{self.username}"
89
-
97
+
90
98
  def update_password(self, password: str):
91
99
  self.password = password
92
100
 
@@ -154,6 +162,7 @@ class AidotClient:
154
162
  CONF_TOKEN: token,
155
163
  CONF_APP_ID: APP_ID,
156
164
  }
165
+ response_data = {}
157
166
  try:
158
167
  response = await self.session.get(url, headers=headers)
159
168
  response_data = await response.json()
@@ -161,17 +170,19 @@ class AidotClient:
161
170
  return response_data
162
171
  except aiohttp.ClientError as e:
163
172
  _LOGGER.info(f"async_get ClientError {e}")
164
- code = response_data[CONF_CODE]
173
+ code = response_data.get(CONF_CODE)
165
174
  if code == ServerErrorCode.TOKEN_EXPIRED:
166
175
  try:
167
176
  await self.async_refresh_token()
168
177
  return await self.async_session_get(params)
169
178
  except AidotAuthFailed:
170
179
  raise AidotAuthFailed
171
- elif code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041:
180
+ elif (
181
+ code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041
182
+ ):
172
183
  self.login_info[CONF_ACCESS_TOKEN] = None
173
184
  raise AidotAuthFailed
174
- return None
185
+ return aiohttp.ClientError
175
186
 
176
187
  async def async_get_products(self, product_ids: str):
177
188
  """Get device list."""
@@ -208,4 +219,39 @@ class AidotClient:
208
219
  device[CONF_PRODUCT] = product
209
220
  except Exception as e:
210
221
  raise e
211
- return {CONF_DEVICE_LIST:final_device_list}
222
+ return {CONF_DEVICE_LIST: final_device_list}
223
+
224
+ def get_device_client(self, device: dict[str:Any]) -> DeviceClient:
225
+ device_id = device.get(CONF_ID)
226
+ device_client: DeviceClient = self._device_clients.get(device_id)
227
+ if device_client is None:
228
+ device_client = DeviceClient(device, self.login_info)
229
+ self._device_clients[device_id] = device_client
230
+ asyncio.get_running_loop().create_task(device_client.ping_task())
231
+ if self._discover is not None:
232
+ ip = self._discover.discovered_device.get(device_id)
233
+ device_client.update_ip_address(ip)
234
+ return device_client
235
+
236
+ def start_discover(self) -> None:
237
+ if self._discover is not None:
238
+ return
239
+
240
+ def _discover_callback(dev_id, event: dict[str, str]) -> None:
241
+ device_ip = event[CONF_IPADDRESS]
242
+ device_client: DeviceClient = self._device_clients.get(dev_id)
243
+ if device_client is not None:
244
+ device_client.update_ip_address(device_ip)
245
+
246
+ self._discover = Discover(self.login_info, _discover_callback)
247
+ asyncio.get_running_loop().create_task(self._discover.repeat_broadcast())
248
+
249
+ def stop_discover(self) -> None:
250
+ self._discover.close()
251
+ self._discover = None
252
+
253
+ def cleanup(self) -> None:
254
+ self.stop_discover()
255
+ for client in self._device_clients.values():
256
+ asyncio.get_running_loop().create_task(client.close())
257
+ self._device_clients.clear()
@@ -1,4 +1,5 @@
1
- from enum import StrEnum,IntEnum
1
+ from enum import StrEnum, IntEnum
2
+
2
3
  SUPPORTED_COUNTRYS = [
3
4
  {"_id": "1-0", "id": "AL", "name": "Albania", "ext": "", "region": "EU"},
4
5
  {
@@ -195,17 +196,30 @@ CONF_MINVALUE = "minValue"
195
196
  CONF_MAXVALUE = "maxValue"
196
197
  CONF_IPADDRESS = "ipAddress"
197
198
  CONF_CODE = "code"
199
+ CONF_PAYLOAD = "payload"
200
+ CONF_ASCNUMBER = "ascNumber"
201
+ CONF_ATTR = "attr"
202
+ CONF_ON_OFF = "OnOff"
203
+ CONF_DIMMING = "Dimming"
204
+ CONF_RGBW = "RGBW"
205
+ CONF_CCT = "CCT"
206
+
207
+
198
208
  class Identity(StrEnum):
199
209
  """Available entity identity."""
210
+
200
211
  RGBW = "control.light.rgbw"
201
212
  CCT = "control.light.cct"
202
213
 
214
+
203
215
  class Attribute(StrEnum):
204
216
  """Available entity attributes."""
217
+
205
218
  RGBW = "rgbw"
206
219
  CCT = "cct"
207
220
 
221
+
208
222
  class ServerErrorCode(IntEnum):
209
223
  TOKEN_EXPIRED = 21026
210
224
  LOGIN_INVALID = 21025
211
- USER_PWD_INCORRECT = 560080
225
+ USER_PWD_INCORRECT = 560080
@@ -0,0 +1,372 @@
1
+ """The aidot integration."""
2
+
3
+ import ctypes
4
+ import socket
5
+ import struct
6
+ import time
7
+ import json
8
+ import asyncio
9
+ import logging
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ from .exceptions import AidotNotLogin
14
+ from .aes_utils import aes_encrypt, aes_decrypt
15
+ from .const import (
16
+ CONF_AES_KEY,
17
+ CONF_ASCNUMBER,
18
+ CONF_ATTR,
19
+ CONF_CCT,
20
+ CONF_HARDWARE_VERSION,
21
+ CONF_ID,
22
+ CONF_IDENTITY,
23
+ CONF_MAC,
24
+ CONF_MAXVALUE,
25
+ CONF_MINVALUE,
26
+ CONF_MODEL_ID,
27
+ CONF_NAME,
28
+ CONF_ON_OFF,
29
+ CONF_DIMMING,
30
+ CONF_PASSWORD,
31
+ CONF_PAYLOAD,
32
+ CONF_PRODUCT,
33
+ CONF_PROPERTIES,
34
+ CONF_RGBW,
35
+ CONF_SERVICE_MODULES,
36
+ Identity,
37
+ )
38
+
39
+ _LOGGER = logging.getLogger(__name__)
40
+
41
+
42
+ class DeviceStatusData:
43
+ online: bool = False
44
+ on: bool = False
45
+ rgdb: int = None
46
+ rgbw: tuple[int, int, int, int] = None
47
+ cct: int = None
48
+ dimming: int = None
49
+
50
+ def update(self, attr: dict[str, Any]) -> None:
51
+ if attr is None:
52
+ return
53
+ if attr.get(CONF_ON_OFF) is not None:
54
+ self.on = attr.get(CONF_ON_OFF)
55
+ if attr.get(CONF_DIMMING) is not None:
56
+ self.dimming = int(attr.get(CONF_DIMMING) * 255 / 100)
57
+ if attr.get(CONF_RGBW) is not None:
58
+ self.rgdb = attr.get(CONF_RGBW)
59
+ rgbw = ctypes.c_uint32(self.rgdb).value
60
+ r = (rgbw >> 24) & 0xFF
61
+ g = (rgbw >> 16) & 0xFF
62
+ b = (rgbw >> 8) & 0xFF
63
+ w = rgbw & 0xFF
64
+ self.rgbw = (r, g, b, w)
65
+ if attr.get(CONF_CCT) is not None:
66
+ self.cct = attr.get(CONF_CCT)
67
+
68
+
69
+ class DeviceInformation:
70
+ enable_rgbw: bool = False
71
+ enable_dimming: bool = True
72
+ enable_cct: bool = False
73
+ cct_min: int
74
+ cct_max: int
75
+ dev_id: str
76
+ mac: str
77
+ model_id: str
78
+ name: str
79
+ hw_version: str
80
+
81
+ def __init__(self, device: dict[str:Any]):
82
+ self.dev_id = device.get(CONF_ID)
83
+ self.mac = device.get(CONF_MAC) if device.get(CONF_MAC) is not None else ""
84
+ self.model_id = device.get(CONF_MODEL_ID)
85
+ self.name = device.get(CONF_NAME)
86
+ self.hw_version = device.get(CONF_HARDWARE_VERSION)
87
+ if CONF_PRODUCT in device and CONF_SERVICE_MODULES in device[CONF_PRODUCT]:
88
+ for service in device[CONF_PRODUCT][CONF_SERVICE_MODULES]:
89
+ if service[CONF_IDENTITY] == Identity.RGBW:
90
+ self.enable_rgbw = True
91
+ self.enable_cct = True
92
+ elif service[CONF_IDENTITY] == Identity.CCT:
93
+ self.cct_min = int(service[CONF_PROPERTIES][0][CONF_MINVALUE])
94
+ self.cct_max = int(service[CONF_PROPERTIES][0][CONF_MAXVALUE])
95
+ self.enable_cct = True
96
+
97
+
98
+ class DeviceClient(object):
99
+ status: DeviceStatusData
100
+ info: DeviceInformation
101
+ _login_uuid = 0
102
+ _connect_and_login: bool = False
103
+ _connecting: bool = False
104
+ _simpleVersion: str = ""
105
+ _ip_address: str
106
+ device_id: str
107
+ _is_close: bool = False
108
+
109
+ @property
110
+ def connect_and_login(self) -> bool:
111
+ return self._connect_and_login
112
+
113
+ @property
114
+ def connecting(self) -> bool:
115
+ return self._connecting
116
+
117
+ def __init__(self, device: dict, user_info: dict) -> None:
118
+ self.ping_count = 0
119
+ self.status = DeviceStatusData()
120
+ self.info = DeviceInformation(device)
121
+ self.user_id = user_info.get(CONF_ID)
122
+
123
+ if CONF_AES_KEY in device:
124
+ key_string = device[CONF_AES_KEY][0]
125
+ if key_string is not None:
126
+ self.aes_key = bytearray(16)
127
+ key_bytes = key_string.encode()
128
+ self.aes_key[: len(key_bytes)] = key_bytes
129
+
130
+ self.password = device.get(CONF_PASSWORD)
131
+ self.device_id = device.get(CONF_ID)
132
+ self._simpleVersion = device.get("simpleVersion")
133
+
134
+ async def connect(self, ip_address):
135
+ self.reader = self.writer = None
136
+ self._connecting = True
137
+ try:
138
+ self.reader, self.writer = await asyncio.open_connection(ip_address, 10000)
139
+ sock: socket.socket = self.writer.get_extra_info("socket")
140
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
141
+ self.seq_num = 1
142
+ await self.login()
143
+ self._connect_and_login = True
144
+ except Exception as e:
145
+ self._connect_and_login = False
146
+ finally:
147
+ self._connecting = False
148
+
149
+ def update_ip_address(self, ip: str) -> None:
150
+ self._ip_address = ip
151
+
152
+ async def async_login(self) -> None:
153
+ if self._ip_address is None:
154
+ return
155
+ if self._connecting is not True and self._connect_and_login is not True:
156
+ await self.connect(self._ip_address)
157
+
158
+ def getSendPacket(self, message, msgtype):
159
+ magic = struct.pack(">H", 0x1EED)
160
+ _msgtype = struct.pack(">h", msgtype)
161
+
162
+ if self.aes_key is not None:
163
+ send_data = aes_encrypt(message, self.aes_key)
164
+ else:
165
+ send_data = message
166
+
167
+ bodysize = struct.pack(">i", len(send_data))
168
+ packet = magic + _msgtype + bodysize + send_data
169
+
170
+ return packet
171
+
172
+ async def login(self):
173
+ login_seq = str(int(time.time() * 1000) + self._login_uuid)[-9:]
174
+ self._login_uuid += 1
175
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
176
+ message = {
177
+ "service": "device",
178
+ "method": "loginReq",
179
+ "seq": login_seq,
180
+ "srcAddr": self.user_id,
181
+ "deviceId": self.device_id,
182
+ "payload": {
183
+ "userId": self.user_id,
184
+ "password": self.password,
185
+ "timestamp": timestamp,
186
+ "ascNumber": 1,
187
+ },
188
+ }
189
+ try:
190
+ self.writer.write(self.getSendPacket(json.dumps(message).encode(), 1))
191
+ await self.writer.drain()
192
+ data = await self.reader.read(1024)
193
+ except (BrokenPipeError, ConnectionResetError) as e:
194
+ _LOGGER.error(f"{self.device_id} login read status error {e}")
195
+ except Exception as e:
196
+ _LOGGER.error(f"recv data error {e}")
197
+
198
+ data_len = len(data)
199
+ if data_len <= 0:
200
+ return
201
+
202
+ magic, msgtype, bodysize = struct.unpack(">HHI", data[:8])
203
+ encrypted_data = data[8:]
204
+ if self.aes_key is not None:
205
+ decrypted_data = aes_decrypt(encrypted_data, self.aes_key)
206
+ else:
207
+ decrypted_data = encrypted_data
208
+
209
+ json_data = json.loads(decrypted_data)
210
+
211
+ self.ascNumber = json_data[CONF_PAYLOAD][CONF_ASCNUMBER]
212
+ self.ascNumber += 1
213
+ self.status.online = True
214
+ await self.send_action({}, "getDevAttrReq")
215
+
216
+ async def read_status(self):
217
+ if self._connect_and_login is False:
218
+ await asyncio.sleep(2)
219
+ raise AidotNotLogin
220
+ try:
221
+ data = await self.reader.read(1024)
222
+ except (BrokenPipeError, ConnectionResetError) as e:
223
+ _LOGGER.error(f"{self.device_id} read status error {e}")
224
+ await self.reset()
225
+ self.status.online = False
226
+ return self.status
227
+ except Exception as e:
228
+ _LOGGER.error(f"recv data error {e}")
229
+ return self.status
230
+ data_len = len(data)
231
+ if data_len <= 0:
232
+ _LOGGER.error("recv data error len")
233
+ await self.reset()
234
+ self.status.online = False
235
+ return self.status
236
+ try:
237
+ magic, msgtype, bodysize = struct.unpack(">HHI", data[:8])
238
+ decrypted_data = aes_decrypt(data[8:], self.aes_key)
239
+ json_data = json.loads(decrypted_data)
240
+ except Exception as e:
241
+ _LOGGER.error(f"recv json error : {e}")
242
+ return await self.read_status()
243
+
244
+ if "service" in json_data:
245
+ if "test" == json_data["service"]:
246
+ self.ping_count = 0
247
+ return await self.read_status()
248
+ payload = json_data.get(CONF_PAYLOAD)
249
+ if payload is not None:
250
+ self.ascNumber = payload.get(CONF_ASCNUMBER)
251
+ self.status.update(payload.get(CONF_ATTR))
252
+ return self.status
253
+
254
+ async def ping_task(self):
255
+ while True:
256
+ if self._is_close:
257
+ return
258
+ await asyncio.sleep(5)
259
+ await self.send_ping_action()
260
+ await asyncio.sleep(5)
261
+
262
+ async def send_dev_attr(self, dev_attr):
263
+ await self.send_action(dev_attr, "setDevAttrReq")
264
+
265
+ async def async_turn_off(self) -> None:
266
+ await self.send_dev_attr({CONF_ON_OFF: 0})
267
+
268
+ async def async_turn_on(self) -> None:
269
+ await self.send_dev_attr({CONF_ON_OFF: 1})
270
+
271
+ async def async_set_brightness(self, brightness: int) -> None:
272
+ final_dimming = int(brightness * 100 / 255)
273
+ await self.send_dev_attr({CONF_DIMMING: final_dimming})
274
+
275
+ async def async_set_rgbw(self, rgbw: tuple[int, int, int, int]) -> None:
276
+ final_rgbw = (rgbw[0] << 24) | (rgbw[1] << 16) | (rgbw[2] << 8) | rgbw[3]
277
+ await self.send_dev_attr({CONF_RGBW: ctypes.c_int32(final_rgbw).value})
278
+
279
+ async def async_set_cct(self, cct: int) -> None:
280
+ await self.send_dev_attr({CONF_CCT: cct})
281
+
282
+ async def send_action(self, attr, method):
283
+ current_timestamp_milliseconds = int(time.time() * 1000)
284
+ self.seq_num += 1
285
+ seq = "ha93" + str(self.seq_num).zfill(5)
286
+ if not self.status.on and not CONF_ON_OFF in attr:
287
+ self.status.on = True
288
+ attr[CONF_ON_OFF] = 1
289
+
290
+ if self._simpleVersion is not None:
291
+ action = {
292
+ "method": method,
293
+ "service": "device",
294
+ "clientId": "ha-" + self.user_id,
295
+ "srcAddr": "0." + self.user_id,
296
+ "seq": "" + seq,
297
+ "payload": {
298
+ "devId": self.device_id,
299
+ "parentId": self.device_id,
300
+ "userId": self.user_id,
301
+ "password": self.password,
302
+ "attr": attr,
303
+ "channel": "tcp",
304
+ "ascNumber": self.ascNumber,
305
+ },
306
+ "tst": current_timestamp_milliseconds,
307
+ "deviceId": self.device_id,
308
+ }
309
+ else:
310
+ action = {
311
+ "method": method,
312
+ "service": "device",
313
+ "seq": "" + seq,
314
+ "srcAddr": "0." + self.user_id,
315
+ "payload": {
316
+ "attr": attr,
317
+ "ascNumber": self.ascNumber,
318
+ },
319
+ "tst": current_timestamp_milliseconds,
320
+ "deviceId": self.device_id,
321
+ }
322
+
323
+ try:
324
+ self.writer.write(self.getSendPacket(json.dumps(action).encode(), 1))
325
+ await self.writer.drain()
326
+ except (BrokenPipeError, ConnectionResetError) as e:
327
+ _LOGGER.error(f"{self.device_id} send action error {e}")
328
+ await self.reset()
329
+ except Exception as e:
330
+ _LOGGER.error(f"{self.device_id} send action error {e}")
331
+
332
+ async def send_ping_action(self):
333
+ ping = {
334
+ "service": "test",
335
+ "method": "pingreq",
336
+ "seq": "123456",
337
+ "srcAddr": "x.xxxxxxx",
338
+ CONF_PAYLOAD: {},
339
+ }
340
+ try:
341
+ if self.ping_count >= 2:
342
+ _LOGGER.error(
343
+ f"Last ping did not return within 20 seconds. device id:{self.device_id}"
344
+ )
345
+ await self.reset()
346
+ return -1
347
+ if self._connect_and_login is False:
348
+ return -1
349
+ self.writer.write(self.getSendPacket(json.dumps(ping).encode(), 2))
350
+ await self.writer.drain()
351
+ self.ping_count += 1
352
+ return 1
353
+ except Exception as e:
354
+ _LOGGER.error(f"{self.device_id} ping error {e}")
355
+ await self.reset()
356
+ return -1
357
+
358
+ async def reset(self):
359
+ try:
360
+ if self.writer:
361
+ self.writer.close()
362
+ await self.writer.wait_closed()
363
+ except Exception as e:
364
+ _LOGGER.error(f"{self.device_id} writer close error {e}")
365
+ self._connect_and_login = False
366
+ self.status.online = False
367
+ self.ping_count = 0
368
+
369
+ async def close(self):
370
+ self._is_close = True
371
+ await self.reset()
372
+ _LOGGER.info(f"{self.device_id} connect close by user")
@@ -2,22 +2,24 @@ import socket
2
2
  import json
3
3
  import time
4
4
  import logging
5
+ import asyncio
5
6
  from typing import Any
6
7
 
7
- from .aes_utils import aes_encrypt,aes_decrypt
8
- import asyncio
9
- from .const import CONF_ID,CONF_IPADDRESS
8
+ from .aes_utils import aes_encrypt, aes_decrypt
9
+ from .const import CONF_ID, CONF_IPADDRESS
10
10
  from .exceptions import AidotOSError
11
11
 
12
12
  _LOGGER = logging.getLogger(__name__)
13
+ _DISCOVER_TIME = 5
13
14
 
14
15
  class BroadcastProtocol:
15
16
  _is_closed = False
16
- def __init__(self,callback,user_id):
17
+
18
+ def __init__(self, callback, user_id):
17
19
  self.aes_key = bytearray(32)
18
20
  key_string = "T54uednca587"
19
- key_bytes = key_string.encode()
20
- self.aes_key[:len(key_bytes)] = key_bytes
21
+ key_bytes = key_string.encode()
22
+ self.aes_key[: len(key_bytes)] = key_bytes
21
23
 
22
24
  self._discover_cb = callback
23
25
  self.user_id = user_id
@@ -34,43 +36,42 @@ class BroadcastProtocol:
34
36
  current_timestamp_milliseconds = int(time.time() * 1000)
35
37
  seq = str(current_timestamp_milliseconds + 1)[-9:]
36
38
  message = {
37
- "protocolVer":"2.0.0",
38
- "service":"device",
39
- "method":"devDiscoveryReq",
39
+ "protocolVer": "2.0.0",
40
+ "service": "device",
41
+ "method": "devDiscoveryReq",
40
42
  "seq": seq,
41
- "srcAddr":f"0.{self.user_id}]",
42
- "tst":current_timestamp_milliseconds,
43
- "payload":{
44
- "extends":{ },
45
- "localCtrFlag":1,
46
- "timestamp":str(current_timestamp_milliseconds)
47
- }
43
+ "srcAddr": f"0.{self.user_id}]",
44
+ "tst": current_timestamp_milliseconds,
45
+ "payload": {
46
+ "extends": {},
47
+ "localCtrFlag": 1,
48
+ "timestamp": str(current_timestamp_milliseconds),
49
+ },
48
50
  }
49
- send_data = aes_encrypt(json.dumps(message).encode(),self.aes_key)
51
+ send_data = aes_encrypt(json.dumps(message).encode(), self.aes_key)
50
52
  try:
51
- self.transport.sendto(send_data, ('255.255.255.255', 6666))
53
+ self.transport.sendto(send_data, ("255.255.255.255", 6666))
52
54
  except Exception as error:
53
55
  _LOGGER.error(f"{self.user_id}:Connection lost due to error: {error}")
54
56
 
55
57
  def datagram_received(self, data, addr):
56
- data_str = aes_decrypt(data,self.aes_key)
58
+ data_str = aes_decrypt(data, self.aes_key)
57
59
  data_json = json.loads(data_str)
58
- if("payload" in data_json):
59
- if("mac" in data_json["payload"]):
60
+ if "payload" in data_json:
61
+ if "mac" in data_json["payload"]:
60
62
  devId = data_json["payload"]["devId"]
61
63
  if self._discover_cb:
62
- self._discover_cb(devId,{CONF_IPADDRESS : addr[0]})
64
+ self._discover_cb(devId, {CONF_IPADDRESS: addr[0]})
63
65
 
64
66
  def error_received(self, exc):
65
67
  _LOGGER.error(f"{self.user_id}:Error occurred: {exc}")
66
-
68
+
67
69
  def close(self) -> None:
68
70
  try:
69
71
  self.transport.close()
70
72
  except Exception as error:
71
73
  _LOGGER.error(f"Connection lost due to error: {error}")
72
-
73
-
74
+
74
75
  def connection_lost(self, exc):
75
76
  self._is_closed = True
76
77
  if exc:
@@ -78,33 +79,60 @@ class BroadcastProtocol:
78
79
  else:
79
80
  _LOGGER.info("{self.user_id}:Connection closed.")
80
81
 
82
+
81
83
  class Discover:
82
- _login_info: dict[str: Any] = None
84
+ _login_info: dict[str:Any] = None
83
85
  _broadcast_protocol: BroadcastProtocol = None
84
- _discovered_device: dict[str: str]
85
- def __init__(self,login_info):
86
- self._discovered_device = {}
86
+ discovered_device: dict[str:str]
87
+ _is_close: bool = False
88
+
89
+ def __init__(self, login_info, callback):
90
+ self.discovered_device = {}
87
91
  self._login_info = login_info
92
+ self._callback = callback
88
93
 
89
- async def fetch_devices_info(self) -> dict[str: str]:
94
+ async def try_create_broadcast(self) -> None:
90
95
  if self._broadcast_protocol is None:
91
- self._broadcast_protocol = BroadcastProtocol(self._discover_callback,self._login_info[CONF_ID])
96
+ self._broadcast_protocol = BroadcastProtocol(
97
+ self._discover_callback, self._login_info[CONF_ID]
98
+ )
92
99
  try:
93
- transport, protocol = await asyncio.get_event_loop().create_datagram_endpoint(
100
+ (
101
+ transport,
102
+ protocol,
103
+ ) = await asyncio.get_event_loop().create_datagram_endpoint(
94
104
  lambda: self._broadcast_protocol,
95
105
  local_addr=("0.0.0.0", 0),
96
106
  )
97
107
  except OSError:
98
108
  raise AidotOSError
99
109
 
110
+ async def send_broadcast(self) -> None:
111
+ await self.try_create_broadcast()
112
+ self._broadcast_protocol.send_broadcast()
113
+
114
+ async def repeat_broadcast(self) -> None:
115
+ self._is_close = False
116
+ while True:
117
+ await self.send_broadcast()
118
+ for i in range(_DISCOVER_TIME):
119
+ await asyncio.sleep(1) # 每秒检查一次是否需要取消任务
120
+ if self._is_close is True:
121
+ return
122
+
123
+ async def fetch_devices_info(self) -> dict[str:str]:
124
+ self.try_create_broadcast()
100
125
  self._broadcast_protocol.send_broadcast()
101
126
  await asyncio.sleep(2)
102
- return self._discovered_device
127
+ return self.discovered_device
103
128
 
104
129
  def _discover_callback(self, dev_id, event: dict[str, str]) -> None:
105
- self._discovered_device[dev_id] = event[CONF_IPADDRESS]
106
-
130
+ self.discovered_device[dev_id] = event[CONF_IPADDRESS]
131
+ if self._callback:
132
+ self._callback(dev_id, event)
133
+
107
134
  def close(self) -> None:
135
+ self._is_close = True
108
136
  if self._broadcast_protocol is not None:
109
137
  self._broadcast_protocol.close()
110
- self._broadcast_protocol.send_broadcast()
138
+ self._broadcast_protocol = None
@@ -1,24 +1,37 @@
1
1
  """aidot Exceptions."""
2
+
3
+
2
4
  class AidotError(Exception):
3
5
  """Aidot api exception."""
4
6
 
7
+
5
8
  class InvalidURL(AidotError):
6
9
  """Invalid url exception."""
7
10
 
11
+
8
12
  class HTTPError(AidotError):
9
13
  """Invalid host exception."""
10
14
 
15
+
11
16
  class InvalidHost(AidotError):
12
17
  """Invalid host exception."""
13
18
 
19
+
14
20
  class AidotAuthTokenExpired(AidotError):
15
21
  """Authentication failed because token is invalid or expired."""
16
22
 
23
+
17
24
  class AidotAuthFailed(AidotError):
18
25
  """Authentication failed"""
19
26
 
27
+
28
+ class AidotNotLogin(AidotError):
29
+ """Aidot not login"""
30
+
31
+
20
32
  class AidotUserOrPassIncorrect(AidotError):
21
33
  """Authentication failed"""
22
34
 
35
+
23
36
  class AidotOSError(Exception):
24
37
  """Aidot exception."""
@@ -1,6 +1,5 @@
1
1
  """Constants for the aidot integration."""
2
2
 
3
-
4
3
  APP_ID = "1383974540041977857"
5
4
  BASE_URL = "https://prod-us-api.arnoo.com/v17"
6
5
 
@@ -11,4 +10,4 @@ p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
11
10
  c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
12
11
  kXMSjXOldI/290b9BQIDAQAB
13
12
  -----END PUBLIC KEY-----
14
- """
13
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: python-aidot
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -6,9 +6,9 @@ aidot/__init__.py
6
6
  aidot/aes_utils.py
7
7
  aidot/client.py
8
8
  aidot/const.py
9
+ aidot/device_client.py
9
10
  aidot/discover.py
10
11
  aidot/exceptions.py
11
- aidot/lan.py
12
12
  aidot/login_const.py
13
13
  python_aidot.egg-info/PKG-INFO
14
14
  python_aidot.egg-info/SOURCES.txt
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="python-aidot",
8
- version="0.3.2",
8
+ version="0.3.4",
9
9
  author="aidotdev2024",
10
10
  url='https://github.com/Aidot-Development-Team/python-aidot',
11
11
  description="aidot control wifi lights",
@@ -1,318 +0,0 @@
1
- import socket
2
- import struct
3
- import binascii
4
- import time
5
- from datetime import datetime
6
- import json
7
- import asyncio
8
- import logging
9
-
10
- from .aes_utils import aes_encrypt,aes_decrypt
11
- _LOGGER = logging.getLogger(__name__)
12
-
13
- class Lan(object):
14
-
15
- _is_on : bool = False
16
- _dimming = 0
17
- _rgdb : int
18
- _cct : int
19
- _login_uuid = 0
20
- _available : bool = False
21
-
22
- _connectAndLogin : bool = False
23
- _connecting = False
24
- _simpleVersion = ""
25
- _colorMode = ""
26
-
27
- @property
28
- def is_on(self) -> bool:
29
- return self._is_on
30
-
31
- @property
32
- def brightness(self) -> int:
33
- return self._dimming * 255 / 100
34
-
35
- @property
36
- def rgdb(self) -> int:
37
- return self._rgdb
38
-
39
- @property
40
- def cct(self) -> int:
41
- return self._cct
42
-
43
- @property
44
- def available(self) -> bool:
45
- return self._available
46
-
47
- @property
48
- def connectAndLogin(self) -> bool:
49
- return self._connectAndLogin
50
-
51
- @property
52
- def connecting(self) -> bool:
53
- return self._connecting
54
-
55
- @property
56
- def colorMode(self) -> str:
57
- return self._colorMode
58
-
59
- def __init__(self,device:dict,user_info:dict) -> None:
60
- self.ping_count = 0
61
-
62
- if "id" in user_info:
63
- self.user_id = user_info["id"]
64
-
65
- if "aesKey" in device :
66
- key_string = device["aesKey"][0]
67
- if key_string is not None:
68
- self.aes_key = bytearray(16)
69
- key_bytes = key_string.encode()
70
- self.aes_key[:len(key_bytes)] = key_bytes
71
-
72
- if "password" in device:
73
- self.password = device["password"]
74
-
75
- if "id" in device:
76
- self.device_id = device["id"]
77
-
78
- if "simpleVersion" in device:
79
- self._simpleVersion = device["simpleVersion"]
80
-
81
- async def connect(self,ipAddress):
82
- self.reader = self.writer = None
83
- self._connecting = True
84
- try:
85
- self.reader, self.writer = await asyncio.open_connection(ipAddress, 10000)
86
- sock: socket.socket = self.writer.get_extra_info("socket")
87
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
88
- self.seq_num = 1
89
- await self.login()
90
- self._connectAndLogin = True
91
- except Exception as e:
92
- self._connectAndLogin = False
93
- finally:
94
- self._connecting = False
95
-
96
- def setUpdateDeviceCb(self,callback):
97
- self._updateDeviceCb = callback
98
-
99
- def printfHex(self,packet):
100
- hex_representation = binascii.hexlify(packet).decode()
101
-
102
- def getSendPacket(self,message,msgtype):
103
- magic = struct.pack('>H', 0x1eed)
104
- _msgtype = struct.pack('>h', msgtype)
105
-
106
- if self.aes_key is not None:
107
- send_data = aes_encrypt(message,self.aes_key)
108
- else :
109
- send_data = message
110
-
111
- bodysize = struct.pack('>i', len(send_data))
112
- packet = magic + _msgtype + bodysize + send_data
113
-
114
- return packet
115
-
116
- async def login(self):
117
- login_seq = str(int(time.time() * 1000) + self._login_uuid)[-9:]
118
- self._login_uuid += 1
119
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
120
- message = {
121
- "service":"device",
122
- "method":"loginReq",
123
- "seq":login_seq,
124
- "srcAddr":self.user_id,
125
- "deviceId":self.device_id,
126
- "payload":{
127
- "userId":self.user_id,
128
- "password":self.password,
129
- "timestamp":timestamp,
130
- "ascNumber":1
131
- }
132
- }
133
- self.writer.write(self.getSendPacket(json.dumps(message).encode(),1))
134
- await self.writer.drain()
135
-
136
- data = await self.reader.read(1024)
137
- data_len = len(data)
138
- if(data_len <= 0):
139
- return
140
-
141
- magic, msgtype, bodysize = struct.unpack('>HHI', data[:8])
142
- encrypted_data = data[8:]
143
- if self.aes_key is not None:
144
- decrypted_data = aes_decrypt(encrypted_data, self.aes_key)
145
- else :
146
- decrypted_data = encrypted_data
147
-
148
- json_data = json.loads(decrypted_data)
149
-
150
- self.ascNumber = json_data["payload"]["ascNumber"]
151
- self.ascNumber += 1
152
-
153
- self._available = True
154
-
155
- await self.sendAction({},"getDevAttrReq")
156
-
157
- async def recvData(self):
158
- while True:
159
- try :
160
- data = await self.reader.read(1024)
161
- except Exception as e:
162
- _LOGGER.error(f"recv data error {e}")
163
- await asyncio.sleep(3)
164
- continue
165
- data_len = len(data)
166
- if(data_len <= 0):
167
- break
168
-
169
- try:
170
- magic, msgtype, bodysize = struct.unpack('>HHI', data[:8])
171
- encrypted_data = data[8:]
172
- decrypted_data = aes_decrypt(encrypted_data, self.aes_key)
173
-
174
- json_data = json.loads(decrypted_data)
175
- except Exception as e:
176
- _LOGGER.error(f"recv json error : {e}")
177
- await asyncio.sleep(3)
178
- continue
179
-
180
- if "service" in json_data:
181
- if "test" == json_data["service"]:
182
- self.ping_count = 0
183
-
184
- if "payload" in json_data:
185
- if "ascNumber" in json_data["payload"]:
186
- self.ascNumber = json_data["payload"]["ascNumber"]
187
- if "attr" in json_data["payload"]:
188
- if "OnOff" in json_data["payload"]["attr"]:
189
- self._is_on = json_data["payload"]["attr"]["OnOff"]
190
- if "Dimming" in json_data["payload"]["attr"]:
191
- self._dimming = json_data["payload"]["attr"]["Dimming"]
192
- if "RGBW" in json_data["payload"]["attr"]:
193
- self._rgdb = json_data["payload"]["attr"]["RGBW"]
194
- self._colorMode = "rgbw"
195
- if "CCT" in json_data["payload"]["attr"]:
196
- self._cct = json_data["payload"]["attr"]["CCT"]
197
- self._colorMode = "cct"
198
- if self._updateDeviceCb:
199
- await self._updateDeviceCb()
200
-
201
- async def ping_task(self):
202
- while True:
203
- await asyncio.sleep(5) #加个延迟,不然遇到查状态和发ping同时出现状态回不来,暂时不知道什么原因
204
- if await self.sendPingAction() == -1 :
205
- return
206
- await asyncio.sleep(5)
207
-
208
- def getOnOffAction(self,OnOff):
209
- self._is_on = OnOff
210
- return {"OnOff": self._is_on}
211
-
212
- def getDimingAction(self,brightness):
213
- self._dimming = int(brightness * 100 / 255)
214
- return {"Dimming": self._dimming}
215
-
216
- def getCCTAction(self,cct):
217
- self._cct = cct
218
- self._colorMode = "cct"
219
- return {"CCT": self._cct}
220
-
221
- def getRGBWAction(self,rgbw):
222
- self._rgdb = rgbw
223
- self._colorMode = "rgbw"
224
- return {"RGBW": rgbw}
225
-
226
- async def sendDevAttr(self,devAttr):
227
- await self.sendAction(devAttr,"setDevAttrReq")
228
-
229
- async def sendAction(self,attr,method):
230
-
231
- current_timestamp_milliseconds = int(time.time() * 1000)
232
-
233
- self.seq_num += 1
234
-
235
- seq = "ha93" + str(self.seq_num).zfill(5)
236
-
237
- if not self._is_on and not "OnOff" in attr:
238
- attr["OnOff"] = 1
239
- self._is_on = 1
240
-
241
- if self._simpleVersion is not None:
242
- action = {
243
- "method": method,
244
- "service": "device",
245
- "clientId": "ha-" + self.user_id,
246
- "srcAddr": "0." + self.user_id,
247
- "seq": "" + seq,
248
- "payload": {
249
- "devId": self.device_id,
250
- "parentId": self.device_id,
251
- "userId": self.user_id,
252
- "password": self.password,
253
- "attr": attr,
254
- "channel":"tcp",
255
- "ascNumber":self.ascNumber,
256
- },
257
- "tst": current_timestamp_milliseconds,
258
- # "tid": "homeassistant",
259
- "deviceId": self.device_id,
260
- }
261
- else :
262
- action = {
263
- "method": method,
264
- "service": "device",
265
- "seq": "" + seq,
266
- "srcAddr": "0." + self.user_id,
267
- "payload": {
268
- "attr": attr,
269
- "ascNumber":self.ascNumber,
270
- },
271
- "tst": current_timestamp_milliseconds,
272
- # "tid": "homeassistant",
273
- "deviceId": self.device_id,
274
- }
275
-
276
- try:
277
- self.writer.write(self.getSendPacket(json.dumps(action).encode(),1))
278
- await self.writer.drain()
279
- except BrokenPipeError as e :
280
- _LOGGER.error(f"{self.device_id} send action error {e}")
281
- except Exception as e:
282
- _LOGGER.error(f"{self.device_id} send action error {e}")
283
-
284
- async def sendPingAction(self):
285
- ping = {
286
- "service": "test",
287
- "method": "pingreq",
288
- "seq": "123456",
289
- "srcAddr": "x.xxxxxxx",
290
- "payload": {}
291
- }
292
- try:
293
- if self.ping_count >= 2 :
294
- _LOGGER.error(f"Last ping did not return within 20 seconds. device id:{self.device_id}")
295
- await self.reset()
296
- return -1
297
- self.writer.write(self.getSendPacket(json.dumps(ping).encode(),2))
298
- await self.writer.drain()
299
- self.ping_count += 1
300
- return 1
301
- except Exception as e:
302
- _LOGGER.error(f"{self.device_id} ping error {e}")
303
- await self.reset()
304
- return -1
305
-
306
- async def reset(self):
307
- try:
308
- if self.writer:
309
- self.writer.close()
310
- await self.writer.wait_closed()
311
- except Exception as e:
312
- _LOGGER.error(f"{self.device_id} writer close error {e}")
313
- self._connectAndLogin = False
314
- self._available = False
315
- self.ping_count = 0
316
- if self._updateDeviceCb:
317
- await self._updateDeviceCb()
318
-
File without changes
File without changes
File without changes