sensor-sdk 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {sensor_sdk-0.2.0/sensor_sdk.egg-info → sensor_sdk-0.2.2}/PKG-INFO +1 -1
  2. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/bleak_no_ack_patch.py +6 -3
  3. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/bleak_process.py +137 -11
  4. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/gforce.py +5 -0
  5. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_data_context.py +105 -5
  6. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_profile.py +34 -1
  7. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2/sensor_sdk.egg-info}/PKG-INFO +1 -1
  8. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/setup.py +1 -1
  9. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/LICENSE.txt +0 -0
  10. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/README.md +0 -0
  11. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/__init__.py +0 -0
  12. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/bleak_host.py +0 -0
  13. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/DataType.py +0 -0
  14. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/Sample.py +0 -0
  15. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/SensorData.py +0 -0
  16. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/__init__.py +0 -0
  17. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sdk_log.py +0 -0
  18. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_controller.py +0 -0
  19. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_data.py +0 -0
  20. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_data_pool.py +0 -0
  21. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_device.py +0 -0
  22. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_utils.py +0 -0
  23. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/winrt_high_throughput.py +0 -0
  24. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/SOURCES.txt +0 -0
  25. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/dependency_links.txt +0 -0
  26. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/requires.txt +0 -0
  27. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/top_level.txt +0 -0
  28. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/zip-safe +0 -0
  29. {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sensor-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python sdk for Synchroni
5
5
  Home-page: https://github.com/oymotion/SynchroniSDKPython
6
6
  Author: Martin Ye
@@ -3,7 +3,7 @@
3
3
  某些 Windows 主机上 write-with-response 会因为系统 ACK 等待导致命令超时,
4
4
  把命令特征强制改成无响应写入可以显著提升命令通道的实时性。
5
5
 
6
- 默认只作用于 SDK 已知的两个 CMD 特征 UUID;传入空列表或 `None` 则全局生效。
6
+ 默认只作用于 SDK 已知的两个 CMD 特征 UUID;传 `None` 则全局生效,空列表表示不匹配任何特征。
7
7
  """
8
8
 
9
9
  import logging
@@ -53,6 +53,7 @@ def apply(cmd_char_uuids=DEFAULT_CMD_CHAR_UUIDS):
53
53
  return await orig_write(self, char_specifier, data, response=response)
54
54
 
55
55
  BleakClient.write_gatt_char = patched_write
56
+ BleakClient._orig_write_gatt_char = orig_write # type: ignore[attr-defined]
56
57
  BleakClient._no_ack_patched = True # type: ignore[attr-defined]
57
58
  logger.debug(
58
59
  "Applied bleak write-without-response patch (targets: %s)",
@@ -70,6 +71,8 @@ def reset():
70
71
  if not getattr(BleakClient, "_no_ack_patched", False):
71
72
  return
72
73
 
73
- # 找到原始的未绑定方法比较麻烦,这里简单把标记清掉,
74
- # 实际撤销需要保存原始方法;apply 没有保存,故 reset 仅作占位。
74
+ orig_write = getattr(BleakClient, "_orig_write_gatt_char", None)
75
+ if orig_write is not None:
76
+ BleakClient.write_gatt_char = orig_write
77
+ del BleakClient._orig_write_gatt_char # type: ignore[attr-defined]
75
78
  BleakClient._no_ack_patched = False # type: ignore[attr-defined]
@@ -25,6 +25,20 @@ OYM_DATA_NOTIFY_CHAR_UUID = "f000ffe2-0451-4000-b000-000000000000"
25
25
  RFSTAR_CMD_UUID = "00000002-0000-1000-8000-00805f9b34fb"
26
26
  RFSTAR_DATA_UUID = "00000003-0000-1000-8000-00805f9b34fb"
27
27
 
28
+ # 异常断开后自动重连参数
29
+ _MAX_RECONNECT_ATTEMPTS = 5
30
+ _RECONNECT_DELAY_SECONDS = 1.0
31
+
32
+
33
+ def _is_core_bluetooth(client) -> bool:
34
+ """判断 bleak 当前是否使用 macOS CoreBluetooth 后端。"""
35
+ if client is None:
36
+ return False
37
+ backend = getattr(client, "_backend", None)
38
+ if backend is None:
39
+ return False
40
+ return "corebluetooth" in type(backend).__module__.lower()
41
+
28
42
 
29
43
  def _extract_mac(_device: bleak.BLEDevice, adv: AdvertisementData) -> str:
30
44
 
@@ -93,6 +107,9 @@ class BleakProcess(multiprocessing.Process):
93
107
  self._power_intervals: dict = {} # mac -> int (ms)
94
108
  self._data_tasks: dict = {} # mac -> asyncio.Task
95
109
  self._battery_tasks: dict = {} # mac -> asyncio.Task
110
+ self._reconnect_info: dict = {} # mac -> {cmd, attempts, task, normal_disconnect}
111
+ self._restore_info: dict = {} # mac -> {package_sample_count, power_refresh_interval, streaming}
112
+ self._main_event_loop = None # BleakProcess 主事件循环
96
113
 
97
114
  # Per-device event loops and threads
98
115
  self._event_loops: dict = {} # mac -> asyncio.AbstractEventLoop
@@ -344,6 +361,7 @@ class BleakProcess(multiprocessing.Process):
344
361
  self._msg_queue = queue.Queue()
345
362
 
346
363
  async def _run_main():
364
+ self._main_event_loop = asyncio.get_running_loop()
347
365
  publisher_task = asyncio.create_task(self._publisher_task())
348
366
  try:
349
367
  await self._main_loop()
@@ -581,6 +599,14 @@ class BleakProcess(multiprocessing.Process):
581
599
  name = cmd.get("name", "")
582
600
  service_data = cmd.get("service_data", {})
583
601
 
602
+ # 保存连接信息,用于异常断开后自动重连
603
+ self._reconnect_info[device_mac] = {
604
+ "cmd": cmd,
605
+ "attempts": 0,
606
+ "task": None,
607
+ "normal_disconnect": False,
608
+ }
609
+
584
610
  event_loop = self._event_loops.get(device_mac)
585
611
  gforce_event_loop = self._gforce_event_loop
586
612
  data_event_loop = self._data_event_loops.get(device_mac)
@@ -676,8 +702,18 @@ class BleakProcess(multiprocessing.Process):
676
702
  self._raw_bufs[device_mac] = raw_buf
677
703
  self._device_states[device_mac] = "Connected"
678
704
 
705
+ # 重连成功,重置重连计数
706
+ reconnect_info = self._reconnect_info.get(device_mac)
707
+ if reconnect_info is not None:
708
+ reconnect_info["attempts"] = 0
709
+
679
710
  # Create data context
680
- data_ctx = SensorProfileDataCtx(gforce, device_mac, raw_buf)
711
+ data_ctx = SensorProfileDataCtx(
712
+ gforce,
713
+ device_mac,
714
+ raw_buf,
715
+ on_reconnect_request=lambda: self._schedule_reconnect(device_mac),
716
+ )
681
717
  self._data_ctxs[device_mac] = data_ctx
682
718
 
683
719
  # Start data processing loop in data_event_loop
@@ -705,6 +741,19 @@ class BleakProcess(multiprocessing.Process):
705
741
  if device_mac not in self._gforces and not disconnect_client:
706
742
  return # Already cleaned
707
743
 
744
+ # 用户主动断开时标记为正常断开,并取消正在进行的自动重连
745
+ if disconnect_client:
746
+ info = self._reconnect_info.get(device_mac)
747
+ if info is not None:
748
+ info["normal_disconnect"] = True
749
+ task = info.get("task")
750
+ if task is not None:
751
+ try:
752
+ task.cancel()
753
+ except Exception:
754
+ pass
755
+ info["task"] = None
756
+
708
757
  # Cancel data task (running in data_event_loop)
709
758
  if device_mac in self._data_tasks:
710
759
  task = self._data_tasks.pop(device_mac, None)
@@ -750,8 +799,63 @@ class BleakProcess(multiprocessing.Process):
750
799
  self._raw_bufs.pop(device_mac, None)
751
800
  self._device_states[device_mac] = "Disconnected"
752
801
 
802
+ # 正常断开后清理重连信息;异常断开保留以继续自动重连
803
+ if disconnect_client:
804
+ self._reconnect_info.pop(device_mac, None)
805
+
753
806
  self._publish("state_changed", device_mac=device_mac, state="Disconnected")
754
807
 
808
+ def _schedule_reconnect(self, device_mac: str):
809
+ """从 watchdog 线程调用,调度一次自动重连。"""
810
+ info = self._reconnect_info.get(device_mac)
811
+ if info is None or info.get("normal_disconnect"):
812
+ return
813
+ if info.get("task") is not None:
814
+ return # 已有重连任务在进行
815
+ if info["attempts"] >= _MAX_RECONNECT_ATTEMPTS:
816
+ SdkLog.e(_TAG, f"Max reconnect attempts reached for {device_mac}")
817
+ return
818
+
819
+ info["attempts"] += 1
820
+ SdkLog.i(_TAG, f"Scheduling reconnect attempt {info['attempts']} for {device_mac}")
821
+
822
+ loop = self._main_event_loop
823
+ if loop is None or loop.is_closed():
824
+ SdkLog.e(_TAG, f"Main event loop not available, cannot reconnect {device_mac}")
825
+ return
826
+
827
+ async def _delayed_reconnect():
828
+ await asyncio.sleep(_RECONNECT_DELAY_SECONDS)
829
+ await self._do_reconnect(device_mac)
830
+
831
+ try:
832
+ info["task"] = asyncio.run_coroutine_threadsafe(_delayed_reconnect(), loop)
833
+ except Exception as e:
834
+ SdkLog.exception(_TAG, f"Failed to schedule reconnect for {device_mac}: {e}")
835
+ info["task"] = None
836
+
837
+ async def _do_reconnect(self, device_mac: str):
838
+ """执行一次自动重连。"""
839
+ info = self._reconnect_info.get(device_mac)
840
+ if info is None or info.get("normal_disconnect"):
841
+ return
842
+
843
+ # 已经连接则无需重连
844
+ if device_mac in self._gforces:
845
+ info["task"] = None
846
+ return
847
+
848
+ SdkLog.i(_TAG, f"Reconnecting {device_mac}, attempt {info['attempts']}")
849
+ try:
850
+ await self._do_connect(info["cmd"])
851
+ except Exception as e:
852
+ SdkLog.exception(_TAG, f"Reconnect attempt failed for {device_mac}: {e}")
853
+ # 失败后会再次由 _schedule_reconnect 决定是否继续
854
+ self._schedule_reconnect(device_mac)
855
+ finally:
856
+ if info is not None:
857
+ info["task"] = None
858
+
755
859
  async def _do_disconnect(self, cmd: dict):
756
860
 
757
861
  device_mac = cmd["device_mac"]
@@ -1160,6 +1264,8 @@ class BleakProcess(multiprocessing.Process):
1160
1264
 
1161
1265
  result = "Error: Not supported"
1162
1266
  needs_restart = False
1267
+ oym_needs_subscribe = False
1268
+ was_streaming = ctx.isDataTransfering
1163
1269
 
1164
1270
  ntf_keys = [
1165
1271
  "NTF_GEST", "NTF_EMG", "NTF_EEG", "NTF_ECG", "NTF_IMU", "NTF_BRTH", "NTF_IMPEDANCE",
@@ -1220,14 +1326,19 @@ class BleakProcess(multiprocessing.Process):
1220
1326
  except Exception as e:
1221
1327
  SdkLog.exception(_TAG, f"_do_set_param set_function_switch failed: {device_mac}")
1222
1328
  result = "ERROR: set_function_switch fail: " + str(e)
1223
- elif ctx.isDataTransfering:
1329
+ if ctx.getChipType() == BLEChipType.OYM:
1330
+ oym_needs_subscribe = True
1331
+ elif was_streaming:
1224
1332
  needs_restart = True
1225
1333
  if ctx.getChipType() == BLEChipType.OYM:
1226
- try:
1227
- await ctx.gForce.set_subscription(ctx.notifyDataFlag)
1228
- except Exception as e:
1229
- SdkLog.exception(_TAG, f"_do_set_param set_subscription failed: {device_mac}")
1230
- result = "ERROR: set_subscription fail: " + str(e)
1334
+ oym_needs_subscribe = True
1335
+ elif ctx.getChipType() == BLEChipType.OYM:
1336
+ # 未在传输时,直接更新 OYM 订阅掩码
1337
+ try:
1338
+ await ctx.gForce.set_subscription(ctx.notifyDataFlag)
1339
+ except Exception as e:
1340
+ SdkLog.exception(_TAG, f"_do_set_param set_subscription failed: {device_mac}")
1341
+ result = "ERROR: set_subscription fail: " + str(e)
1231
1342
 
1232
1343
  if key in ["FILTER_50HZ", "FILTER_60HZ", "FILTER_HPF", "FILTER_LPF"]:
1233
1344
  if value in ["ON", "OFF"]:
@@ -1239,13 +1350,28 @@ class BleakProcess(multiprocessing.Process):
1239
1350
  SdkLog.exception(_TAG, f"_do_set_param setFilter failed: {device_mac} {key}={value}")
1240
1351
  result = "ERROR: " + str(e)
1241
1352
 
1242
- if needs_restart:
1353
+ if needs_restart or oym_needs_subscribe:
1243
1354
  try:
1244
- await ctx.stop_streaming()
1245
- await ctx.start_streaming()
1355
+ is_oym = ctx.getChipType() == BLEChipType.OYM
1356
+ # macOS CoreBluetooth 在流传输中 stop_notify 容易导致设备断开,
1357
+ # 因此 OYM 设备在 CoreBluetooth 后端上直接更新订阅掩码,不启停通知。
1358
+ oym_skip_stop_start = is_oym and _is_core_bluetooth(ctx.gForce.client)
1359
+ if oym_skip_stop_start:
1360
+ SdkLog.d(_TAG, f"OYM CoreBluetooth: skip stop/start, set subscription directly for {device_mac}")
1361
+ if was_streaming and not oym_skip_stop_start:
1362
+ await ctx.stop_streaming()
1363
+ if oym_needs_subscribe and is_oym:
1364
+ await ctx.gForce.set_subscription(ctx.notifyDataFlag)
1365
+ if was_streaming and not oym_skip_stop_start:
1366
+ await ctx.start_streaming()
1246
1367
  except Exception as e:
1368
+ err_msg = str(e)
1247
1369
  SdkLog.exception(_TAG, f"_do_set_param restart stream failed: {device_mac}")
1248
- result = "ERROR: restart stream fail: " + str(e)
1370
+ result = "ERROR: restart stream fail: " + err_msg
1371
+ # 如果是因为设备已经断开,直接触发自动重连
1372
+ if "disconnected" in err_msg.lower() or "not connected" in err_msg.lower():
1373
+ SdkLog.w(_TAG, f"Device disconnected during setParam restart, scheduling reconnect for {device_mac}")
1374
+ self._schedule_reconnect(device_mac)
1249
1375
 
1250
1376
  if key == "DEBUG_LOG_PATH":
1251
1377
  try:
@@ -2,6 +2,7 @@ import asyncio
2
2
  import queue
3
3
  import struct
4
4
  import platform
5
+ import time
5
6
  from contextlib import suppress
6
7
  from datetime import datetime
7
8
  from dataclasses import dataclass
@@ -453,6 +454,8 @@ class GForce:
453
454
  self.cmd_char = cmd_char
454
455
  self.data_char = data_char
455
456
  self.responses: Dict[Command, queue.Queue] = {}
457
+ self.last_command_failure_time: Optional[float] = None
458
+ self.last_command_failure_cmd: Optional[int] = None
456
459
  self.resolution = SampleResolution.BITS_8
457
460
  self._num_channels = 8
458
461
  self._device = device
@@ -1091,6 +1094,8 @@ class GForce:
1091
1094
  timeout=2
1092
1095
  )
1093
1096
  except Exception as e:
1097
+ self.last_command_failure_time = time.time()
1098
+ self.last_command_failure_cmd = req.cmd
1094
1099
  SdkLog.exception(_TAG, f"_send_request write_gatt_char failed: {req.cmd}")
1095
1100
  if req.has_res:
1096
1101
  self.responses[req.cmd] = None
@@ -9,7 +9,7 @@ import struct
9
9
  import math
10
10
  import threading
11
11
  import time
12
- from typing import Deque, List
12
+ from typing import Deque, List, Optional
13
13
  from concurrent.futures import ThreadPoolExecutor
14
14
  import csv
15
15
  from sensor import sensor_utils
@@ -30,6 +30,8 @@ QUAT_SCALE = 1 / 1073741824.0 # 2^30
30
30
  _MAX_CONCAT_BUFFER_SIZE = 64 * 1024 # 64KB
31
31
  # data 日志队列最大长度,写盘跟不上时丢弃日志而不是阻塞解析线程
32
32
  _DATA_LOG_QUEUE_MAXSIZE = 1000
33
+ # 包序号最大允许跳变值,超过视为非法跳变/丢包
34
+ _MAX_ALLOWED_PACKAGE_INDEX_DELTA = 100
33
35
 
34
36
  class SensorDataType(IntEnum):
35
37
  DATA_TYPE_EEG = 0
@@ -83,7 +85,13 @@ class ReadSamplesResult(IntEnum):
83
85
 
84
86
 
85
87
  class SensorProfileDataCtx:
86
- def __init__(self, gForce: GForce, deviceMac: str, buf: Queue[bytes]):
88
+ def __init__(
89
+ self,
90
+ gForce: GForce,
91
+ deviceMac: str,
92
+ buf: Queue[bytes],
93
+ on_reconnect_request: Optional[callable] = None,
94
+ ):
87
95
  self.featureMap = 0
88
96
  self.notifyDataFlag: DataSubscription = 0
89
97
 
@@ -150,6 +158,15 @@ class SensorProfileDataCtx:
150
158
  self._watchdog_thread.daemon = True
151
159
  self._watchdog_thread.start()
152
160
 
161
+ # 非法跳变监控:持续超过阈值则重启数据通知
162
+ self._illegal_jump_start_time: Optional[float] = None
163
+ self._is_restarting_stream = False
164
+
165
+ # 命令失败导致异常断开后自动重连
166
+ self._on_reconnect_request = on_reconnect_request
167
+ self._disconnected_start_time: Optional[float] = None
168
+ self._reconnect_requested = False
169
+
153
170
  def close(self):
154
171
  self._is_running = False
155
172
  if self.debugCSVWriter != None:
@@ -188,10 +205,28 @@ class SensorProfileDataCtx:
188
205
  pass
189
206
  self._watchdog_thread = None
190
207
 
208
+ async def _restart_streaming(self):
209
+ """非法跳变持续时重启数据通知。"""
210
+ if self._is_restarting_stream:
211
+ return
212
+ self._is_restarting_stream = True
213
+ try:
214
+ SdkLog.w(_TAG, f"Illegal package index jump persisted > 5s, restarting data notification for {self.deviceMac}")
215
+ await self.stop_streaming()
216
+ await self.start_streaming(self._rawDataBuffer)
217
+ self._illegal_jump_start_time = None
218
+ except Exception as e:
219
+ SdkLog.exception(_TAG, f"Restart streaming failed for {self.deviceMac}: {e}")
220
+ finally:
221
+ self._is_restarting_stream = False
222
+
191
223
  def _watchdog_loop(self):
192
224
  """守护线程:监控解析线程进度,防止卡死后内存无限增长。"""
193
225
  WATCHDOG_INTERVAL = 1.0 # 检查间隔
194
226
  WATCHDOG_TIMEOUT = 5.0 # 无进度报警阈值
227
+ ILLEGAL_JUMP_TIMEOUT = 5.0 # 非法跳变持续阈值
228
+ RECONNECT_AFTER_DISCONNECT = 3.0 # 断开后等待多久尝试重连
229
+ RECENT_CMD_FAILURE_WINDOW = 30.0 # 命令失败多久内视为相关
195
230
  RAW_BUF_CLEAR_THRESHOLD = 1500 # 超过此值且无进度时清空
196
231
 
197
232
  while not self._watchdog_stop_event.is_set():
@@ -229,6 +264,46 @@ class SensorProfileDataCtx:
229
264
  self._concatDataBuffer.clear()
230
265
  # 清空后更新时间戳,避免持续报警
231
266
  self._last_progress_time = time.time()
267
+
268
+ # 非法跳变持续超过阈值则重启数据通知
269
+ if (self._illegal_jump_start_time is not None
270
+ and not self._is_restarting_stream
271
+ and self.isDataTransfering):
272
+ jump_elapsed = time.time() - self._illegal_jump_start_time
273
+ if jump_elapsed > ILLEGAL_JUMP_TIMEOUT:
274
+ SdkLog.w(_TAG, f"Illegal jump persisted for {jump_elapsed:.1f}s, restarting stream for {self.deviceMac}")
275
+ try:
276
+ asyncio.run_coroutine_threadsafe(
277
+ self._restart_streaming(), self.gForce.gforce_event_loop
278
+ )
279
+ except Exception as e:
280
+ SdkLog.exception(_TAG, f"Failed to schedule stream restart for {self.deviceMac}: {e}")
281
+
282
+ # 命令失败后设备异常断开,触发自动重连
283
+ if self._on_reconnect_request is not None:
284
+ client = getattr(self.gForce, "client", None)
285
+ is_connected = getattr(client, "is_connected", False) if client is not None else False
286
+ if is_connected:
287
+ self._disconnected_start_time = None
288
+ self._reconnect_requested = False
289
+ else:
290
+ if self._disconnected_start_time is None:
291
+ self._disconnected_start_time = time.time()
292
+ disconnected_elapsed = time.time() - self._disconnected_start_time
293
+ cmd_fail_time = getattr(self.gForce, "last_command_failure_time", None)
294
+ recent_cmd_failure = (
295
+ cmd_fail_time is not None
296
+ and (time.time() - cmd_fail_time) < RECENT_CMD_FAILURE_WINDOW
297
+ )
298
+ if (disconnected_elapsed > RECONNECT_AFTER_DISCONNECT
299
+ and recent_cmd_failure
300
+ and not self._reconnect_requested):
301
+ SdkLog.w(_TAG, f"Device disconnected after command failure, scheduling reconnect for {self.deviceMac}")
302
+ self._reconnect_requested = True
303
+ try:
304
+ self._on_reconnect_request()
305
+ except Exception as e:
306
+ SdkLog.exception(_TAG, f"Reconnect request failed for {self.deviceMac}: {e}")
232
307
  except Exception as e:
233
308
  SdkLog.exception(_TAG, f"Watchdog error: {e}")
234
309
 
@@ -408,6 +483,15 @@ class SensorProfileDataCtx:
408
483
  self.sensorDatas[SensorDataType.DATA_TYPE_EMG] = data
409
484
  self.isNewEMG = isNewEMG
410
485
 
486
+ # 新 EMG + OYM 芯片:默认只开 EMG,关闭 Gesture 和 IMU
487
+ if isNewEMG and self._chip_type == BLEChipType.OYM:
488
+ self.notify_map["NTF_GEST"] = "OFF"
489
+ self.notify_map["NTF_IMU"] = "OFF"
490
+ self.notify_map["NTF_GFORCE_ACC"] = "OFF"
491
+ self.notify_map["NTF_GFORCE_GYRO"] = "OFF"
492
+ self.notify_map["NTF_GFORCE_QUAT"] = "OFF"
493
+ self.notify_map["NTF_GFORCE_EULER"] = "OFF"
494
+
411
495
  # 老 EMG 设备不支持 FILTER
412
496
  if not isNewEMG:
413
497
  self.filter_map.clear()
@@ -630,7 +714,9 @@ class SensorProfileDataCtx:
630
714
  data.clear()
631
715
  self.sensorDatas[SensorDataType.DATA_TYPE_EULER] = data
632
716
 
633
- self.notifyDataFlag |= DataSubscription.DNF_IMU
717
+ # EEG + OYM 设备默认不订阅 IMU
718
+ if not (self._chip_type == BLEChipType.OYM and self.hasEEG()):
719
+ self.notifyDataFlag |= DataSubscription.DNF_IMU
634
720
  if self._device_info is not None:
635
721
  self._device_info.AccChannelCount = 3
636
722
  self._device_info.GyroChannelCount = 3
@@ -786,6 +872,14 @@ class SensorProfileDataCtx:
786
872
  self._device_info = info
787
873
  await self.initDataTransfer(True)
788
874
 
875
+ # EEG + OYM 设备默认关闭 IMU
876
+ if self._chip_type == BLEChipType.OYM and self.hasEEG():
877
+ self.notify_map["NTF_IMU"] = "OFF"
878
+ self.notify_map["NTF_GFORCE_ACC"] = "OFF"
879
+ self.notify_map["NTF_GFORCE_GYRO"] = "OFF"
880
+ self.notify_map["NTF_GFORCE_QUAT"] = "OFF"
881
+ self.notify_map["NTF_GFORCE_EULER"] = "OFF"
882
+
789
883
  if self.hasConcatBLE():
790
884
  self.notifyDataFlag |= DataSubscription.DNF_CONCAT_BLE
791
885
 
@@ -1489,6 +1583,7 @@ class SensorProfileDataCtx:
1489
1583
  if newPackageIndex <= 2 and lastPackageIndex >= maxPackageIndex - 2:
1490
1584
  packageIndex = maxPackageIndex + 1 + newPackageIndex
1491
1585
  elif newPackageIndex == lastPackageIndex:
1586
+ self._illegal_jump_start_time = None
1492
1587
  return ReadSamplesResult.Repeated
1493
1588
  elif newPackageIndex < lastPackageIndex:
1494
1589
  SdkLog.i(_TAG, (
@@ -1498,10 +1593,12 @@ class SensorProfileDataCtx:
1498
1593
  + "|CURR_IDX|" + str(newPackageIndex)
1499
1594
  + "|DELTA|" + str(lastPackageIndex - newPackageIndex)
1500
1595
  ))
1596
+ if self._illegal_jump_start_time is None:
1597
+ self._illegal_jump_start_time = time.time()
1501
1598
  return ReadSamplesResult.Error
1502
1599
 
1503
1600
  deltaPackageIndex = packageIndex - lastPackageIndex
1504
- if deltaPackageIndex > 20:
1601
+ if deltaPackageIndex > _MAX_ALLOWED_PACKAGE_INDEX_DELTA:
1505
1602
  SdkLog.i(_TAG, (
1506
1603
  "Illegal package index jump|MAC|" + str(sensorData.deviceMac)
1507
1604
  + "|TYPE|" + str(sensorData.dataType)
@@ -1509,6 +1606,8 @@ class SensorProfileDataCtx:
1509
1606
  + "|CURR_IDX|" + str(newPackageIndex)
1510
1607
  + "|DELTA|" + str(deltaPackageIndex)
1511
1608
  ))
1609
+ if self._illegal_jump_start_time is None:
1610
+ self._illegal_jump_start_time = time.time()
1512
1611
  return ReadSamplesResult.Error
1513
1612
 
1514
1613
  lostPackageCounter = deltaPackageIndex - 1
@@ -1522,7 +1621,7 @@ class SensorProfileDataCtx:
1522
1621
  + "|COUNT|" + str(lostSampleCount)
1523
1622
  ))
1524
1623
 
1525
- if lostPackageCounter < 20:
1624
+ if lostPackageCounter < _MAX_ALLOWED_PACKAGE_INDEX_DELTA:
1526
1625
  self.readSamples(data, sensorData, 0, dataGap, lostSampleCount)
1527
1626
 
1528
1627
  sensorData.lastPackageIndex = newPackageIndex - 1
@@ -1534,6 +1633,7 @@ class SensorProfileDataCtx:
1534
1633
  self.readSamples(data, sensorData, dataOffset, dataGap, 0)
1535
1634
 
1536
1635
  sensorData.lastPackageCounter += 1
1636
+ self._illegal_jump_start_time = None
1537
1637
  except Exception as e:
1538
1638
  SdkLog.exception(_TAG, "Unexpected error")
1539
1639
  return ReadSamplesResult.Error
@@ -93,6 +93,11 @@ class SensorProfile:
93
93
  self._device_info: Optional[DeviceInfo] = None
94
94
  self._chip_type: BLEChipType = BLEChipType.Unknown
95
95
 
96
+ # 异常断开后自动恢复流状态所需信息
97
+ self._last_init_args: Optional[tuple] = None # (packageSampleCount, powerRefreshInterval)
98
+ self._was_streaming_before_disconnect = False
99
+ self._auto_restoring = False
100
+
96
101
  # 用于在独立线程中执行用户回调,避免阻塞 BLE/数据解析线程
97
102
  # onDataCallback 使用单线程池,保证数据回调严格按到达顺序执行
98
103
  self._callback_executor = ThreadPoolExecutor(max_workers=4)
@@ -222,7 +227,8 @@ class SensorProfile:
222
227
  return self._device_state
223
228
 
224
229
  def _set_device_state(self, newState: DeviceStateEx):
225
- if self._device_state != newState:
230
+ old_state = self._device_state
231
+ if old_state != newState:
226
232
  self._device_state = newState
227
233
  if newState == DeviceStateEx.Disconnected:
228
234
  self._has_inited = False
@@ -235,6 +241,33 @@ class SensorProfile:
235
241
  SdkLog.e(_TAG, f"Error occurred while processing state change: {e}")
236
242
  raise RuntimeError("Set device state %s fail: %s" % (self.BLEDevice.Name , e))
237
243
 
244
+ # 异常断开后重连成功,自动恢复 init + 数据流
245
+ if (newState == DeviceStateEx.Connected
246
+ and old_state == DeviceStateEx.Disconnected
247
+ and self._last_init_args is not None
248
+ and self._was_streaming_before_disconnect
249
+ and not self._auto_restoring):
250
+ SdkLog.i(_TAG, f"Auto-restoring stream state for {self._device_mac}")
251
+ threading.Thread(target=self._auto_restore, daemon=True).start()
252
+
253
+ def _auto_restore(self):
254
+ """在异常断开后自动恢复 init 和数据流。"""
255
+ if self._auto_restoring:
256
+ return
257
+ self._auto_restoring = True
258
+ try:
259
+ package_sample_count, power_refresh_interval = self._last_init_args
260
+ SdkLog.i(_TAG, f"Auto-restore init for {self._device_mac}")
261
+ if self.init(package_sample_count, power_refresh_interval):
262
+ SdkLog.i(_TAG, f"Auto-restore start data notification for {self._device_mac}")
263
+ self.startDataNotification()
264
+ else:
265
+ SdkLog.w(_TAG, f"Auto-restore init failed for {self._device_mac}")
266
+ except Exception as e:
267
+ SdkLog.exception(_TAG, f"Auto-restore failed for {self._device_mac}: {e}")
268
+ finally:
269
+ self._auto_restoring = False
270
+
238
271
  @property
239
272
  def hasInited(self) -> bool:
240
273
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sensor-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python sdk for Synchroni
5
5
  Home-page: https://github.com/oymotion/SynchroniSDKPython
6
6
  Author: Martin Ye
@@ -8,7 +8,7 @@ with open(os.path.join(this_directory, "README.md"), "r", encoding="utf-8") as f
8
8
 
9
9
  setup(
10
10
  name="sensor-sdk",
11
- version="0.2.0",
11
+ version="0.2.2",
12
12
  description="Python sdk for Synchroni",
13
13
  long_description=long_description,
14
14
  long_description_content_type="text/markdown",
File without changes
File without changes
File without changes
File without changes