sensor-sdk 0.1.9__tar.gz → 0.2.1__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.1.9/sensor_sdk.egg-info → sensor_sdk-0.2.1}/PKG-INFO +1 -1
  2. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/bleak_no_ack_patch.py +6 -3
  3. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/bleak_process.py +146 -7
  4. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/gforce.py +5 -0
  5. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_data_context.py +100 -6
  6. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1/sensor_sdk.egg-info}/PKG-INFO +1 -1
  7. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/setup.py +1 -1
  8. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/LICENSE.txt +0 -0
  9. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/README.md +0 -0
  10. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/__init__.py +0 -0
  11. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/bleak_host.py +0 -0
  12. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/fb/DataType.py +0 -0
  13. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/fb/Sample.py +0 -0
  14. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/fb/SensorData.py +0 -0
  15. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/fb/__init__.py +0 -0
  16. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sdk_log.py +0 -0
  17. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_controller.py +0 -0
  18. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_data.py +0 -0
  19. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_data_pool.py +0 -0
  20. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_device.py +0 -0
  21. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_profile.py +0 -0
  22. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/sensor_utils.py +0 -0
  23. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor/winrt_high_throughput.py +0 -0
  24. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor_sdk.egg-info/SOURCES.txt +0 -0
  25. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor_sdk.egg-info/dependency_links.txt +0 -0
  26. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor_sdk.egg-info/requires.txt +0 -0
  27. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor_sdk.egg-info/top_level.txt +0 -0
  28. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/sensor_sdk.egg-info/zip-safe +0 -0
  29. {sensor_sdk-0.1.9 → sensor_sdk-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sensor-sdk
3
- Version: 0.1.9
3
+ Version: 0.2.1
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,10 @@ 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
+
28
32
 
29
33
  def _extract_mac(_device: bleak.BLEDevice, adv: AdvertisementData) -> str:
30
34
 
@@ -93,6 +97,8 @@ class BleakProcess(multiprocessing.Process):
93
97
  self._power_intervals: dict = {} # mac -> int (ms)
94
98
  self._data_tasks: dict = {} # mac -> asyncio.Task
95
99
  self._battery_tasks: dict = {} # mac -> asyncio.Task
100
+ self._reconnect_info: dict = {} # mac -> {cmd, attempts, task, normal_disconnect}
101
+ self._main_event_loop = None # BleakProcess 主事件循环
96
102
 
97
103
  # Per-device event loops and threads
98
104
  self._event_loops: dict = {} # mac -> asyncio.AbstractEventLoop
@@ -344,6 +350,7 @@ class BleakProcess(multiprocessing.Process):
344
350
  self._msg_queue = queue.Queue()
345
351
 
346
352
  async def _run_main():
353
+ self._main_event_loop = asyncio.get_running_loop()
347
354
  publisher_task = asyncio.create_task(self._publisher_task())
348
355
  try:
349
356
  await self._main_loop()
@@ -581,6 +588,14 @@ class BleakProcess(multiprocessing.Process):
581
588
  name = cmd.get("name", "")
582
589
  service_data = cmd.get("service_data", {})
583
590
 
591
+ # 保存连接信息,用于异常断开后自动重连
592
+ self._reconnect_info[device_mac] = {
593
+ "cmd": cmd,
594
+ "attempts": 0,
595
+ "task": None,
596
+ "normal_disconnect": False,
597
+ }
598
+
584
599
  event_loop = self._event_loops.get(device_mac)
585
600
  gforce_event_loop = self._gforce_event_loop
586
601
  data_event_loop = self._data_event_loops.get(device_mac)
@@ -676,8 +691,18 @@ class BleakProcess(multiprocessing.Process):
676
691
  self._raw_bufs[device_mac] = raw_buf
677
692
  self._device_states[device_mac] = "Connected"
678
693
 
694
+ # 重连成功,重置重连计数
695
+ reconnect_info = self._reconnect_info.get(device_mac)
696
+ if reconnect_info is not None:
697
+ reconnect_info["attempts"] = 0
698
+
679
699
  # Create data context
680
- data_ctx = SensorProfileDataCtx(gforce, device_mac, raw_buf)
700
+ data_ctx = SensorProfileDataCtx(
701
+ gforce,
702
+ device_mac,
703
+ raw_buf,
704
+ on_reconnect_request=lambda: self._schedule_reconnect(device_mac),
705
+ )
681
706
  self._data_ctxs[device_mac] = data_ctx
682
707
 
683
708
  # Start data processing loop in data_event_loop
@@ -705,6 +730,19 @@ class BleakProcess(multiprocessing.Process):
705
730
  if device_mac not in self._gforces and not disconnect_client:
706
731
  return # Already cleaned
707
732
 
733
+ # 用户主动断开时标记为正常断开,并取消正在进行的自动重连
734
+ if disconnect_client:
735
+ info = self._reconnect_info.get(device_mac)
736
+ if info is not None:
737
+ info["normal_disconnect"] = True
738
+ task = info.get("task")
739
+ if task is not None:
740
+ try:
741
+ task.cancel()
742
+ except Exception:
743
+ pass
744
+ info["task"] = None
745
+
708
746
  # Cancel data task (running in data_event_loop)
709
747
  if device_mac in self._data_tasks:
710
748
  task = self._data_tasks.pop(device_mac, None)
@@ -750,8 +788,63 @@ class BleakProcess(multiprocessing.Process):
750
788
  self._raw_bufs.pop(device_mac, None)
751
789
  self._device_states[device_mac] = "Disconnected"
752
790
 
791
+ # 正常断开后清理重连信息;异常断开保留以继续自动重连
792
+ if disconnect_client:
793
+ self._reconnect_info.pop(device_mac, None)
794
+
753
795
  self._publish("state_changed", device_mac=device_mac, state="Disconnected")
754
796
 
797
+ def _schedule_reconnect(self, device_mac: str):
798
+ """从 watchdog 线程调用,调度一次自动重连。"""
799
+ info = self._reconnect_info.get(device_mac)
800
+ if info is None or info.get("normal_disconnect"):
801
+ return
802
+ if info.get("task") is not None:
803
+ return # 已有重连任务在进行
804
+ if info["attempts"] >= _MAX_RECONNECT_ATTEMPTS:
805
+ SdkLog.e(_TAG, f"Max reconnect attempts reached for {device_mac}")
806
+ return
807
+
808
+ info["attempts"] += 1
809
+ SdkLog.i(_TAG, f"Scheduling reconnect attempt {info['attempts']} for {device_mac}")
810
+
811
+ loop = self._main_event_loop
812
+ if loop is None or loop.is_closed():
813
+ SdkLog.e(_TAG, f"Main event loop not available, cannot reconnect {device_mac}")
814
+ return
815
+
816
+ async def _delayed_reconnect():
817
+ await asyncio.sleep(_RECONNECT_DELAY_SECONDS)
818
+ await self._do_reconnect(device_mac)
819
+
820
+ try:
821
+ info["task"] = asyncio.run_coroutine_threadsafe(_delayed_reconnect(), loop)
822
+ except Exception as e:
823
+ SdkLog.exception(_TAG, f"Failed to schedule reconnect for {device_mac}: {e}")
824
+ info["task"] = None
825
+
826
+ async def _do_reconnect(self, device_mac: str):
827
+ """执行一次自动重连。"""
828
+ info = self._reconnect_info.get(device_mac)
829
+ if info is None or info.get("normal_disconnect"):
830
+ return
831
+
832
+ # 已经连接则无需重连
833
+ if device_mac in self._gforces:
834
+ info["task"] = None
835
+ return
836
+
837
+ SdkLog.i(_TAG, f"Reconnecting {device_mac}, attempt {info['attempts']}")
838
+ try:
839
+ await self._do_connect(info["cmd"])
840
+ except Exception as e:
841
+ SdkLog.exception(_TAG, f"Reconnect attempt failed for {device_mac}: {e}")
842
+ # 失败后会再次由 _schedule_reconnect 决定是否继续
843
+ self._schedule_reconnect(device_mac)
844
+ finally:
845
+ if info is not None:
846
+ info["task"] = None
847
+
755
848
  async def _do_disconnect(self, cmd: dict):
756
849
 
757
850
  device_mac = cmd["device_mac"]
@@ -1111,6 +1204,12 @@ class BleakProcess(multiprocessing.Process):
1111
1204
  sorted_keys = sorted(ctx.notify_map.keys())
1112
1205
  result = "|".join(f"{k}|{ctx.notify_map[k]}" for k in sorted_keys)
1113
1206
 
1207
+ if key == "NTF_IMU":
1208
+ imu_sub_keys = ["NTF_GFORCE_ACC", "NTF_GFORCE_GYRO", "NTF_GFORCE_QUAT", "NTF_GFORCE_EULER"]
1209
+ result = "ON" if all(ctx.notify_map.get(k) == "ON" for k in imu_sub_keys) else "OFF"
1210
+ elif key in ctx.notify_map:
1211
+ result = ctx.notify_map[key]
1212
+
1114
1213
  if key == "DEBUG_LOG_PATH":
1115
1214
  result = SdkLog.get_log_path() or ""
1116
1215
 
@@ -1154,6 +1253,8 @@ class BleakProcess(multiprocessing.Process):
1154
1253
 
1155
1254
  result = "Error: Not supported"
1156
1255
  needs_restart = False
1256
+ oym_needs_subscribe = False
1257
+ was_streaming = ctx.isDataTransfering
1157
1258
 
1158
1259
  ntf_keys = [
1159
1260
  "NTF_GEST", "NTF_EMG", "NTF_EEG", "NTF_ECG", "NTF_IMU", "NTF_BRTH", "NTF_IMPEDANCE",
@@ -1168,6 +1269,12 @@ class BleakProcess(multiprocessing.Process):
1168
1269
  if key == "NTF_PPG_RAW":
1169
1270
  map_key = "NTF_PPG"
1170
1271
 
1272
+ # IMU 总开关同时控制 ACC/GYRO/QUAT/EULER
1273
+ imu_sub_keys = ["NTF_GFORCE_ACC", "NTF_GFORCE_GYRO", "NTF_GFORCE_QUAT", "NTF_GFORCE_EULER"]
1274
+ if map_key == "NTF_IMU":
1275
+ for sub in imu_sub_keys:
1276
+ ctx.notify_map[sub] = value
1277
+
1171
1278
  # 老版本 EMG 设备上 Gesture 与 EMG 互斥,自动切换
1172
1279
  if not ctx.isNewEMG:
1173
1280
  if map_key == "NTF_GEST" and value == "ON" and ctx.notify_map.get("NTF_EMG") == "ON":
@@ -1182,12 +1289,40 @@ class BleakProcess(multiprocessing.Process):
1182
1289
  ctx.notify_map[map_key] = value
1183
1290
  result = "OK"
1184
1291
  else:
1292
+ # 新 EMG:Gesture 依赖 EMG 数据,关闭 EMG 时同步关闭 Gesture;打开 Gesture 时自动打开 EMG
1293
+ if map_key == "NTF_EMG" and value == "OFF":
1294
+ ctx.notify_map["NTF_GEST"] = "OFF"
1295
+ elif map_key == "NTF_GEST" and value == "ON" and ctx.notify_map.get("NTF_EMG") != "ON":
1296
+ ctx.notify_map["NTF_EMG"] = "ON"
1185
1297
  ctx.notify_map[map_key] = value
1186
1298
  result = "OK"
1187
- if result == "OK" and ctx.hasInit() and ctx.isDataTransfering:
1299
+
1300
+ # 单个 IMU 子开关变化时,同步更新 NTF_IMU 总开关的聚合状态
1301
+ if map_key in imu_sub_keys:
1302
+ ctx.notify_map["NTF_IMU"] = "ON" if all(ctx.notify_map.get(k) == "ON" for k in imu_sub_keys) else "OFF"
1303
+
1304
+ if result == "OK" and ctx.hasInit():
1188
1305
  ctx._buildNotifyDataFlag()
1189
- needs_restart = True
1190
- if ctx.getChipType() == BLEChipType.OYM:
1306
+
1307
+ # EMG 设备通过 function switch 控制 EMG/Gesture 输出,bit0=gesture, bit1=emg
1308
+ if ctx.isNewEMG and map_key in ("NTF_EMG", "NTF_GEST"):
1309
+ emg_bit = 1 if ctx.notify_map.get("NTF_EMG") == "ON" else 0
1310
+ gest_bit = 1 if (emg_bit and ctx.notify_map.get("NTF_GEST") == "ON") else 0
1311
+ func_switch = (emg_bit << 1) | gest_bit
1312
+ try:
1313
+ await ctx.gForce.set_function_switch(func_switch)
1314
+ await asyncio.sleep(0.5)
1315
+ except Exception as e:
1316
+ SdkLog.exception(_TAG, f"_do_set_param set_function_switch failed: {device_mac}")
1317
+ result = "ERROR: set_function_switch fail: " + str(e)
1318
+ if ctx.getChipType() == BLEChipType.OYM:
1319
+ oym_needs_subscribe = True
1320
+ elif was_streaming:
1321
+ needs_restart = True
1322
+ if ctx.getChipType() == BLEChipType.OYM:
1323
+ oym_needs_subscribe = True
1324
+ elif ctx.getChipType() == BLEChipType.OYM:
1325
+ # 未在传输时,直接更新 OYM 订阅掩码
1191
1326
  try:
1192
1327
  await ctx.gForce.set_subscription(ctx.notifyDataFlag)
1193
1328
  except Exception as e:
@@ -1204,10 +1339,14 @@ class BleakProcess(multiprocessing.Process):
1204
1339
  SdkLog.exception(_TAG, f"_do_set_param setFilter failed: {device_mac} {key}={value}")
1205
1340
  result = "ERROR: " + str(e)
1206
1341
 
1207
- if needs_restart:
1342
+ if needs_restart or oym_needs_subscribe:
1208
1343
  try:
1209
- await ctx.stop_streaming()
1210
- await ctx.start_streaming()
1344
+ if was_streaming:
1345
+ await ctx.stop_streaming()
1346
+ if oym_needs_subscribe and ctx.getChipType() == BLEChipType.OYM:
1347
+ await ctx.gForce.set_subscription(ctx.notifyDataFlag)
1348
+ if was_streaming:
1349
+ await ctx.start_streaming()
1211
1350
  except Exception as e:
1212
1351
  SdkLog.exception(_TAG, f"_do_set_param restart stream failed: {device_mac}")
1213
1352
  result = "ERROR: restart stream fail: " + str(e)
@@ -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
 
@@ -390,8 +465,12 @@ class SensorProfileDataCtx:
390
465
  config.batch_len = 128
391
466
 
392
467
  if isNewEMG:
393
- await self.gForce.set_function_switch(0b11)
394
-
468
+ # 新版 EMG:bit0=gesture,bit1=emg;Gesture 依赖 EMG,EMG 关闭时 Gesture 同步关闭
469
+ emg_bit = 1 if self.notify_map.get("NTF_EMG") == "ON" else 0
470
+ gest_bit = 1 if (emg_bit and self.notify_map.get("NTF_GEST") == "ON") else 0
471
+ await self.gForce.set_function_switch((emg_bit << 1) | gest_bit)
472
+ await asyncio.sleep(0.5)
473
+
395
474
  await self.gForce.set_emg_raw_data_config(config)
396
475
  await self.gForce.set_package_id(True)
397
476
 
@@ -404,6 +483,15 @@ class SensorProfileDataCtx:
404
483
  self.sensorDatas[SensorDataType.DATA_TYPE_EMG] = data
405
484
  self.isNewEMG = isNewEMG
406
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
+
407
495
  # 老 EMG 设备不支持 FILTER
408
496
  if not isNewEMG:
409
497
  self.filter_map.clear()
@@ -1485,6 +1573,7 @@ class SensorProfileDataCtx:
1485
1573
  if newPackageIndex <= 2 and lastPackageIndex >= maxPackageIndex - 2:
1486
1574
  packageIndex = maxPackageIndex + 1 + newPackageIndex
1487
1575
  elif newPackageIndex == lastPackageIndex:
1576
+ self._illegal_jump_start_time = None
1488
1577
  return ReadSamplesResult.Repeated
1489
1578
  elif newPackageIndex < lastPackageIndex:
1490
1579
  SdkLog.i(_TAG, (
@@ -1494,10 +1583,12 @@ class SensorProfileDataCtx:
1494
1583
  + "|CURR_IDX|" + str(newPackageIndex)
1495
1584
  + "|DELTA|" + str(lastPackageIndex - newPackageIndex)
1496
1585
  ))
1586
+ if self._illegal_jump_start_time is None:
1587
+ self._illegal_jump_start_time = time.time()
1497
1588
  return ReadSamplesResult.Error
1498
1589
 
1499
1590
  deltaPackageIndex = packageIndex - lastPackageIndex
1500
- if deltaPackageIndex > 20:
1591
+ if deltaPackageIndex > _MAX_ALLOWED_PACKAGE_INDEX_DELTA:
1501
1592
  SdkLog.i(_TAG, (
1502
1593
  "Illegal package index jump|MAC|" + str(sensorData.deviceMac)
1503
1594
  + "|TYPE|" + str(sensorData.dataType)
@@ -1505,6 +1596,8 @@ class SensorProfileDataCtx:
1505
1596
  + "|CURR_IDX|" + str(newPackageIndex)
1506
1597
  + "|DELTA|" + str(deltaPackageIndex)
1507
1598
  ))
1599
+ if self._illegal_jump_start_time is None:
1600
+ self._illegal_jump_start_time = time.time()
1508
1601
  return ReadSamplesResult.Error
1509
1602
 
1510
1603
  lostPackageCounter = deltaPackageIndex - 1
@@ -1518,7 +1611,7 @@ class SensorProfileDataCtx:
1518
1611
  + "|COUNT|" + str(lostSampleCount)
1519
1612
  ))
1520
1613
 
1521
- if lostPackageCounter < 20:
1614
+ if lostPackageCounter < _MAX_ALLOWED_PACKAGE_INDEX_DELTA:
1522
1615
  self.readSamples(data, sensorData, 0, dataGap, lostSampleCount)
1523
1616
 
1524
1617
  sensorData.lastPackageIndex = newPackageIndex - 1
@@ -1530,6 +1623,7 @@ class SensorProfileDataCtx:
1530
1623
  self.readSamples(data, sensorData, dataOffset, dataGap, 0)
1531
1624
 
1532
1625
  sensorData.lastPackageCounter += 1
1626
+ self._illegal_jump_start_time = None
1533
1627
  except Exception as e:
1534
1628
  SdkLog.exception(_TAG, "Unexpected error")
1535
1629
  return ReadSamplesResult.Error
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sensor-sdk
3
- Version: 0.1.9
3
+ Version: 0.2.1
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.1.9",
11
+ version="0.2.1",
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