yostlabs 2025.2.17__tar.gz → 2025.3.6__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 (33) hide show
  1. yostlabs-2025.3.6/Examples/example_ble.py +23 -0
  2. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/PKG-INFO +22 -1
  3. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/README.md +20 -0
  4. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/pyproject.toml +3 -2
  5. yostlabs-2025.3.6/src/yostlabs/communication/ble.py +318 -0
  6. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/math/quaternion.py +31 -0
  7. yostlabs-2025.3.6/src/yostlabs/math/vector.py +81 -0
  8. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/api.py +114 -40
  9. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/consts.py +1 -0
  10. yostlabs-2025.2.17/src/yostlabs/math/vector.py +0 -31
  11. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/.gitignore +0 -0
  12. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/embedded_2024_dec_20.xml +0 -0
  13. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_commands.py +0 -0
  14. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_component_specific_settings_and_commands.py +0 -0
  15. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_firmware_upload.py +0 -0
  16. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_parsing_stored_binary.py +0 -0
  17. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_read_settings.py +0 -0
  18. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_streaming.py +0 -0
  19. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_streaming_manager.py +0 -0
  20. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/Examples/example_write_settings.py +0 -0
  21. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/LICENSE +0 -0
  22. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/__init__.py +0 -0
  23. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/communication/__init__.py +0 -0
  24. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/communication/base.py +0 -0
  25. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/communication/serial.py +0 -0
  26. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/math/__init__.py +0 -0
  27. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/__init__.py +0 -0
  28. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/eepts.py +0 -0
  29. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/utils/__init__.py +0 -0
  30. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/utils/calibration.py +0 -0
  31. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/utils/parser.py +0 -0
  32. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/utils/streaming.py +0 -0
  33. {yostlabs-2025.2.17 → yostlabs-2025.3.6}/src/yostlabs/tss3/utils/version.py +0 -0
@@ -0,0 +1,23 @@
1
+ from yostlabs.tss3.api import ThreespaceSensor
2
+ from yostlabs.communication.ble import ThreespaceBLEComClass
3
+
4
+ auto_detect = False
5
+
6
+ if auto_detect:
7
+ #Create a sensor by auto detecting a ThreespaceBLEComClass.
8
+ #It does this by attempting to connect to a device with the Nordic Uart Service
9
+ sensor = ThreespaceSensor(ThreespaceBLEComClass)
10
+ else:
11
+ #PUT YOUR SENSORS BLE_NAME HERE
12
+ ble_name = "YL-TSS-####" #Defaults to the lowest 4 hex digits of the sensors serial number
13
+ print("Attempting to discover and connect to a sensor with the name:", ble_name)
14
+ com_class = ThreespaceBLEComClass(ble_name)
15
+ sensor = ThreespaceSensor(com_class)
16
+
17
+ ble_name = sensor.get_settings("ble_name")
18
+ print("Connected to:", ble_name)
19
+
20
+ result = sensor.getPrimaryCorrectedAccelVec()
21
+ print(result)
22
+
23
+ sensor.cleanup()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yostlabs
3
- Version: 2025.2.17
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
@@ -6,6 +6,8 @@
6
6
 
7
7
  ## Basic Usage
8
8
 
9
+ #### USB
10
+
9
11
  ```Python
10
12
  from yostlabs.tss3.api import ThreespaceSensor
11
13
 
@@ -18,6 +20,24 @@ print(result)
18
20
  sensor.cleanup()
19
21
  ```
20
22
 
23
+ #### BLE
24
+
25
+ ```Python
26
+ from yostlabs.tss3.api import ThreespaceSensor
27
+ from yostlabs.communication.ble import ThreespaceBLEComClass
28
+
29
+ #PUT YOUR SENSORS BLE_NAME HERE
30
+ ble_name = "YL-TSS-####" #Defaults to the lowest 4 hex digits of the sensors serial number
31
+ com_class = ThreespaceBLEComClass(ble_name)
32
+ sensor = ThreespaceSensor(com_class)
33
+ #sensor = ThreespaceSensor(ThreespaceBLEComClass) #Attempt to auto discover nearby sensor
34
+
35
+ result = sensor.getPrimaryCorrectedAccelVec()
36
+ print(result)
37
+
38
+ sensor.cleanup()
39
+ ```
40
+
21
41
  Click [here](https://github.com/YostLabs/3SpacePythonPackage/tree/main/Examples) for more examples.
22
42
 
23
43
  ## Communication
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "yostlabs"
7
7
  #If uploading again on the same day, add a fourth number
8
- version = "2025.02.17"
8
+ version = "2025.03.6"
9
9
  authors = [
10
10
  { name="Yost Labs Inc.", email="techsupport@yostlabs.com" },
11
11
  { name="Andy Riedlinger", email="techsupport@yostlabs.com" },
@@ -22,7 +22,8 @@ classifiers = [
22
22
  keywords = ["3space", "threespace", "yost"]
23
23
  dependencies = [
24
24
  "pyserial",
25
- "numpy"
25
+ "numpy",
26
+ "bleak"
26
27
  ]
27
28
 
28
29
  [project.urls]
@@ -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)
@@ -0,0 +1,81 @@
1
+ def vec_len(vector: list[float|int]):
2
+ return sum(v ** 2 for v in vector) ** 0.5
3
+
4
+ def vec_dot(a: list[float], b: list[float]):
5
+ return sum(a[i] * b[i] for i in range(len(a)))
6
+
7
+ def vec_cross(a: list[float], b: list[float]):
8
+ cross = [0, 0, 0]
9
+ cross[0] = a[1] * b[2] - a[2] * b[1]
10
+ cross[1] = a[2] * b[0] - a[0] * b[2]
11
+ cross[2] = a[0] * b[1] - a[1] * b[0]
12
+ return cross
13
+
14
+ def vec_normalize(vec: list[float]):
15
+ l = vec_len(vec)
16
+ if l == 0:
17
+ return vec
18
+ return [v / l for v in vec]
19
+
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
+
33
+ return right_handed
34
+
35
+ def axis_to_unit_vector(axis: str):
36
+ axis = axis.lower()
37
+ if axis == 'x' or axis == 0: return [1, 0, 0]
38
+ if axis == 'y' or axis == 1: return [0, 1, 0]
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
@@ -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,14 +817,14 @@ 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:
@@ -1006,7 +1037,7 @@ class ThreespaceSensor:
1006
1037
 
1007
1038
  #------------------------------BASE INPUT PARSING--------------------------------------------
1008
1039
 
1009
- def __internal_update(self, header: ThreespaceHeader = None):
1040
+ def __internal_update(self, header: ThreespaceHeader = None, blocking=True):
1010
1041
  """
1011
1042
  Manages checking the datastream for asynchronous responses (Streaming, Immediate Debug Messages).
1012
1043
  If no data is found to match these responses, the data buffer will be considered corrupted/misaligned
@@ -1021,29 +1052,39 @@ class ThreespaceSensor:
1021
1052
 
1022
1053
  Returns
1023
1054
  --------
1024
- False : Misalignment
1025
- True : Internal Data Found/Parsed
1055
+ 0 : Internal Data Found/Parsed
1056
+ 1 : Not enough data (Only possible when blocking == False)
1057
+ 2 : Misalignment
1026
1058
  """
1027
1059
  checksum_match = False #Just for debugging
1028
1060
 
1029
1061
  if header is not None:
1030
1062
  #NOTE: FOR THIS TO WORK IT IS REQUIRED THAT THE HEADER DOES NOT CHANGE WHILE STREAMING ANY FORM OF DATA.
1031
1063
  #IT IS UP TO THE API TO ENFORCE NOT ALLOWING HEADER CHANGES WHILE ANY OF THOSE THINGS ARE HAPPENING
1032
- 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
1033
1068
  if checksum_match := self.__peek_checksum(header, max_data_length=self.getStreamingBatchCommand.info.out_size):
1034
1069
  self.__update_base_streaming()
1035
1070
  self.misaligned = False
1036
- return True
1071
+ return THREESPACE_UPDATE_COMMAND_PARSED
1037
1072
  elif self.is_log_streaming and header.echo == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
1038
- 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):
1039
1077
  self.__update_log_streaming()
1040
1078
  self.misaligned = False
1041
- return True
1079
+ return THREESPACE_UPDATE_COMMAND_PARSED
1042
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
1043
1084
  if checksum_match := self.__peek_checksum(header, max_data_length=THREESPACE_FILE_STREAMING_MAX_PACKET_SIZE):
1044
1085
  self.__update_file_streaming()
1045
1086
  self.misaligned = False
1046
- return True
1087
+ return THREESPACE_UPDATE_COMMAND_PARSED
1047
1088
 
1048
1089
  #Debug messages are possible and there is enough data to potentially be a debug message
1049
1090
  #NOTE: Firmware should avoid putting more then one \r\n in a debug message as they will be treated as unprocessed/misaligned characters
@@ -1058,7 +1099,7 @@ class ThreespaceSensor:
1058
1099
  message = self.com.readline() #Read out the whole message!
1059
1100
  self.debug_callback(message.decode('ascii'), self)
1060
1101
  self.misaligned = False
1061
- return True
1102
+ return THREESPACE_UPDATE_COMMAND_PARSED
1062
1103
 
1063
1104
  #The response didn't match any of the expected asynchronous streaming API responses, so assume a misalignment
1064
1105
  if header is not None:
@@ -1069,7 +1110,7 @@ class ThreespaceSensor:
1069
1110
  msg = "Possible Misalignment or corruption/debug message"
1070
1111
  #self.log("Misaligned:", self.com.peek(1))
1071
1112
  self.__handle_misalignment(msg)
1072
- return False
1113
+ return THREESPACE_UPDATE_COMMAND_MISALIGNED
1073
1114
 
1074
1115
  def __handle_misalignment(self, message: str = None):
1075
1116
  if not self.misaligned and message is not None:
@@ -1167,12 +1208,21 @@ class ThreespaceSensor:
1167
1208
  self.streaming_packets.clear()
1168
1209
 
1169
1210
  #This is called for all streaming types
1170
- def updateStreaming(self, max_checks=float('inf')):
1211
+ def updateStreaming(self, max_checks=float('inf'), timeout=None, blocking=False):
1171
1212
  """
1172
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.
1173
1223
  """
1174
1224
  if not self.is_streaming: return False
1175
-
1225
+ if timeout is None: timeout = float('inf')
1176
1226
  #I may need to make this have a max num bytes it will process before exiting to prevent locking up on slower machines
1177
1227
  #due to streaming faster then the program runs
1178
1228
  num_checks = 0
@@ -1182,12 +1232,17 @@ class ThreespaceSensor:
1182
1232
  return data_processed
1183
1233
 
1184
1234
  #Get header
1235
+
1185
1236
  header = self.com.peek(self.header_info.size)
1186
1237
 
1187
1238
  #Get the header and send it to the internal update
1188
1239
  header = ThreespaceHeader.from_bytes(header, self.header_info)
1189
- self.__internal_update(header)
1190
- 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
+
1191
1246
  num_checks += 1
1192
1247
 
1193
1248
  return data_processed
@@ -1436,20 +1491,26 @@ class ThreespaceSensor:
1436
1491
  self.com.write("RR".encode())
1437
1492
 
1438
1493
  def cleanup(self):
1439
- if not self.in_bootloader:
1440
- if self.is_data_streaming:
1441
- self.stopStreaming()
1442
- if self.is_file_streaming:
1443
- self.fileStopStream()
1444
- if self.is_log_streaming:
1445
- self.stopDataLogging()
1446
-
1447
- #The sensor may or may not have this command registered. So just try it
1448
- try:
1449
- #May not be opened, but also not cacheing that so just attempt to close.
1450
- self.closeFile()
1451
- except: pass
1452
- 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
1453
1514
 
1454
1515
  #-------------------------START ALL PROTOTYPES------------------------------------
1455
1516
 
@@ -1738,4 +1799,17 @@ def threespaceGetHeaderLabels(header_info: ThreespaceHeaderInfo):
1738
1799
  order.append("serial#")
1739
1800
  if header_info.length_enabled:
1740
1801
  order.append("len")
1741
- 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
@@ -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
@@ -1,31 +0,0 @@
1
- def vec_len(vector: list[float|int]):
2
- return sum(v ** 2 for v in vector) ** 0.5
3
-
4
- def vec_dot(a: list[float], b: list[float]):
5
- return sum(a[i] * b[i] for i in range(len(a)))
6
-
7
- def vec_cross(a: list[float], b: list[float]):
8
- cross = [0, 0, 0]
9
- cross[0] = a[1] * b[2] - a[2] * b[1]
10
- cross[1] = a[2] * b[0] - a[0] * b[2]
11
- cross[2] = a[0] * b[1] - a[1] * b[0]
12
- return cross
13
-
14
- def vec_normalize(vec: list[float]):
15
- l = vec_len(vec)
16
- if l == 0:
17
- return vec
18
- return [v / l for v in vec]
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
25
- return right_handed
26
-
27
- def axis_to_unit_vector(axis: str):
28
- axis = axis.lower()
29
- if axis == 'x' or axis == 0: return [1, 0, 0]
30
- if axis == 'y' or axis == 1: return [0, 1, 0]
31
- if axis == 'z' or axis == 2: return [0, 0, 1]
File without changes
File without changes