yostlabs 2025.2.11__py3-none-any.whl → 2025.3.6__py3-none-any.whl

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.
@@ -0,0 +1,318 @@
1
+ import asyncio
2
+ import async_timeout
3
+ import time
4
+ from bleak import BleakScanner, BleakClient
5
+ from bleak.backends.device import BLEDevice
6
+ from bleak.backends.scanner import AdvertisementData
7
+ from bleak.backends.characteristic import BleakGATTCharacteristic
8
+ from bleak.exc import BleakDeviceNotFoundError
9
+
10
+ #Services
11
+ NORDIC_UART_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
12
+
13
+ #Characteristics
14
+ NORDIC_UART_RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
15
+ NORDIC_UART_TX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
16
+
17
+ DEVICE_NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb"
18
+ APPEARANCE_UUID = "00002a01-0000-1000-8000-00805f9b34fb"
19
+
20
+ FIRMWARE_REVISION_STRING_UUID = "00002a26-0000-1000-8000-00805f9b34fb"
21
+ HARDWARE_REVISION_STRING_UUID = "00002a27-0000-1000-8000-00805f9b34fb"
22
+ SERIAL_NUMBER_STRING_UUID = "00002a25-0000-1000-8000-00805f9b34fb"
23
+ MANUFACTURER_NAME_STRING_UUID = "00002a29-0000-1000-8000-00805f9b34fb"
24
+
25
+ class TssBLENoConnectionError(Exception): ...
26
+
27
+ from yostlabs.communication.base import *
28
+ class ThreespaceBLEComClass(ThreespaceComClass):
29
+
30
+ DEFAULT_TIMEOUT = 2
31
+
32
+ def __init__(self, ble: BleakClient | BLEDevice | str, discover_name: bool = True, discovery_timeout=5, error_on_disconnect=True):
33
+ """
34
+ Parameters
35
+ ----------
36
+ ble : Can be either a BleakClient, BleakDevice, MacAddress String, or localName string
37
+ discover_name : If true, a string ble parameter is interpreted as a localName, else as a MacAddress
38
+ discovery_timeout : Max amount of time in seconds to discover the BLE device for the corresponding MacAddress/localName
39
+ error_on_disconnect : If trying to read while the sensor is disconnected, an exception will be generated. This may be undesirable \
40
+ if it is expected that the sensor will frequently go in and out of range and the user wishes to preserve data (such as streaming)
41
+ """
42
+ self.event_loop = asyncio.new_event_loop()
43
+ bleak_options = { "timeout": discovery_timeout, "disconnected_callback": self.__on_disconnect }
44
+ if isinstance(ble, BleakClient): #Actual client
45
+ self.client = ble
46
+ self.__name = ble.address
47
+ elif isinstance(ble, str):
48
+ if discover_name: #Local Name stirng
49
+ device = self.event_loop.run_until_complete(BleakScanner.find_device_by_name(ble, timeout=discovery_timeout))
50
+ if device is None:
51
+ raise BleakDeviceNotFoundError(ble)
52
+ self.client = BleakClient(device, **bleak_options)
53
+ self.__name = ble
54
+ else: #Address string
55
+ self.client = BleakClient(ble, **bleak_options)
56
+ self.__name = self.client.address
57
+ elif isinstance(ble, BLEDevice):
58
+ self.client = BleakClient(ble, **bleak_options)
59
+ self.__name = ble.name #Use the local name instead of the address
60
+ else:
61
+ raise TypeError("Invalid type for creating a ThreespaceBLEComClass:", type(ble), ble)
62
+
63
+ self.__timeout = self.DEFAULT_TIMEOUT
64
+
65
+ self.buffer = bytearray()
66
+ self.data_read_event = asyncio.Event()
67
+
68
+ #Default to 20, will update on open
69
+ self.max_packet_size = 20
70
+
71
+ self.error_on_disconnect = error_on_disconnect
72
+ #is_connected is different from open.
73
+ #check_open() should return is_connected as that is what the user likely wants.
74
+ #open is whether or not the client will auto connect to the device when rediscovered.
75
+ #This file is set up to automatically close the connection if a method is called and is_connected is False
76
+ #This behavior might be specific to Windows.
77
+ self.__opened = False
78
+ #client.is_connected is really slow (noticeable when called in bulk, which happens do to the assert_connected)...
79
+ #So instead using the disconnected callback and this variable to manage tracking the state without the delay
80
+ self.__connected = False
81
+ #Writing functions will naturally throw an exception if disconnected. Reading ones don't because they use notifications rather
82
+ #then direct reads. This means reading functions will need to assert the connection status but writing does not.
83
+
84
+ async def __async_open(self):
85
+ await self.client.connect()
86
+ await self.client.start_notify(NORDIC_UART_TX_UUID, self.__on_data_received)
87
+
88
+ def open(self):
89
+ #If trying to open while already open, this infinitely loops
90
+ if self.__opened:
91
+ if not self.__connected and self.error_on_disconnect:
92
+ self.close()
93
+ return
94
+ self.event_loop.run_until_complete(self.__async_open())
95
+ self.max_packet_size = self.client.mtu_size - 3 #-3 to account for the opcode and attribute handle stored in the data packet
96
+ self.__opened = True
97
+ self.__connected = True
98
+
99
+ async def __async_close(self):
100
+ #There appears to be a bug where if you call close too soon after is_connected returns false,
101
+ #the disconnect call will hang on Windows. It seems similar to this issue: https://github.com/hbldh/bleak/issues/1359
102
+ await asyncio.sleep(0.5)
103
+ await self.client.disconnect()
104
+
105
+ def close(self):
106
+ if not self.__opened: return
107
+ self.event_loop.run_until_complete(self.__async_close())
108
+ self.buffer.clear()
109
+ self.__opened = False
110
+
111
+ def __on_disconnect(self, client: BleakClient):
112
+ self.__connected = False
113
+
114
+ #Goal is that this is always called after something that would have already performed an async callback
115
+ #to prevent needing to run the event loop. Running the event loop frequently is slow. Which is also why this
116
+ #comclass will eventually have a threaded asyncio version.
117
+ def __assert_connected(self):
118
+ if not self.__connected and self.error_on_disconnect:
119
+ raise TssBLENoConnectionError(f"{self.name} is not connected")
120
+
121
+ def check_open(self):
122
+ #Checking this, while slow, isn't much difference in speed as allowing the disconnect callback to update via
123
+ #running the empty async function. So just going to use this here. Repeated calls to check_open are not a good
124
+ #idea from a speed perspective until a fix is found. We will probably make a version of this BLEComClass that uses
125
+ #a background thread for asyncio to allow for speed increases.
126
+ self.__connected = self.client.is_connected
127
+ if not self.__connected and self.__opened and self.error_on_disconnect:
128
+ self.close()
129
+ return self.__connected
130
+
131
+ #Bleak does run a thread to read data on notification after calling start_notify, however on notification
132
+ #it schedules a callback using loop.call_soon_threadsafe() so the actual notification can't happen unless we
133
+ #run the event loop. Therefore, this async function that does nothing is used just to trigger an event loop updated
134
+ #so the read callbacks __on_data_received can occur
135
+ @staticmethod
136
+ async def __wait_for_callbacks_async():
137
+ pass
138
+
139
+ def __read_all_data(self):
140
+ self.event_loop.run_until_complete(self.__wait_for_callbacks_async())
141
+ self.__assert_connected()
142
+
143
+ def __on_data_received(self, sender: BleakGATTCharacteristic, data: bytearray):
144
+ self.buffer += data
145
+ self.data_read_event.set()
146
+
147
+ def write(self, bytes: bytes):
148
+ start_index = 0
149
+ while start_index < len(bytes):
150
+ end_index = min(len(bytes), start_index + self.max_packet_size) #Can only send max_packet_size data per call to write_gatt_char
151
+ self.event_loop.run_until_complete(self.client.write_gatt_char(NORDIC_UART_RX_UUID, bytes[start_index:end_index], response=False))
152
+ start_index = end_index
153
+
154
+ async def __await_read(self, timeout_time: int):
155
+ self.__assert_connected()
156
+ self.data_read_event.clear()
157
+ try:
158
+ async with async_timeout.timeout_at(timeout_time):
159
+ await self.data_read_event.wait()
160
+ return True
161
+ except:
162
+ return False
163
+
164
+ async def __await_num_bytes(self, num_bytes: int):
165
+ start_time = self.event_loop.time()
166
+ while len(self.buffer) < num_bytes and self.event_loop.time() - start_time < self.timeout:
167
+ await self.__await_read(start_time + self.timeout)
168
+
169
+ def read(self, num_bytes: int):
170
+ self.event_loop.run_until_complete(self.__await_num_bytes(num_bytes))
171
+ num_bytes = min(num_bytes, len(self.buffer))
172
+ data = self.buffer[:num_bytes]
173
+ del self.buffer[:num_bytes]
174
+ return data
175
+
176
+ def peek(self, num_bytes: int):
177
+ self.event_loop.run_until_complete(self.__await_num_bytes(num_bytes))
178
+ num_bytes = min(num_bytes, len(self.buffer))
179
+ data = self.buffer[:num_bytes]
180
+ return data
181
+
182
+ #Reads until the pattern is received, max_length is exceeded, or timeout occurs
183
+ async def __await_pattern(self, pattern: bytes, max_length: int = None):
184
+ if max_length is None: max_length = float('inf')
185
+ start_time = self.event_loop.time()
186
+ while pattern not in self.buffer and self.event_loop.time() - start_time < self.timeout and len(self.buffer) < max_length:
187
+ await self.__await_read(start_time + self.timeout)
188
+ return pattern in self.buffer
189
+
190
+ def read_until(self, expected: bytes) -> bytes:
191
+ self.event_loop.run_until_complete(self.__await_pattern(expected))
192
+ if expected in self.buffer: #Found the pattern
193
+ length = self.buffer.index(expected) + len(expected)
194
+ result = self.buffer[:length]
195
+ del self.buffer[:length]
196
+ return result
197
+ #Failed to find the pattern, just return whatever is there
198
+ result = self.buffer.copy()
199
+ self.buffer.clear()
200
+ return result
201
+
202
+ def peek_until(self, expected: bytes, max_length: int = None) -> bytes:
203
+ self.event_loop.run_until_complete(self.__await_pattern(expected, max_length=max_length))
204
+ if expected in self.buffer:
205
+ length = self.buffer.index(expected) + len(expected)
206
+ else:
207
+ length = len(self.buffer)
208
+
209
+ if max_length is not None and length > max_length:
210
+ length = max_length
211
+
212
+ return self.buffer[:length]
213
+
214
+ @property
215
+ def length(self):
216
+ self.__read_all_data() #Gotta update the data before knowing the length
217
+ return len(self.buffer)
218
+
219
+ @property
220
+ def timeout(self) -> float:
221
+ return self.__timeout
222
+
223
+ @timeout.setter
224
+ def timeout(self, timeout: float):
225
+ self.__timeout = timeout
226
+
227
+ @property
228
+ def reenumerates(self) -> bool:
229
+ return False
230
+
231
+ @property
232
+ def name(self) -> str:
233
+ return self.__name
234
+
235
+ SCANNER = None
236
+ SCANNER_EVENT_LOOP = None
237
+
238
+ SCANNER_CONTINOUS = False #Controls if scanning will continously run
239
+ SCANNER_TIMEOUT = 5 #Controls the scanners timeout
240
+ SCANNER_FIND_COUNT = 1 #When continous=False, will stop scanning after at least this many devices are found. Set to None to search the entire timeout.
241
+ SCANNER_EXPIRATION_TIME = 5 #Controls the timeout for detected BLE sensors. If a sensor hasn't been detected again in this amount of time, its removed from discovered devices
242
+
243
+ #Format: Address - dict = { device: ..., adv: ..., last_found: ... }
244
+ discovered_devices: dict[str,dict] = {}
245
+
246
+ @classmethod
247
+ def __lazy_init_scanner(cls):
248
+ if cls.SCANNER is None:
249
+ cls.SCANNER = BleakScanner(detection_callback=cls.__detection_callback, service_uuids=[NORDIC_UART_SERVICE_UUID])
250
+ cls.SCANNER_EVENT_LOOP = asyncio.new_event_loop()
251
+
252
+ @classmethod
253
+ def __detection_callback(cls, device: BLEDevice, adv: AdvertisementData):
254
+ cls.discovered_devices[device.address] = {"device": device, "adv": adv, "last_found": time.time()}
255
+
256
+ @classmethod
257
+ def set_scanner_continous(cls, continous: bool):
258
+ """
259
+ If not using continous mode, functions like update_nearby_devices and auto_detect are blocking with the following rules:
260
+ - Will search for at most SCANNER_TIMEOUT time
261
+ - Will stop searching immediately once SCANNER_FIND_COUNT is reached
262
+
263
+ If using continous mode, no scanning functions are blocking. However, the user must continously call
264
+ update_nearby_devices to ensure up to date information.
265
+ """
266
+ cls.__lazy_init_scanner()
267
+ cls.SCANNER_CONTINOUS = continous
268
+ if continous: cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.start())
269
+ else: cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.stop())
270
+
271
+ @classmethod
272
+ def update_nearby_devices(cls):
273
+ """
274
+ Updates ThreespaceBLEComClass.discovered_devices using the current configuration.
275
+ """
276
+ cls.__lazy_init_scanner()
277
+ if cls.SCANNER_CONTINOUS:
278
+ #Allow the callbacks for nearby devices to trigger
279
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.__wait_for_callbacks_async())
280
+ #Remove expired devices
281
+ cur_time = time.time()
282
+ to_remove = [] #Avoiding concurrent list modification
283
+ for device in cls.discovered_devices:
284
+ if cur_time - cls.discovered_devices[device]["last_found"] > cls.SCANNER_EXPIRATION_TIME:
285
+ to_remove.append(device)
286
+ for device in to_remove:
287
+ del cls.discovered_devices[device]
288
+
289
+ else:
290
+ #Mark all devices as invalid before searching for nearby devices
291
+ cls.discovered_devices.clear()
292
+ start_time = time.time()
293
+ end_time = cls.SCANNER_TIMEOUT or float('inf')
294
+ end_count = cls.SCANNER_FIND_COUNT or float('inf')
295
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.start())
296
+ while time.time() - start_time < end_time and len(cls.discovered_devices) < end_count:
297
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.__wait_for_callbacks_async())
298
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.stop())
299
+
300
+ return cls.discovered_devices
301
+
302
+ @classmethod
303
+ def get_discovered_nearby_devices(cls):
304
+ """
305
+ A helper to get a copy of the discovered devices
306
+ """
307
+ return cls.discovered_devices.copy()
308
+
309
+ @staticmethod
310
+ def auto_detect() -> Generator["ThreespaceBLEComClass", None, None]:
311
+ """
312
+ Returns a list of com classes of the same type called on nearby.
313
+ These ports will start unopened. This allows the caller to get a list of ports without having to connect.
314
+ """
315
+ cls = ThreespaceBLEComClass
316
+ cls.update_nearby_devices()
317
+ for device_info in cls.discovered_devices.values():
318
+ yield(ThreespaceBLEComClass(device_info["device"]))
@@ -134,6 +134,37 @@ def quaternion_global_to_local(quat, vec):
134
134
  def quaternion_local_to_global(quat, vec):
135
135
  return quat_rotate_vec(quat, vec)
136
136
 
137
+ def quaternion_swap_axes(quat: list, old_order: str, new_order: str):
138
+ return quaternion_swap_axes_fast(quat, _vec.parse_axis_string_info(old_order), _vec.parse_axis_string_info(new_order))
139
+
140
+ def quaternion_swap_axes_fast(quat: list, old_parsed_order: list[list, list, bool], new_parsed_order: list[list, list, bool]):
141
+ """
142
+ Like quaternion_swap_axes but uses the inputs of parsing the axis strings to avoid having to recompute
143
+ the storage types.
144
+
145
+ each order should be a sequence of [order, mults, right_handed]
146
+ """
147
+ old_order, old_mults, old_right_handed = old_parsed_order
148
+ new_order, new_mults, new_right_handed = new_parsed_order
149
+
150
+ #Undo the old negations
151
+ base_quat = quat.copy()
152
+ for i, mult in enumerate(old_mults):
153
+ base_quat[i] *= mult
154
+
155
+ #Now swap the positions and apply new multipliers
156
+ new_quat = base_quat.copy()
157
+ for i in range(3):
158
+ new_quat[i] = base_quat[old_order.index(new_order[i])]
159
+ new_quat[i] *= new_mults[i]
160
+
161
+ if old_right_handed != new_right_handed:
162
+ #Different handed systems rotate opposite directions. So to maintain the same rotation,
163
+ #negate the rotation of the quaternion when swapping systems
164
+ new_quat[-1] *= -1
165
+
166
+ return new_quat
167
+
137
168
  #https://splines.readthedocs.io/en/latest/rotation/slerp.html
138
169
  def slerp(a, b, t):
139
170
  dot = _vec.vec_dot(a, b)
yostlabs/math/vector.py CHANGED
@@ -17,15 +17,65 @@ def vec_normalize(vec: list[float]):
17
17
  return vec
18
18
  return [v / l for v in vec]
19
19
 
20
- def vec_is_right_handed(order: str, negations: list[bool]):
21
- right_handed = order.lower() in ("xzy", "yxz", "zyx")
22
- for i in range(3):
23
- if negations[i]:
24
- right_handed = not right_handed
20
+ def vec_is_right_handed(order: str, negations: list[bool] = None):
21
+ order = order.lower()
22
+ if negations is None: #Must build the negation list
23
+ base_order = order.replace('-', '')
24
+ num_negations = order.count('-')
25
+ else:
26
+ base_order = order
27
+ num_negations = sum(negations)
28
+
29
+ right_handed = base_order in ("xzy", "yxz", "zyx")
30
+ if num_negations & 1: #Odd number of negations causes handedness to swap
31
+ right_handed = not right_handed
32
+
25
33
  return right_handed
26
34
 
27
35
  def axis_to_unit_vector(axis: str):
28
36
  axis = axis.lower()
29
37
  if axis == 'x' or axis == 0: return [1, 0, 0]
30
38
  if axis == 'y' or axis == 1: return [0, 1, 0]
31
- if axis == 'z' or axis == 2: return [0, 0, 1]
39
+ if axis == 'z' or axis == 2: return [0, 0, 1]
40
+
41
+ def parse_axis_string(axis: str):
42
+ """
43
+ Given an axis order string, convert it to an array representing the axis order and negations/multipliers
44
+ """
45
+ axis = axis.lower()
46
+ order = [0, 1, 2]
47
+ multipliers = [1, 1, 1]
48
+ if 'x' in axis: #Using XYZ notation
49
+ index = 0
50
+ for c in axis:
51
+ if c == '-':
52
+ multipliers[index] = -1
53
+ continue
54
+ order[index] = ord(c) - ord('x')
55
+ index += 1
56
+ else: #Using N/S E/W U/D notation
57
+ axis_lookup = {'e': 0, 'w': 0, 'u': 1, 'd': 1, 'n': 2, 's': 2}
58
+ negative_axes = "wds"
59
+ for i, c in enumerate(axis):
60
+ order[i] = axis_lookup[c]
61
+ if c in negative_axes:
62
+ multipliers[i] = -1
63
+
64
+ return order, multipliers
65
+
66
+ def parse_axis_string_info(axis: str):
67
+ order, mult = parse_axis_string(axis)
68
+ right_handed = axis_is_righthanded(order, mult)
69
+ return [order, mult, right_handed]
70
+
71
+ def axis_is_righthanded(order: list[int], negations: list[int]):
72
+ num_swaps = 0
73
+ for i in range(3):
74
+ if i != order[i]:
75
+ num_swaps += 1
76
+
77
+ right_handed = num_swaps == 2 #Defaults to left handed, but if a singular swap occured, the handedness swaps
78
+ if negations.count(-1) & 1: #Odd number of negations causes handedness to swap
79
+ right_handed = not right_handed
80
+
81
+ return right_handed
yostlabs/tss3/api.py CHANGED
@@ -419,6 +419,10 @@ THREESPACE_AWAIT_COMMAND_FOUND = 0
419
419
  THREESPACE_AWAIT_COMMAND_TIMEOUT = 1
420
420
  THREESPACE_AWAIT_BOOTLOADER = 2
421
421
 
422
+ THREESPACE_UPDATE_COMMAND_PARSED = 0
423
+ THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA = 1
424
+ THREESPACE_UPDATE_COMMAND_MISALIGNED = 2
425
+
422
426
  T = TypeVar('T')
423
427
 
424
428
  @dataclass
@@ -464,10 +468,11 @@ class ThreespaceBootloaderInfo:
464
468
  THREESPACE_REQUIRED_HEADER = THREESPACE_HEADER_ECHO_BIT | THREESPACE_HEADER_CHECKSUM_BIT | THREESPACE_HEADER_LENGTH_BIT
465
469
  class ThreespaceSensor:
466
470
 
467
- def __init__(self, com = None, timeout=2, verbose=False):
471
+ def __init__(self, com = None, timeout=2, verbose=False, initial_clear_timeout=None):
468
472
  if com is None: #Default to attempting to use the serial com class if none is provided
469
473
  com = ThreespaceSerialComClass
470
474
 
475
+ manually_opened_com = False
471
476
  #Auto discover using the supplied com class type
472
477
  if inspect.isclass(com) and issubclass(com, ThreespaceComClass):
473
478
  new_com = None
@@ -477,10 +482,14 @@ class ThreespaceSensor:
477
482
  if new_com is None:
478
483
  raise RuntimeError("Failed to auto discover com port")
479
484
  self.com = new_com
485
+ manually_opened_com = True
480
486
  self.com.open()
481
487
  #The supplied com already was a com class, nothing to do
482
488
  elif inspect.isclass(type(com)) and issubclass(type(com), ThreespaceComClass):
483
489
  self.com = com
490
+ if not self.com.check_open():
491
+ self.com.open()
492
+ manually_opened_com = True
484
493
  else: #Unknown type, try making a ThreespaceSerialComClass out of this
485
494
  try:
486
495
  self.com = ThreespaceSerialComClass(com)
@@ -503,6 +512,12 @@ class ThreespaceSensor:
503
512
  self.is_log_streaming = False
504
513
  self.is_file_streaming = False
505
514
  self._force_stop_streaming()
515
+ #Clear out the buffer to allow faster initializing
516
+ #Ex: If a large buffer build up due to streaming, especially if using a slower interface like BLE,
517
+ #it may take a while before the entire garbage data can be parsed when checking for bootloader, causing a timeout
518
+ #even though it would have eventually succeeded
519
+ self.__clear_com(initial_clear_timeout)
520
+
506
521
 
507
522
  #Used to ensure connecting to the correct sensor when reconnecting
508
523
  self.serial_number = None
@@ -514,12 +529,19 @@ class ThreespaceSensor:
514
529
  self.getStreamingBatchCommand: ThreespaceGetStreamingBatchCommand = None
515
530
  self.funcs = {}
516
531
 
517
- self.__cached_in_bootloader = self.__check_bootloader_status()
518
- if not self.in_bootloader:
519
- self.__firmware_init()
520
- else:
521
- self.__cache_serial_number(self.bootloader_get_sn())
522
- self.__empty_debug_cache()
532
+ try:
533
+ self.__cached_in_bootloader = self.__check_bootloader_status()
534
+ if not self.in_bootloader:
535
+ self.__firmware_init()
536
+ else:
537
+ self.__cache_serial_number(self.bootloader_get_sn())
538
+ self.__empty_debug_cache()
539
+ #This is just to prevent a situation where instantiating the API creates and fails to release a com class on failure when user catches the exception
540
+ #If user provides the com class, it is up to them to handle its state on error
541
+ except Exception as e:
542
+ if manually_opened_com:
543
+ self.com.close()
544
+ raise e
523
545
 
524
546
  #Just a helper for outputting information
525
547
  def log(self, *args):
@@ -528,6 +550,16 @@ class ThreespaceSensor:
528
550
 
529
551
  #-----------------------INITIALIZIATION & REINITIALIZATION-----------------------------------
530
552
 
553
+ def __clear_com(self, refresh_timeout=None):
554
+ data = self.com.read_all()
555
+ if refresh_timeout is None: return
556
+ while len(data) > 0: #Continue until all data is cleared
557
+ start_time = time.time()
558
+ while time.time() - start_time < refresh_timeout: #Wait up to refresh time for a new message
559
+ data = self.com.read_all()
560
+ if len(data) > 0:
561
+ break #Refresh the start time and wait for more data
562
+
531
563
  def __firmware_init(self):
532
564
  """
533
565
  Should only be called when not streaming and known in firmware.
@@ -757,8 +789,7 @@ class ThreespaceSensor:
757
789
  return 0xFF, 0xFF
758
790
 
759
791
  #For dirty check
760
- keys = cmd[1:-1].split(';')
761
- keys = [v.split('=')[0] for v in keys]
792
+ param_dict = threespaceSetSettingsStringToDict(cmd[1:-1])
762
793
 
763
794
  #Must enable this before sending the set so can properly handle reading the response
764
795
  if "debug_mode=1" in cmd:
@@ -786,21 +817,30 @@ class ThreespaceSensor:
786
817
  #Handle updating state variables based on settings
787
818
  #If the user modified the header, need to cache the settings so the API knows how to interpret responses
788
819
  if "header" in cmd.lower(): #First do a quick check
789
- if any(v in keys for v in ThreespaceSensor.HEADER_KEYS): #Then do a longer check
820
+ if any(v in param_dict.keys() for v in ThreespaceSensor.HEADER_KEYS): #Then do a longer check
790
821
  self.__cache_header_settings()
791
822
 
792
823
  if "stream_slots" in cmd.lower():
793
824
  self.__cache_streaming_settings()
794
825
 
795
826
  #All the settings changed, just need to mark dirty
796
- if any(v in keys for v in ("default", "reboot")):
827
+ if any(v in param_dict.keys() for v in ("default", "reboot")):
797
828
  self.set_cached_settings_dirty()
798
829
 
799
830
  if err:
800
831
  self.log(f"Err setting {cmd}: {err=} {num_successes=}")
801
832
  return err, num_successes
802
833
 
803
- def get_settings(self, *args: str) -> dict[str, str] | str:
834
+ def get_settings(self, *args: str, format="Mixed") -> dict[str, str] | str:
835
+ """
836
+ Gets the values for all requested settings. Settings are request by their string name. The result will be
837
+ the string response to that setting.
838
+
839
+ Params
840
+ -----
841
+ *args : Any number of string keys
842
+ format : "Mixed" (Dictionary if multiple settings requested, else just the response string) or "Dict" (Always a dictionary even if only one key)
843
+ """
804
844
  self.check_dirty()
805
845
  #Build and send the cmd
806
846
  params = list(args)
@@ -837,7 +877,7 @@ class ThreespaceSensor:
837
877
  self.log("Failed to parse get value:", i, v, len(v))
838
878
 
839
879
  #Format response
840
- if len(response_dict) == 1:
880
+ if len(response_dict) == 1 and format == "Mixed":
841
881
  return list(response_dict.values())[0]
842
882
  return response_dict
843
883
 
@@ -997,7 +1037,7 @@ class ThreespaceSensor:
997
1037
 
998
1038
  #------------------------------BASE INPUT PARSING--------------------------------------------
999
1039
 
1000
- def __internal_update(self, header: ThreespaceHeader = None):
1040
+ def __internal_update(self, header: ThreespaceHeader = None, blocking=True):
1001
1041
  """
1002
1042
  Manages checking the datastream for asynchronous responses (Streaming, Immediate Debug Messages).
1003
1043
  If no data is found to match these responses, the data buffer will be considered corrupted/misaligned
@@ -1012,29 +1052,39 @@ class ThreespaceSensor:
1012
1052
 
1013
1053
  Returns
1014
1054
  --------
1015
- False : Misalignment
1016
- True : Internal Data Found/Parsed
1055
+ 0 : Internal Data Found/Parsed
1056
+ 1 : Not enough data (Only possible when blocking == False)
1057
+ 2 : Misalignment
1017
1058
  """
1018
1059
  checksum_match = False #Just for debugging
1019
1060
 
1020
1061
  if header is not None:
1021
1062
  #NOTE: FOR THIS TO WORK IT IS REQUIRED THAT THE HEADER DOES NOT CHANGE WHILE STREAMING ANY FORM OF DATA.
1022
1063
  #IT IS UP TO THE API TO ENFORCE NOT ALLOWING HEADER CHANGES WHILE ANY OF THOSE THINGS ARE HAPPENING
1023
- if self.is_data_streaming and header.echo == THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM: #Its a streaming packet, so update streaming
1064
+ if self.is_data_streaming and header.echo == THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM:
1065
+ if not blocking:
1066
+ expected_output_size = len(header.raw_binary) + self.getStreamingBatchCommand.info.out_size
1067
+ if self.com.length < expected_output_size: return THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA
1024
1068
  if checksum_match := self.__peek_checksum(header, max_data_length=self.getStreamingBatchCommand.info.out_size):
1025
1069
  self.__update_base_streaming()
1026
1070
  self.misaligned = False
1027
- return True
1071
+ return THREESPACE_UPDATE_COMMAND_PARSED
1028
1072
  elif self.is_log_streaming and header.echo == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
1029
- if checksum_match := self.__peek_checksum(header, max_data_length=2560): #TODO: Confirm this number can be 2048 and then pound define it. Currently set to 2560 because I know that is big enough, but not optimal
1073
+ if not blocking:
1074
+ expected_output_size = len(header.raw_binary) + min(header.length, THREESPACE_LIVE_LOG_STREAM_MAX_PACKET_SIZE)
1075
+ if self.com.length < expected_output_size: return THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA
1076
+ if checksum_match := self.__peek_checksum(header, max_data_length=THREESPACE_LIVE_LOG_STREAM_MAX_PACKET_SIZE):
1030
1077
  self.__update_log_streaming()
1031
1078
  self.misaligned = False
1032
- return True
1079
+ return THREESPACE_UPDATE_COMMAND_PARSED
1033
1080
  elif self.is_file_streaming and header.echo == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
1081
+ if not blocking:
1082
+ expected_output_size = len(header.raw_binary) + min(header.length, THREESPACE_FILE_STREAMING_MAX_PACKET_SIZE)
1083
+ if self.com.length < expected_output_size: return THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA
1034
1084
  if checksum_match := self.__peek_checksum(header, max_data_length=THREESPACE_FILE_STREAMING_MAX_PACKET_SIZE):
1035
1085
  self.__update_file_streaming()
1036
1086
  self.misaligned = False
1037
- return True
1087
+ return THREESPACE_UPDATE_COMMAND_PARSED
1038
1088
 
1039
1089
  #Debug messages are possible and there is enough data to potentially be a debug message
1040
1090
  #NOTE: Firmware should avoid putting more then one \r\n in a debug message as they will be treated as unprocessed/misaligned characters
@@ -1049,7 +1099,7 @@ class ThreespaceSensor:
1049
1099
  message = self.com.readline() #Read out the whole message!
1050
1100
  self.debug_callback(message.decode('ascii'), self)
1051
1101
  self.misaligned = False
1052
- return True
1102
+ return THREESPACE_UPDATE_COMMAND_PARSED
1053
1103
 
1054
1104
  #The response didn't match any of the expected asynchronous streaming API responses, so assume a misalignment
1055
1105
  if header is not None:
@@ -1060,7 +1110,7 @@ class ThreespaceSensor:
1060
1110
  msg = "Possible Misalignment or corruption/debug message"
1061
1111
  #self.log("Misaligned:", self.com.peek(1))
1062
1112
  self.__handle_misalignment(msg)
1063
- return False
1113
+ return THREESPACE_UPDATE_COMMAND_MISALIGNED
1064
1114
 
1065
1115
  def __handle_misalignment(self, message: str = None):
1066
1116
  if not self.misaligned and message is not None:
@@ -1158,12 +1208,21 @@ class ThreespaceSensor:
1158
1208
  self.streaming_packets.clear()
1159
1209
 
1160
1210
  #This is called for all streaming types
1161
- def updateStreaming(self, max_checks=float('inf')):
1211
+ def updateStreaming(self, max_checks=float('inf'), timeout=None, blocking=False):
1162
1212
  """
1163
1213
  Returns true if any amount of data was processed whether valid or not. This is called for all streaming types.
1214
+
1215
+ Parameters
1216
+ ----------
1217
+ max_checks : Will only attempt to read up to max_checks packets
1218
+ timeout : Will only attempt to read packets for this duration. It is possible for this function to take longer then this timeout \
1219
+ if blocking = True, in which case it could take up to timeout + com.timeout
1220
+ blocking : If False, will immediately stop when not enough data is available. If true, will immediately stop if not enough data \
1221
+ for a header, but will block when trying to retrieve the data associated with that header. For most com classes, this does not matter. \
1222
+ But for communication such as BLE where the header and data may be split between different packets, this will have a clear effect.
1164
1223
  """
1165
1224
  if not self.is_streaming: return False
1166
-
1225
+ if timeout is None: timeout = float('inf')
1167
1226
  #I may need to make this have a max num bytes it will process before exiting to prevent locking up on slower machines
1168
1227
  #due to streaming faster then the program runs
1169
1228
  num_checks = 0
@@ -1173,12 +1232,17 @@ class ThreespaceSensor:
1173
1232
  return data_processed
1174
1233
 
1175
1234
  #Get header
1235
+
1176
1236
  header = self.com.peek(self.header_info.size)
1177
1237
 
1178
1238
  #Get the header and send it to the internal update
1179
1239
  header = ThreespaceHeader.from_bytes(header, self.header_info)
1180
- self.__internal_update(header)
1181
- data_processed = True #Internal update always processes data. Either reads a streaming message, or advances buffer due to misalignment
1240
+ result = self.__internal_update(header, blocking=blocking)
1241
+ if result == THREESPACE_UPDATE_COMMAND_PARSED:
1242
+ data_processed = True
1243
+ elif result == THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA:
1244
+ return data_processed
1245
+
1182
1246
  num_checks += 1
1183
1247
 
1184
1248
  return data_processed
@@ -1427,20 +1491,26 @@ class ThreespaceSensor:
1427
1491
  self.com.write("RR".encode())
1428
1492
 
1429
1493
  def cleanup(self):
1430
- if not self.in_bootloader:
1431
- if self.is_data_streaming:
1432
- self.stopStreaming()
1433
- if self.is_file_streaming:
1434
- self.fileStopStream()
1435
- if self.is_log_streaming:
1436
- self.stopDataLogging()
1437
-
1438
- #The sensor may or may not have this command registered. So just try it
1439
- try:
1440
- #May not be opened, but also not cacheing that so just attempt to close.
1441
- self.closeFile()
1442
- except: pass
1443
- self.com.close()
1494
+ error = None
1495
+ try:
1496
+ if not self.in_bootloader:
1497
+ if self.is_data_streaming:
1498
+ self.stopStreaming()
1499
+ if self.is_file_streaming:
1500
+ self.fileStopStream()
1501
+ if self.is_log_streaming:
1502
+ self.stopDataLogging()
1503
+
1504
+ #The sensor may or may not have this command registered. So just try it
1505
+ try:
1506
+ #May not be opened, but also not cacheing that so just attempt to close.
1507
+ self.closeFile()
1508
+ except: pass
1509
+ except Exception as e:
1510
+ error = e
1511
+ self.com.close() #Ensuring the close gets called, that way com ports can't get stuck open. Also makes calling cleanup() "safe" even after disconnect
1512
+ if error:
1513
+ raise error
1444
1514
 
1445
1515
  #-------------------------START ALL PROTOTYPES------------------------------------
1446
1516
 
@@ -1729,4 +1799,17 @@ def threespaceGetHeaderLabels(header_info: ThreespaceHeaderInfo):
1729
1799
  order.append("serial#")
1730
1800
  if header_info.length_enabled:
1731
1801
  order.append("len")
1732
- return order
1802
+ return order
1803
+
1804
+ def threespaceSetSettingsStringToDict(setting_string: str):
1805
+ d = {}
1806
+ for item in setting_string.split(';'):
1807
+ result = item.split('=')
1808
+ key = result[0]
1809
+ if len(result) == 1:
1810
+ value = None
1811
+ else:
1812
+ value = '='.join(result[1:]) #In case = was part of the value, do a join
1813
+
1814
+ d[key] = value
1815
+ return d
yostlabs/tss3/consts.py CHANGED
@@ -29,6 +29,7 @@ THREESPACE_GET_SETTINGS_ERROR_RESPONSE = "<KEY_ERROR>"
29
29
  THREESPACE_SETTING_KEY_INVALID_CHARS = " :;"
30
30
 
31
31
  THREESPACE_FILE_STREAMING_MAX_PACKET_SIZE = 512
32
+ THREESPACE_LIVE_LOG_STREAM_MAX_PACKET_SIZE = 2048
32
33
 
33
34
  THREESPACE_SN_FAMILY_POS = 14 * 4
34
35
  THREESPACE_SN_VARIATION_POS = 11 * 4
@@ -270,6 +270,24 @@ class ThreespaceStreamingManager:
270
270
  if immediate_update:
271
271
  self.__apply_streaming_settings_and_update_state()
272
272
 
273
+ def unregister_all_commands_from_owner(self, owner: object, immediate_update: bool = True):
274
+ """
275
+ Undoes all command registrations done by the given owner automatically
276
+
277
+ Parameters
278
+ ----------
279
+ owner : A reference to the object unregistering the command. A command is only unregistered after all its owners release it
280
+ immediate_update : If true, the streaming manager will immediately change the streaming slots on the sensor. If doing bulk unregisters, it
281
+ is useful to set this as False until the last one for performance purposes.
282
+ """
283
+ for registered_command in self.registered_commands.values():
284
+ if owner in registered_command.registrations:
285
+ registered_command.registrations.remove(owner)
286
+ if len(registered_command.registrations) == 0:
287
+ self.dirty = True
288
+
289
+ if self.dirty and immediate_update:
290
+ self.__apply_streaming_settings_and_update_state()
273
291
 
274
292
  def __build_stream_slots_string(self):
275
293
  cmd_strings = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yostlabs
3
- Version: 2025.2.11
3
+ Version: 2025.3.6
4
4
  Summary: Python resources and API for 3Space sensors from Yost Labs Inc.
5
5
  Project-URL: Homepage, https://yostlabs.com/
6
6
  Project-URL: Repository, https://github.com/YostLabs/3SpacePythonPackage/tree/main
@@ -13,6 +13,7 @@ Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Requires-Python: >=3.10
16
+ Requires-Dist: bleak
16
17
  Requires-Dist: numpy
17
18
  Requires-Dist: pyserial
18
19
  Description-Content-Type: text/markdown
@@ -25,6 +26,8 @@ Description-Content-Type: text/markdown
25
26
 
26
27
  ## Basic Usage
27
28
 
29
+ #### USB
30
+
28
31
  ```Python
29
32
  from yostlabs.tss3.api import ThreespaceSensor
30
33
 
@@ -37,6 +40,24 @@ print(result)
37
40
  sensor.cleanup()
38
41
  ```
39
42
 
43
+ #### BLE
44
+
45
+ ```Python
46
+ from yostlabs.tss3.api import ThreespaceSensor
47
+ from yostlabs.communication.ble import ThreespaceBLEComClass
48
+
49
+ #PUT YOUR SENSORS BLE_NAME HERE
50
+ ble_name = "YL-TSS-####" #Defaults to the lowest 4 hex digits of the sensors serial number
51
+ com_class = ThreespaceBLEComClass(ble_name)
52
+ sensor = ThreespaceSensor(com_class)
53
+ #sensor = ThreespaceSensor(ThreespaceBLEComClass) #Attempt to auto discover nearby sensor
54
+
55
+ result = sensor.getPrimaryCorrectedAccelVec()
56
+ print(result)
57
+
58
+ sensor.cleanup()
59
+ ```
60
+
40
61
  Click [here](https://github.com/YostLabs/3SpacePythonPackage/tree/main/Examples) for more examples.
41
62
 
42
63
  ## Communication
@@ -1,20 +1,21 @@
1
1
  yostlabs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  yostlabs/communication/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  yostlabs/communication/base.py,sha256=ahAIQndfo9ifX6Lf2NeEaHpIhRJ_uBv6jv9P7N3Rbhg,2884
4
+ yostlabs/communication/ble.py,sha256=rNLC6jnNgbx_m4oRBoUg7f_8GhS2DFqlbmbS9w90iq0,15187
4
5
  yostlabs/communication/serial.py,sha256=cOv3CxloQo7sCjdAEUzkfZuZieqy-JbsW2Zwavc9LT8,5514
5
6
  yostlabs/math/__init__.py,sha256=JFzsPQ4AbsX1AH1brBpn1c_Pa_ItF43__D3mlPvA2a4,34
6
- yostlabs/math/quaternion.py,sha256=YyvbSrTPXGS8BsQJCn2tjdzIZ9WeDzfUe7dIDKeWsAM,4989
7
- yostlabs/math/vector.py,sha256=CPtIxJXelCidGxTBrz6vZwLv-qXlccpAlYODaKJnWNw,991
7
+ yostlabs/math/quaternion.py,sha256=6kO6-QDVT6n-lvLtPrnhOWiSjic0Kdvj0_cfWrNV1N0,6284
8
+ yostlabs/math/vector.py,sha256=9vfVFSahHa0ZZRZ_SgAU5ucVplt7J-fHQ0s8ymOanj4,2725
8
9
  yostlabs/tss3/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- yostlabs/tss3/api.py,sha256=kJJ1sEBvmwrDkVemwijtUsGS4OWDOHMAzuUXPjDD6Eo,78998
10
- yostlabs/tss3/consts.py,sha256=S8KH4imiz2eaufoK0rMt9hZKk2zBq_DzO44zcYW1gCU,2353
10
+ yostlabs/tss3/api.py,sha256=OA0EpwAxeXqfLlqN9j0gitDDYJmgD_BAxTzwZDtuHD0,83666
11
+ yostlabs/tss3/consts.py,sha256=RwhqmKIXRGpRdusss3q17ukCuRS96ZsvBl6y0mjF0b4,2404
11
12
  yostlabs/tss3/eepts.py,sha256=7A7sCyOfDiJgw5Y9pGneg-5YgNvcfKtqeS9FoVWfJO8,9540
12
13
  yostlabs/tss3/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
14
  yostlabs/tss3/utils/calibration.py,sha256=42jCEzfTXoHuPZ4e-30N1ijOhkz9ld4PQnhX6AhTrZE,7069
14
15
  yostlabs/tss3/utils/parser.py,sha256=thM5s70CZvehM5qP3AGVgHs6woeQM-wmA7hIcRO3MlY,11332
15
- yostlabs/tss3/utils/streaming.py,sha256=218U29LmsLel42kd6g63Hi9XnovRqFVMofO0GEOAAA0,18990
16
+ yostlabs/tss3/utils/streaming.py,sha256=My6SSvQSbrxKC2-mZd0tQK9vGtmX8NpdQsRRShwjUs8,20024
16
17
  yostlabs/tss3/utils/version.py,sha256=NT2H9l-oIRCYhV_yjf5UjkadoJQ0IN4eLl8y__pyTPc,3001
17
- yostlabs-2025.2.11.dist-info/METADATA,sha256=HTjiIUX7KUm_oYHfmfnBBYvMjJdeWF9XnJSBnsHP6ZU,2184
18
- yostlabs-2025.2.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- yostlabs-2025.2.11.dist-info/licenses/LICENSE,sha256=PtF8EXRlVhm1_ve52_3GHixSPwMn0tGajFxF3xKS-j0,1090
20
- yostlabs-2025.2.11.dist-info/RECORD,,
18
+ yostlabs-2025.3.6.dist-info/METADATA,sha256=QVCHfEFY4pUWY41jVF3G_q7dkpCEzLRTw1iIr9K7uXs,2721
19
+ yostlabs-2025.3.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
20
+ yostlabs-2025.3.6.dist-info/licenses/LICENSE,sha256=PtF8EXRlVhm1_ve52_3GHixSPwMn0tGajFxF3xKS-j0,1090
21
+ yostlabs-2025.3.6.dist-info/RECORD,,