python-aidot 0.3.44__tar.gz → 0.3.46__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
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.44
3
+ Version: 0.3.46
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -11,12 +11,12 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: requests
13
13
  Requires-Dist: aiohttp
14
- Requires-Dist: setuptools
15
14
  Dynamic: author
16
15
  Dynamic: classifier
17
16
  Dynamic: description
18
17
  Dynamic: description-content-type
19
18
  Dynamic: home-page
19
+ Dynamic: license-file
20
20
  Dynamic: requires-dist
21
21
  Dynamic: summary
22
22
 
@@ -9,7 +9,9 @@ from typing import Any, Optional
9
9
  from cryptography.hazmat.backends import default_backend
10
10
  from cryptography.hazmat.primitives import serialization
11
11
  from cryptography.hazmat.primitives.asymmetric import padding
12
-
12
+ import uuid
13
+ from pathlib import Path
14
+ import hashlib
13
15
  from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
14
16
  from .device_client import DeviceClient
15
17
  from .discover import Discover
@@ -93,7 +95,7 @@ class AidotClient:
93
95
  self._region = token[CONF_REGION]
94
96
  self.country_name = token[CONF_COUNTRY]
95
97
  self._base_url = f"https://prod-{self._region}-api.arnoo.com/v17"
96
-
98
+ self.setup_discover()
97
99
  def set_token_fresh_cb(self, callback) -> None:
98
100
  self._token_fresh_cb = callback
99
101
 
@@ -102,17 +104,35 @@ class AidotClient:
102
104
 
103
105
  def update_password(self, password: str) -> None:
104
106
  self.password = password
107
+
108
+ def get_terminal_id(self) -> str:
109
+ file_path = Path.home() / ".aidot_terminal_id"
110
+ if file_path.exists():
111
+ raw_id = file_path.read_text().strip()
112
+ else:
113
+ node = uuid.getnode()
114
+ is_random = (node >> 40) & 1
115
+
116
+ if is_random:
117
+ raw_id = str(uuid.uuid4())
118
+ else:
119
+ raw_id = format(node, 'x')
120
+ file_path.write_text(raw_id)
121
+ return hashlib.md5(raw_id.encode()).hexdigest()
105
122
 
106
123
  async def async_post_login(self) -> dict[str, Any]:
107
124
  """Login the user input allows us to connect."""
108
125
  url = f"{self._base_url}/users/loginWithFreeVerification"
109
126
  headers = {CONF_APP_ID: APP_ID, CONF_TERMINAL: "app"}
110
127
  # f"{region}:{self.country_name.strip()}",
128
+ terminalId = self.get_terminal_id()
129
+ if terminalId is None:
130
+ terminalId = "gvz3gjae10l4zii00t7y0"
111
131
  data = {
112
132
  "countryKey": f"region:{self.country_name.strip()}",
113
133
  "username": self.username,
114
134
  "password": rsa_password_encrypt(self.password),
115
- "terminalId": "gvz3gjae10l4zii00t7y0",
135
+ "terminalId": terminalId,
116
136
  "webVersion": "0.5.0",
117
137
  "area": "Asia/Shanghai",
118
138
  "UTC": "UTC+8",
@@ -126,6 +146,7 @@ class AidotClient:
126
146
  self.login_info[CONF_PASSWORD] = self.password
127
147
  self.login_info[CONF_REGION] = self._region
128
148
  self.login_info[CONF_COUNTRY] = self.country_name
149
+ self.setup_discover()
129
150
  return self.login_info
130
151
  except aiohttp.ClientError as e:
131
152
  _LOGGER.info(f"async_post_login ClientError {e}")
@@ -237,7 +258,6 @@ class AidotClient:
237
258
  if device_client is None:
238
259
  device_client = DeviceClient(device, self.login_info)
239
260
  self._device_clients[device_id] = device_client
240
- asyncio.get_running_loop().create_task(device_client.ping_task())
241
261
  if self._discover is not None:
242
262
  ip = self._discover.discovered_device.get(device_id)
243
263
  device_client.update_ip_address(ip)
@@ -249,7 +269,10 @@ class AidotClient:
249
269
  await device_client.close()
250
270
  del self._device_clients[dev_id]
251
271
 
252
- def start_discover(self) -> None:
272
+ def setup_discover(self) -> None:
273
+ """初始化完成后调用,启动设备发现"""
274
+ if self.login_info.get(CONF_ID) is None:
275
+ return
253
276
  if self._discover is not None:
254
277
  return
255
278
 
@@ -260,14 +283,18 @@ class AidotClient:
260
283
  device_client.update_ip_address(device_ip)
261
284
 
262
285
  self._discover = Discover(self.login_info, _discover_callback)
263
- asyncio.get_running_loop().create_task(self._discover.repeat_broadcast())
286
+ self._discover.start_repeat_broadcast()
264
287
 
265
- def stop_discover(self) -> None:
266
- self._discover.close()
267
- self._discover = None
268
-
269
- def cleanup(self) -> None:
270
- self.stop_discover()
288
+ async def async_close(self) -> None:
289
+ """关闭客户端,清理资源"""
290
+ if self._discover is not None:
291
+ self._discover.close()
292
+ self._discover = None
271
293
  for client in self._device_clients.values():
272
- asyncio.get_running_loop().create_task(client.close())
294
+ await client.close()
273
295
  self._device_clients.clear()
296
+
297
+ async def async_cleanup(self) -> None:
298
+ """清理所有资源"""
299
+ _LOGGER.info(f"async_cleanup")
300
+ await self.async_close()
@@ -107,7 +107,13 @@ class DeviceClient(object):
107
107
  _ip_address: str = None
108
108
  device_id: str
109
109
  _is_close: bool = False
110
- _status_fresh_cb: Any = None
110
+ on_status_update: Any = None
111
+ _receive_task: Any = None
112
+ _login_task: Any = None
113
+ _reconnect_handle: Any = None
114
+ _ping_timer: Any = None
115
+ writer: Any = None
116
+ reader: Any = None
111
117
  @property
112
118
  def connect_and_login(self) -> bool:
113
119
  return self._connect_and_login
@@ -154,7 +160,8 @@ class DeviceClient(object):
154
160
  return
155
161
  self._ip_address = ip
156
162
  if self._connecting is not True and self._connect_and_login is not True:
157
- asyncio.get_running_loop().create_task(self.async_login())
163
+ self._login_task = asyncio.create_task(self.async_login())
164
+
158
165
 
159
166
  async def async_login(self) -> None:
160
167
  if self._ip_address is None:
@@ -175,6 +182,11 @@ class DeviceClient(object):
175
182
  packet = magic + _msgtype + bodysize + send_data
176
183
 
177
184
  return packet
185
+
186
+ def _notify_status_update(self) -> None:
187
+ if self.on_status_update:
188
+ self.on_status_update(self.status)
189
+
178
190
 
179
191
  async def login(self) -> None:
180
192
  login_seq = str(int(time.time() * 1000) + self._login_uuid)[-9:]
@@ -225,39 +237,48 @@ class DeviceClient(object):
225
237
  self.ascNumber = json_data[CONF_PAYLOAD][CONF_ASCNUMBER]
226
238
  self.ascNumber += 1
227
239
  self.status.online = True
228
- asyncio.get_running_loop().create_task(self.reveive_data())
240
+ self._receive_task = asyncio.create_task(
241
+ self.receive_data(),
242
+ name=f"aidot_receive_{self.device_id}"
243
+ )
244
+ if self._ping_timer:
245
+ self._ping_timer.cancel()
246
+ if self._reconnect_handle:
247
+ self._reconnect_handle.cancel()
248
+ self._reconnect_handle = None
249
+ self._schedule_ping()
229
250
  _LOGGER.info(f"connect device success: {self._ip_address}")
230
251
  await self.send_action({}, "getDevAttrReq")
231
252
  except Exception as e:
232
253
  _LOGGER.error(f"connect device error : {e}")
233
254
  return
234
255
 
235
-
236
-
237
- async def reveive_data(self) -> None:
256
+ # TCP容易拼包,需要谨慎处理
257
+ async def receive_data(self) -> None:
238
258
  while True:
239
259
  try:
240
- data = await self.reader.read(1024)
241
- except (BrokenPipeError, ConnectionResetError) as e:
260
+ # 先读取 8 字节头
261
+ header = await self.reader.readexactly(8)
262
+ magic, msgtype, bodysize = struct.unpack(">HHI", header)
263
+
264
+ # 再读取 body
265
+ body = await self.reader.readexactly(bodysize)
266
+
267
+ # 解密
268
+ decrypted_data = aes_decrypt(body, self.aes_key)
269
+ json_data = json.loads(decrypted_data)
270
+ # _LOGGER.info(f"reveive_data : {json_data}")
271
+ except asyncio.CancelledError:
272
+ _LOGGER.debug("Receive task cancelled")
273
+ raise
274
+ except (BrokenPipeError, ConnectionResetError, asyncio.IncompleteReadError) as e:
242
275
  _LOGGER.error(f"{self.device_id} read status error {e}")
243
276
  await self.reset()
244
277
  self.status.online = False
278
+ self._notify_status_update()
245
279
  return
246
280
  except Exception as e:
247
- _LOGGER.error(f"recv data error {e}")
248
- return
249
- data_len = len(data)
250
- if data_len <= 0:
251
- _LOGGER.error("recv data error len, exit socket")
252
- await self.reset()
253
- self.status.online = False
254
- return
255
- try:
256
- magic, msgtype, bodysize = struct.unpack(">HHI", data[:8])
257
- decrypted_data = aes_decrypt(data[8:], self.aes_key)
258
- json_data = json.loads(decrypted_data)
259
- except Exception as e:
260
- _LOGGER.error(f"recv json error : {e}")
281
+ _LOGGER.error(f"recv error: {e}")
261
282
  continue
262
283
 
263
284
  if "service" in json_data:
@@ -269,56 +290,12 @@ class DeviceClient(object):
269
290
  if payload is not None:
270
291
  self.ascNumber = payload.get(CONF_ASCNUMBER)
271
292
  self.status.update(payload.get(CONF_ATTR))
272
- # _LOGGER.info(f"recv status : {payload}")
273
- if self._status_fresh_cb:
274
- self._status_fresh_cb(self.status)
275
- def set_status_fresh_cb(self, callback) -> None:
276
- self._status_fresh_cb = callback
277
- async def read_status(self) -> DeviceStatusData:
278
- # if self._connect_and_login is False:
279
- # await asyncio.sleep(2)
280
- # raise AidotNotLogin
281
- # try:
282
- # data = await self.reader.read(1024)
283
- # except (BrokenPipeError, ConnectionResetError) as e:
284
- # _LOGGER.error(f"{self.device_id} read status error {e}")
285
- # await self.reset()
286
- # self.status.online = False
287
- # return self.status
288
- # except Exception as e:
289
- # _LOGGER.error(f"recv data error {e}")
290
- # return self.status
291
- # data_len = len(data)
292
- # if data_len <= 0:
293
- # _LOGGER.error("recv data error len")
294
- # await self.reset()
295
- # self.status.online = False
296
- # return self.status
297
- # try:
298
- # magic, msgtype, bodysize = struct.unpack(">HHI", data[:8])
299
- # decrypted_data = aes_decrypt(data[8:], self.aes_key)
300
- # json_data = json.loads(decrypted_data)
301
- # except Exception as e:
302
- # _LOGGER.error(f"recv json error : {e}")
303
- # return await self.read_status()
304
-
305
- # if "service" in json_data:
306
- # if "test" == json_data["service"]:
307
- # self.ping_count = 0
308
- # return await self.read_status()
309
- # payload = json_data.get(CONF_PAYLOAD)
310
- # if payload is not None:
311
- # self.ascNumber = payload.get(CONF_ASCNUMBER)
312
- # self.status.update(payload.get(CONF_ATTR))
313
- return self.status
314
-
315
- async def ping_task(self) -> None:
316
- while True:
317
- if self._is_close:
318
- return
319
- await asyncio.sleep(5)
320
- await self.send_ping_action()
321
- await asyncio.sleep(5)
293
+ self._notify_status_update()
294
+
295
+ def _schedule_ping(self):
296
+ loop = asyncio.get_running_loop()
297
+ loop.create_task(self.send_ping_action())
298
+ self._ping_timer = loop.call_later(10, self._schedule_ping)
322
299
 
323
300
  async def send_dev_attr(self, dev_attr) -> None:
324
301
  if not self._connect_and_login:
@@ -393,6 +370,8 @@ class DeviceClient(object):
393
370
  _LOGGER.error(f"{self.device_id} send action error {e}")
394
371
 
395
372
  async def send_ping_action(self) -> int:
373
+ if self._is_close:
374
+ return -1
396
375
  ping = {
397
376
  "service": "test",
398
377
  "method": "pingreq",
@@ -400,6 +379,7 @@ class DeviceClient(object):
400
379
  "srcAddr": "x.xxxxxxx",
401
380
  CONF_PAYLOAD: {},
402
381
  }
382
+ _LOGGER.info(f"{self.device_id} send_ping_action {ping}")
403
383
  try:
404
384
  if self.ping_count >= 2:
405
385
  _LOGGER.error(
@@ -419,17 +399,47 @@ class DeviceClient(object):
419
399
  return -1
420
400
 
421
401
  async def reset(self) -> None:
402
+ if self._ping_timer:
403
+ self._ping_timer.cancel()
404
+ if self._reconnect_handle:
405
+ self._reconnect_handle.cancel()
406
+ self._reconnect_handle = None
407
+
408
+ if self._receive_task and not self._receive_task.done():
409
+ self._receive_task.cancel()
410
+ try:
411
+ await self._receive_task
412
+ except asyncio.CancelledError as e:
413
+ _LOGGER.error(f"{self.device_id} writer close error {e}")
414
+ pass
422
415
  try:
423
416
  if self.writer:
424
417
  self.writer.close()
425
418
  await self.writer.wait_closed()
426
419
  except Exception as e:
427
- _LOGGER.error(f"{self.device_id} writer close error {e}")
420
+ _LOGGER.error(f"{self.device_id} writer/reader close error {e}")
421
+ self.writer = self.reader = None;
422
+
428
423
  self._connect_and_login = False
429
424
  self.status.online = False
430
425
  self.ping_count = 0
426
+ self._notify_status_update()
427
+ # 自动重连(如果没有主动关闭)
428
+ if not self._is_close and self._ip_address:
429
+ self._schedule_reconnect()
431
430
 
432
431
  async def close(self) -> None:
433
432
  self._is_close = True
434
433
  await self.reset()
435
434
  _LOGGER.info(f"{self.device_id} connect close by user")
435
+
436
+ def _schedule_reconnect(self) -> None:
437
+ """延迟重连"""
438
+ _LOGGER.info(f"{self.device_id} _schedule_reconnect")
439
+ loop = asyncio.get_running_loop()
440
+ # self._reconnect_handle = loop.call_later(
441
+ # 10, # 10秒后重连
442
+ # lambda: asyncio.create_task(self.async_login())
443
+ # )
444
+ self._reconnect_handle = loop.call_later(15, self._schedule_reconnect)
445
+ self._login_task = asyncio.create_task(self.async_login())
@@ -10,8 +10,10 @@ 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
+ # _DISCOVER_TIME = 15
14
14
 
15
+ _DISCOVER_FAST = 5 # 启动时快速发现
16
+ _DISCOVER_SLOW = 120 # 稳定后慢速维持
15
17
 
16
18
  class BroadcastProtocol:
17
19
  _is_closed = False
@@ -49,6 +51,7 @@ class BroadcastProtocol:
49
51
  "timestamp": str(current_timestamp_milliseconds),
50
52
  },
51
53
  }
54
+ _LOGGER.info(f"send_broadcast {message}")
52
55
  send_data = aes_encrypt(json.dumps(message).encode(), self.aes_key)
53
56
  try:
54
57
  self.transport.sendto(send_data, ("255.255.255.255", 6666))
@@ -58,6 +61,7 @@ class BroadcastProtocol:
58
61
  def datagram_received(self, data, addr) -> None:
59
62
  data_str = aes_decrypt(data, self.aes_key)
60
63
  data_json = json.loads(data_str)
64
+ _LOGGER.info(f"datagram_received {data_json}")
61
65
  if "payload" in data_json:
62
66
  if "mac" in data_json["payload"]:
63
67
  devId = data_json["payload"]["devId"]
@@ -85,47 +89,51 @@ class Discover:
85
89
  _login_info: dict[str, Any] = None
86
90
  _broadcast_protocol: BroadcastProtocol = None
87
91
  discovered_device: dict[str, str]
88
- _is_close: bool = False
92
+ _timer_handle: asyncio.TimerHandle | None = None
89
93
 
90
94
  def __init__(self, login_info, callback):
91
95
  self.discovered_device = {}
92
96
  self._login_info = login_info
93
97
  self._callback = callback
94
-
98
+
95
99
  async def try_create_broadcast(self) -> None:
96
- if self._broadcast_protocol is None:
97
- self._broadcast_protocol = BroadcastProtocol(
98
- self._discover_callback, self._login_info[CONF_ID]
100
+ if self._broadcast_protocol is not None:
101
+ return
102
+ try:
103
+ protocol = BroadcastProtocol(self._discover_callback, self._login_info[CONF_ID])
104
+ self._transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
105
+ lambda: protocol,
106
+ local_addr=("0.0.0.0", 0),
99
107
  )
100
- try:
101
- (
102
- transport,
103
- protocol,
104
- ) = await asyncio.get_event_loop().create_datagram_endpoint(
105
- lambda: self._broadcast_protocol,
106
- local_addr=("0.0.0.0", 0),
107
- )
108
- except OSError:
109
- raise AidotOSError
110
-
111
- async def send_broadcast(self) -> None:
112
- await self.try_create_broadcast()
113
- self._broadcast_protocol.send_broadcast()
114
-
115
- async def repeat_broadcast(self) -> None:
108
+ self._broadcast_protocol = protocol # 成功后再赋值
109
+ except OSError:
110
+ raise AidotOSError
111
+
112
+ def start_repeat_broadcast(self) -> None:
116
113
  self._is_close = False
117
- while True:
118
- await self.send_broadcast()
119
- for i in range(_DISCOVER_TIME):
120
- await asyncio.sleep(1) # 每秒检查一次是否需要取消任务
121
- if self._is_close is True:
122
- return
123
-
124
- async def fetch_devices_info(self) -> dict[str, str]:
125
- self.try_create_broadcast()
126
- self._broadcast_protocol.send_broadcast()
127
- await asyncio.sleep(2)
128
- return self.discovered_device
114
+ self._fast_discover_count = 6 # 前6次快速(30秒内)
115
+ self._schedule_broadcast()
116
+
117
+ def _schedule_broadcast(self) -> None:
118
+ _LOGGER.debug(f"_schedule_broadcast")
119
+ # 前几次快速发现,之后慢速
120
+ if self._fast_discover_count > 0:
121
+ interval = _DISCOVER_FAST
122
+ self._fast_discover_count -= 1
123
+ else:
124
+ interval = _DISCOVER_SLOW
125
+
126
+ loop = asyncio.get_running_loop()
127
+ asyncio.create_task(self._do_broadcast())
128
+ self._timer_handle = loop.call_later(interval, self._schedule_broadcast)
129
+
130
+ async def _do_broadcast(self) -> None:
131
+ """执行广播"""
132
+ try:
133
+ await self.try_create_broadcast()
134
+ self._broadcast_protocol.send_broadcast()
135
+ except Exception as e:
136
+ _LOGGER.error(f"Broadcast failed: {e}")
129
137
 
130
138
  def _discover_callback(self, dev_id, event: dict[str, str]) -> None:
131
139
  self.discovered_device[dev_id] = event[CONF_IPADDRESS]
@@ -133,7 +141,9 @@ class Discover:
133
141
  self._callback(dev_id, event)
134
142
 
135
143
  def close(self) -> None:
136
- self._is_close = True
144
+ if self._timer_handle is not None:
145
+ self._timer_handle.cancel()
146
+ self._timer_handle = None
137
147
  if self._broadcast_protocol is not None:
138
148
  self._broadcast_protocol.close()
139
149
  self._broadcast_protocol = None
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.44
3
+ Version: 0.3.46
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -11,12 +11,12 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: requests
13
13
  Requires-Dist: aiohttp
14
- Requires-Dist: setuptools
15
14
  Dynamic: author
16
15
  Dynamic: classifier
17
16
  Dynamic: description
18
17
  Dynamic: description-content-type
19
18
  Dynamic: home-page
19
+ Dynamic: license-file
20
20
  Dynamic: requires-dist
21
21
  Dynamic: summary
22
22
 
@@ -1,3 +1,2 @@
1
1
  requests
2
2
  aiohttp
3
- setuptools
@@ -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.44",
8
+ version="0.3.46",
9
9
  author="aidotdev2024",
10
10
  url='https://github.com/Aidot-Development-Team/python-aidot',
11
11
  description="aidot control wifi lights",
@@ -15,7 +15,6 @@ setuptools.setup(
15
15
  install_requires=[
16
16
  "requests",
17
17
  "aiohttp",
18
- "setuptools",
19
18
  ],
20
19
  classifiers=(
21
20
  "Programming Language :: Python :: 3.12",
File without changes
File without changes
File without changes