python-aidot 0.3.1__tar.gz → 0.3.3__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.1
3
+ Version: 0.3.3
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,38 @@
1
1
  """The aidot integration."""
2
2
 
3
3
  import logging
4
- from typing import Any, Optional
5
- from aiohttp import ClientSession
6
4
  import base64
7
5
  import aiohttp
6
+ from aiohttp import ClientSession
7
+ from typing import Any, Optional
8
8
  from cryptography.hazmat.backends import default_backend
9
9
  from cryptography.hazmat.primitives import serialization
10
10
  from cryptography.hazmat.primitives.asymmetric import padding
11
+
12
+ from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
13
+ from .device_client import DeviceClient
14
+ from .discover import Discover
11
15
  from .login_const import APP_ID, PUBLIC_KEY_PEM, BASE_URL
12
16
  from .const import (
13
- SUPPORTED_COUNTRYS,
14
- DEFAULT_COUNTRY_NAME,
15
- CONF_PRODUCT_ID,
16
- CONF_ID,
17
- CONF_PRODUCT,
18
17
  CONF_ACCESS_TOKEN,
19
- CONF_REFRESH_TOKEN,
20
- CONF_TERMINAL,
21
18
  CONF_APP_ID,
22
- CONF_REGION,
19
+ CONF_CODE,
23
20
  CONF_COUNTRY,
24
- CONF_USERNAME,
21
+ CONF_DEVICE_LIST,
22
+ CONF_ID,
23
+ CONF_IPADDRESS,
25
24
  CONF_PASSWORD,
26
- CONF_CODE,
25
+ CONF_PRODUCT,
26
+ CONF_PRODUCT_ID,
27
+ CONF_REFRESH_TOKEN,
28
+ CONF_REGION,
29
+ CONF_TERMINAL,
27
30
  CONF_TOKEN,
28
- CONF_DEVICE_LIST,
29
- ServerErrorCode
31
+ CONF_USERNAME,
32
+ DEFAULT_COUNTRY_NAME,
33
+ SUPPORTED_COUNTRYS,
34
+ ServerErrorCode,
30
35
  )
31
- from .exceptions import AidotAuthFailed,AidotUserOrPassIncorrect
32
36
 
33
37
  _LOGGER = logging.getLogger(__name__)
34
38
 
@@ -56,6 +60,8 @@ class AidotClient:
56
60
  password: str = ""
57
61
  country_name: str = DEFAULT_COUNTRY_NAME
58
62
  login_info: dict[str, Any] = {}
63
+ _device_clients: dict[str:DeviceClient]
64
+ _discover: Discover = None
59
65
 
60
66
  def __init__(
61
67
  self,
@@ -69,7 +75,8 @@ class AidotClient:
69
75
  self.country_name = country_name
70
76
  self.username = username
71
77
  self.password = password
72
- self.login_info = token
78
+ self.login_info = token.copy()
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()
@@ -86,7 +93,7 @@ class AidotClient:
86
93
 
87
94
  def get_identifier(self) -> str:
88
95
  return f"{self._region}-{self.username}"
89
-
96
+
90
97
  def update_password(self, password: str):
91
98
  self.password = password
92
99
 
@@ -154,6 +161,7 @@ class AidotClient:
154
161
  CONF_TOKEN: token,
155
162
  CONF_APP_ID: APP_ID,
156
163
  }
164
+ response_data = {}
157
165
  try:
158
166
  response = await self.session.get(url, headers=headers)
159
167
  response_data = await response.json()
@@ -161,17 +169,19 @@ class AidotClient:
161
169
  return response_data
162
170
  except aiohttp.ClientError as e:
163
171
  _LOGGER.info(f"async_get ClientError {e}")
164
- code = response_data[CONF_CODE]
172
+ code = response_data.get(CONF_CODE)
165
173
  if code == ServerErrorCode.TOKEN_EXPIRED:
166
174
  try:
167
175
  await self.async_refresh_token()
168
176
  return await self.async_session_get(params)
169
177
  except AidotAuthFailed:
170
178
  raise AidotAuthFailed
171
- elif code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041:
179
+ elif (
180
+ code == ServerErrorCode.LOGIN_INVALID or code == 21027 or code == 21041
181
+ ):
172
182
  self.login_info[CONF_ACCESS_TOKEN] = None
173
183
  raise AidotAuthFailed
174
- return None
184
+ return aiohttp.ClientError
175
185
 
176
186
  async def async_get_products(self, product_ids: str):
177
187
  """Get device list."""
@@ -208,4 +218,36 @@ class AidotClient:
208
218
  device[CONF_PRODUCT] = product
209
219
  except Exception as e:
210
220
  raise e
211
- return {CONF_DEVICE_LIST:final_device_list}
221
+ return {CONF_DEVICE_LIST: final_device_list}
222
+
223
+ def get_device_client(self, device: dict[str:Any]) -> DeviceClient:
224
+ device_id = device.get(CONF_ID)
225
+ device_client: DeviceClient = self._device_clients.get(device_id)
226
+ if device_client is None:
227
+ device_client = DeviceClient(device, self.login_info)
228
+ self._device_clients[device_id] = device_client
229
+ if self._discover is not None:
230
+ ip = self._discover.discovered_device.get(device_id)
231
+ device_client.update_ip_address(ip)
232
+ return device_client
233
+
234
+ async def start_discover(self) -> None:
235
+ if self._discover is not None:
236
+ return
237
+
238
+ def _discover_callback(dev_id, event: dict[str, str]) -> None:
239
+ device_ip = event[CONF_IPADDRESS]
240
+ device_client: DeviceClient = self._device_clients.get(dev_id)
241
+ if device_client is not None:
242
+ device_client.update_ip_address(device_ip)
243
+
244
+ self._discover = Discover(self.login_info, _discover_callback)
245
+ await self._discover.repeat_broadcast()
246
+
247
+ def stop_discover(self) -> None:
248
+ self._discover.close()
249
+ self._discover = None
250
+
251
+ def cleanup(self) -> None:
252
+ self.stop_discover()
253
+ 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,371 @@
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()
@@ -0,0 +1,138 @@
1
+ import socket
2
+ import json
3
+ import time
4
+ import logging
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from .aes_utils import aes_encrypt, aes_decrypt
9
+ from .const import CONF_ID, CONF_IPADDRESS
10
+ from .exceptions import AidotOSError
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+ _DISCOVER_TIME = 5
14
+
15
+ class BroadcastProtocol:
16
+ _is_closed = False
17
+
18
+ def __init__(self, callback, user_id):
19
+ self.aes_key = bytearray(32)
20
+ key_string = "T54uednca587"
21
+ key_bytes = key_string.encode()
22
+ self.aes_key[: len(key_bytes)] = key_bytes
23
+
24
+ self._discover_cb = callback
25
+ self.user_id = user_id
26
+
27
+ def connection_made(self, transport):
28
+ self.transport = transport
29
+ sock = transport.get_extra_info("socket")
30
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
31
+
32
+ def send_broadcast(self) -> None:
33
+ if self._is_closed is True:
34
+ _LOGGER.error(f"{self.user_id}:Connection is closed")
35
+ return
36
+ current_timestamp_milliseconds = int(time.time() * 1000)
37
+ seq = str(current_timestamp_milliseconds + 1)[-9:]
38
+ message = {
39
+ "protocolVer": "2.0.0",
40
+ "service": "device",
41
+ "method": "devDiscoveryReq",
42
+ "seq": seq,
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
+ },
50
+ }
51
+ send_data = aes_encrypt(json.dumps(message).encode(), self.aes_key)
52
+ try:
53
+ self.transport.sendto(send_data, ("255.255.255.255", 6666))
54
+ except Exception as error:
55
+ _LOGGER.error(f"{self.user_id}:Connection lost due to error: {error}")
56
+
57
+ def datagram_received(self, data, addr):
58
+ data_str = aes_decrypt(data, self.aes_key)
59
+ data_json = json.loads(data_str)
60
+ if "payload" in data_json:
61
+ if "mac" in data_json["payload"]:
62
+ devId = data_json["payload"]["devId"]
63
+ if self._discover_cb:
64
+ self._discover_cb(devId, {CONF_IPADDRESS: addr[0]})
65
+
66
+ def error_received(self, exc):
67
+ _LOGGER.error(f"{self.user_id}:Error occurred: {exc}")
68
+
69
+ def close(self) -> None:
70
+ try:
71
+ self.transport.close()
72
+ except Exception as error:
73
+ _LOGGER.error(f"Connection lost due to error: {error}")
74
+
75
+ def connection_lost(self, exc):
76
+ self._is_closed = True
77
+ if exc:
78
+ _LOGGER.error(f"{self.user_id}:Connection lost due to error: {exc}")
79
+ else:
80
+ _LOGGER.info("{self.user_id}:Connection closed.")
81
+
82
+
83
+ class Discover:
84
+ _login_info: dict[str:Any] = None
85
+ _broadcast_protocol: BroadcastProtocol = None
86
+ discovered_device: dict[str:str]
87
+ _is_close: bool = False
88
+
89
+ def __init__(self, login_info, callback):
90
+ self.discovered_device = {}
91
+ self._login_info = login_info
92
+ self._callback = callback
93
+
94
+ async def try_create_broadcast(self) -> None:
95
+ if self._broadcast_protocol is None:
96
+ self._broadcast_protocol = BroadcastProtocol(
97
+ self._discover_callback, self._login_info[CONF_ID]
98
+ )
99
+ try:
100
+ (
101
+ transport,
102
+ protocol,
103
+ ) = await asyncio.get_event_loop().create_datagram_endpoint(
104
+ lambda: self._broadcast_protocol,
105
+ local_addr=("0.0.0.0", 0),
106
+ )
107
+ except OSError:
108
+ raise AidotOSError
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()
125
+ self._broadcast_protocol.send_broadcast()
126
+ await asyncio.sleep(2)
127
+ return self.discovered_device
128
+
129
+ def _discover_callback(self, dev_id, event: dict[str, str]) -> None:
130
+ self.discovered_device[dev_id] = event[CONF_IPADDRESS]
131
+ if self._callback:
132
+ self._callback(dev_id, event)
133
+
134
+ def close(self) -> None:
135
+ self._is_close = True
136
+ if self._broadcast_protocol is not None:
137
+ self._broadcast_protocol.close()
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.1
3
+ Version: 0.3.3
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.1",
8
+ version="0.3.3",
9
9
  author="aidotdev2024",
10
10
  url='https://github.com/Aidot-Development-Team/python-aidot',
11
11
  description="aidot control wifi lights",
@@ -1,100 +0,0 @@
1
- import socket
2
- import json
3
- import time
4
- import logging
5
- from typing import Any
6
-
7
- from .aes_utils import aes_encrypt,aes_decrypt
8
- import asyncio
9
- from .const import CONF_ID,CONF_IPADDRESS
10
- from .exceptions import AidotOSError
11
-
12
- _LOGGER = logging.getLogger(__name__)
13
-
14
- class BroadcastProtocol:
15
- _is_running: bool = True
16
- def __init__(self,callback,user_id):
17
- self.aes_key = bytearray(32)
18
- key_string = "T54uednca587"
19
- key_bytes = key_string.encode()
20
- self.aes_key[:len(key_bytes)] = key_bytes
21
-
22
- self._discover_cb = callback
23
- self.user_id = user_id
24
-
25
- def connection_made(self, transport):
26
- self.transport = transport
27
- sock = transport.get_extra_info("socket")
28
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
29
-
30
- def send_broadcast(self) -> None:
31
- current_timestamp_milliseconds = int(time.time() * 1000)
32
- seq = str(current_timestamp_milliseconds + 1)[-9:]
33
- message = {
34
- "protocolVer":"2.0.0",
35
- "service":"device",
36
- "method":"devDiscoveryReq",
37
- "seq": seq,
38
- "srcAddr":f"0.{self.user_id}]",
39
- "tst":current_timestamp_milliseconds,
40
- "payload":{
41
- "extends":{ },
42
- "localCtrFlag":1,
43
- "timestamp":str(current_timestamp_milliseconds)
44
- }
45
- }
46
- send_data = aes_encrypt(json.dumps(message).encode(),self.aes_key)
47
- self.transport.sendto(send_data, ('255.255.255.255', 6666))
48
-
49
- def datagram_received(self, data, addr):
50
- data_str = aes_decrypt(data,self.aes_key)
51
- data_json = json.loads(data_str)
52
- if("payload" in data_json):
53
- if("mac" in data_json["payload"]):
54
- devId = data_json["payload"]["devId"]
55
- if self._discover_cb:
56
- self._discover_cb(devId,{CONF_IPADDRESS : addr[0]})
57
-
58
- def error_received(self, exc):
59
- _LOGGER.error(f"Error occurred: {exc}")
60
-
61
- def close(self) -> None:
62
- self.transport.close()
63
-
64
- def connection_lost(self, exc):
65
- if exc:
66
- _LOGGER.error(f"Connection lost due to error: {exc}")
67
- else:
68
- _LOGGER.info("Connection closed.")
69
-
70
- class Discover:
71
- _login_info: dict[str: Any] = None
72
- _broadcast_protocol: BroadcastProtocol = None
73
- _discovered_device: dict[str: str]
74
- def __init__(self,login_info):
75
- self._discovered_device = {}
76
- self._login_info = login_info
77
-
78
- async def fetch_devices_info(self) -> dict[str: str]:
79
- if self._broadcast_protocol is None:
80
- self._broadcast_protocol = BroadcastProtocol(self._discover_callback,self._login_info[CONF_ID])
81
- try:
82
- transport, protocol = await asyncio.get_event_loop().create_datagram_endpoint(
83
- lambda: self._broadcast_protocol,
84
- local_addr=("0.0.0.0", 0),
85
- )
86
- except OSError:
87
- raise AidotOSError
88
-
89
- self._broadcast_protocol.send_broadcast()
90
- await asyncio.sleep(2)
91
- return self._discovered_device
92
-
93
- def _discover_callback(self, dev_id, event: dict[str, str]) -> None:
94
- self._discovered_device[dev_id] = event[CONF_IPADDRESS]
95
-
96
- def close(self) -> None:
97
- if self._broadcast_protocol is not None:
98
- self._broadcast_protocol.close()
99
-
100
-
@@ -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