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.
- {sensor_sdk-0.2.0/sensor_sdk.egg-info → sensor_sdk-0.2.2}/PKG-INFO +1 -1
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/bleak_no_ack_patch.py +6 -3
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/bleak_process.py +137 -11
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/gforce.py +5 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_data_context.py +105 -5
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_profile.py +34 -1
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2/sensor_sdk.egg-info}/PKG-INFO +1 -1
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/setup.py +1 -1
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/LICENSE.txt +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/README.md +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/__init__.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/bleak_host.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/DataType.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/Sample.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/SensorData.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/fb/__init__.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sdk_log.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_controller.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_data.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_data_pool.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_device.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/sensor_utils.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor/winrt_high_throughput.py +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/SOURCES.txt +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/dependency_links.txt +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/requires.txt +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/top_level.txt +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/sensor_sdk.egg-info/zip-safe +0 -0
- {sensor_sdk-0.2.0 → sensor_sdk-0.2.2}/setup.cfg +0 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
某些 Windows 主机上 write-with-response 会因为系统 ACK 等待导致命令超时,
|
|
4
4
|
把命令特征强制改成无响应写入可以显著提升命令通道的实时性。
|
|
5
5
|
|
|
6
|
-
默认只作用于 SDK 已知的两个 CMD 特征 UUID
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1245
|
-
|
|
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: " +
|
|
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__(
|
|
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
|
-
|
|
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 >
|
|
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 <
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|