yostlabs 2025.2.17__tar.gz → 2025.4.28__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.4.28/Examples/example_ble.py +23 -0
  2. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/PKG-INFO +22 -1
  3. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/README.md +20 -0
  4. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/pyproject.toml +3 -2
  5. yostlabs-2025.4.28/src/yostlabs/communication/ble.py +325 -0
  6. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/communication/serial.py +5 -1
  7. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/math/quaternion.py +34 -5
  8. yostlabs-2025.4.28/src/yostlabs/math/vector.py +81 -0
  9. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/api.py +173 -62
  10. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/consts.py +1 -0
  11. yostlabs-2025.2.17/src/yostlabs/math/vector.py +0 -31
  12. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/.gitignore +0 -0
  13. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/embedded_2024_dec_20.xml +0 -0
  14. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_commands.py +0 -0
  15. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_component_specific_settings_and_commands.py +0 -0
  16. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_firmware_upload.py +0 -0
  17. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_parsing_stored_binary.py +0 -0
  18. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_read_settings.py +0 -0
  19. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_streaming.py +0 -0
  20. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_streaming_manager.py +0 -0
  21. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_write_settings.py +0 -0
  22. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/LICENSE +0 -0
  23. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/__init__.py +0 -0
  24. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/communication/__init__.py +0 -0
  25. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/communication/base.py +0 -0
  26. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/math/__init__.py +0 -0
  27. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/__init__.py +0 -0
  28. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/eepts.py +0 -0
  29. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/__init__.py +0 -0
  30. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/calibration.py +0 -0
  31. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/parser.py +0 -0
  32. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/streaming.py +0 -0
  33. {yostlabs-2025.2.17 → yostlabs-2025.4.28}/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.4.28
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.04.28"
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,325 @@
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
+ bleak_options = { "timeout": discovery_timeout, "disconnected_callback": self.__on_disconnect }
43
+ if isinstance(ble, BleakClient): #Actual client
44
+ self.client = ble
45
+ self.__name = ble.address
46
+ elif isinstance(ble, str):
47
+ if discover_name: #Local Name stirng
48
+ self.__lazy_init_scanner()
49
+ device = ThreespaceBLEComClass.SCANNER_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.event_loop: asyncio.AbstractEventLoop = None
67
+ self.data_read_event: asyncio.Event = None
68
+
69
+ #Default to 20, will update on open
70
+ self.max_packet_size = 20
71
+
72
+ self.error_on_disconnect = error_on_disconnect
73
+ #is_connected is different from open.
74
+ #check_open() should return is_connected as that is what the user likely wants.
75
+ #open is whether or not the client will auto connect to the device when rediscovered.
76
+ #This file is set up to automatically close the connection if a method is called and is_connected is False
77
+ #This behavior might be specific to Windows.
78
+ self.__opened = False
79
+ #client.is_connected is really slow (noticeable when called in bulk, which happens do to the assert_connected)...
80
+ #So instead using the disconnected callback and this variable to manage tracking the state without the delay
81
+ self.__connected = False
82
+ #Writing functions will naturally throw an exception if disconnected. Reading ones don't because they use notifications rather
83
+ #then direct reads. This means reading functions will need to assert the connection status but writing does not.
84
+
85
+ async def __async_open(self):
86
+ await self.client.connect()
87
+ await self.client.start_notify(NORDIC_UART_TX_UUID, self.__on_data_received)
88
+
89
+ def open(self):
90
+ #If trying to open while already open, this infinitely loops
91
+ if self.__opened:
92
+ if not self.__connected and self.error_on_disconnect:
93
+ self.close()
94
+ return
95
+ self.event_loop = asyncio.new_event_loop()
96
+ self.data_read_event = asyncio.Event()
97
+ self.event_loop.run_until_complete(self.__async_open())
98
+ self.max_packet_size = self.client.mtu_size - 3 #-3 to account for the opcode and attribute handle stored in the data packet
99
+ self.__opened = True
100
+ self.__connected = True
101
+
102
+ async def __async_close(self):
103
+ #There appears to be a bug where if you call close too soon after is_connected returns false,
104
+ #the disconnect call will hang on Windows. It seems similar to this issue: https://github.com/hbldh/bleak/issues/1359
105
+ await asyncio.sleep(0.5)
106
+ await self.client.disconnect()
107
+
108
+ def close(self):
109
+ if not self.__opened: return
110
+ self.event_loop.run_until_complete(self.__async_close())
111
+ self.buffer.clear()
112
+ self.event_loop.close()
113
+ self.event_loop = None
114
+ self.data_read_event = None
115
+ self.__opened = False
116
+
117
+ def __on_disconnect(self, client: BleakClient):
118
+ self.__connected = False
119
+
120
+ #Goal is that this is always called after something that would have already performed an async callback
121
+ #to prevent needing to run the event loop. Running the event loop frequently is slow. Which is also why this
122
+ #comclass will eventually have a threaded asyncio version.
123
+ def __assert_connected(self):
124
+ if not self.__connected and self.error_on_disconnect:
125
+ raise TssBLENoConnectionError(f"{self.name} is not connected")
126
+
127
+ def check_open(self):
128
+ #Checking this, while slow, isn't much difference in speed as allowing the disconnect callback to update via
129
+ #running the empty async function. So just going to use this here. Repeated calls to check_open are not a good
130
+ #idea from a speed perspective until a fix is found. We will probably make a version of this BLEComClass that uses
131
+ #a background thread for asyncio to allow for speed increases.
132
+ self.__connected = self.client.is_connected
133
+ if not self.__connected and self.__opened and self.error_on_disconnect:
134
+ self.close()
135
+ return self.__connected
136
+
137
+ #Bleak does run a thread to read data on notification after calling start_notify, however on notification
138
+ #it schedules a callback using loop.call_soon_threadsafe() so the actual notification can't happen unless we
139
+ #run the event loop. Therefore, this async function that does nothing is used just to trigger an event loop updated
140
+ #so the read callbacks __on_data_received can occur
141
+ @staticmethod
142
+ async def __wait_for_callbacks_async():
143
+ pass
144
+
145
+ def __read_all_data(self):
146
+ self.event_loop.run_until_complete(self.__wait_for_callbacks_async())
147
+ self.data_read_event.clear()
148
+ self.__assert_connected()
149
+
150
+ def __on_data_received(self, sender: BleakGATTCharacteristic, data: bytearray):
151
+ self.buffer += data
152
+ self.data_read_event.set()
153
+
154
+ def write(self, bytes: bytes):
155
+ start_index = 0
156
+ while start_index < len(bytes):
157
+ end_index = min(len(bytes), start_index + self.max_packet_size) #Can only send max_packet_size data per call to write_gatt_char
158
+ self.event_loop.run_until_complete(self.client.write_gatt_char(NORDIC_UART_RX_UUID, bytes[start_index:end_index], response=False))
159
+ start_index = end_index
160
+
161
+ async def __await_read(self, timeout_time: int):
162
+ self.__assert_connected()
163
+ try:
164
+ async with async_timeout.timeout_at(timeout_time):
165
+ await self.data_read_event.wait()
166
+ self.data_read_event.clear()
167
+ return True
168
+ except:
169
+ return False
170
+
171
+ async def __await_num_bytes(self, num_bytes: int):
172
+ start_time = self.event_loop.time()
173
+ while len(self.buffer) < num_bytes and self.event_loop.time() - start_time < self.timeout:
174
+ await self.__await_read(start_time + self.timeout)
175
+
176
+ def read(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
+ del self.buffer[:num_bytes]
181
+ return data
182
+
183
+ def peek(self, num_bytes: int):
184
+ self.event_loop.run_until_complete(self.__await_num_bytes(num_bytes))
185
+ num_bytes = min(num_bytes, len(self.buffer))
186
+ data = self.buffer[:num_bytes]
187
+ return data
188
+
189
+ #Reads until the pattern is received, max_length is exceeded, or timeout occurs
190
+ async def __await_pattern(self, pattern: bytes, max_length: int = None):
191
+ if max_length is None: max_length = float('inf')
192
+ start_time = self.event_loop.time()
193
+ while pattern not in self.buffer and self.event_loop.time() - start_time < self.timeout and len(self.buffer) < max_length:
194
+ await self.__await_read(start_time + self.timeout)
195
+ return pattern in self.buffer
196
+
197
+ def read_until(self, expected: bytes) -> bytes:
198
+ self.event_loop.run_until_complete(self.__await_pattern(expected))
199
+ if expected in self.buffer: #Found the pattern
200
+ length = self.buffer.index(expected) + len(expected)
201
+ result = self.buffer[:length]
202
+ del self.buffer[:length]
203
+ return result
204
+ #Failed to find the pattern, just return whatever is there
205
+ result = self.buffer.copy()
206
+ self.buffer.clear()
207
+ return result
208
+
209
+ def peek_until(self, expected: bytes, max_length: int = None) -> bytes:
210
+ self.event_loop.run_until_complete(self.__await_pattern(expected, max_length=max_length))
211
+ if expected in self.buffer:
212
+ length = self.buffer.index(expected) + len(expected)
213
+ else:
214
+ length = len(self.buffer)
215
+
216
+ if max_length is not None and length > max_length:
217
+ length = max_length
218
+
219
+ return self.buffer[:length]
220
+
221
+ @property
222
+ def length(self):
223
+ self.__read_all_data() #Gotta update the data before knowing the length
224
+ return len(self.buffer)
225
+
226
+ @property
227
+ def timeout(self) -> float:
228
+ return self.__timeout
229
+
230
+ @timeout.setter
231
+ def timeout(self, timeout: float):
232
+ self.__timeout = timeout
233
+
234
+ @property
235
+ def reenumerates(self) -> bool:
236
+ return False
237
+
238
+ @property
239
+ def name(self) -> str:
240
+ return self.__name
241
+
242
+ SCANNER = None
243
+ SCANNER_EVENT_LOOP = None
244
+
245
+ SCANNER_CONTINOUS = False #Controls if scanning will continously run
246
+ SCANNER_TIMEOUT = 5 #Controls the scanners timeout
247
+ 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.
248
+ 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
249
+
250
+ #Format: Address - dict = { device: ..., adv: ..., last_found: ... }
251
+ discovered_devices: dict[str,dict] = {}
252
+
253
+ @classmethod
254
+ def __lazy_init_scanner(cls):
255
+ if cls.SCANNER is None:
256
+ cls.SCANNER = BleakScanner(detection_callback=cls.__detection_callback, service_uuids=[NORDIC_UART_SERVICE_UUID])
257
+ cls.SCANNER_EVENT_LOOP = asyncio.new_event_loop()
258
+
259
+ @classmethod
260
+ def __detection_callback(cls, device: BLEDevice, adv: AdvertisementData):
261
+ cls.discovered_devices[device.address] = {"device": device, "adv": adv, "last_found": time.time()}
262
+
263
+ @classmethod
264
+ def set_scanner_continous(cls, continous: bool):
265
+ """
266
+ If not using continous mode, functions like update_nearby_devices and auto_detect are blocking with the following rules:
267
+ - Will search for at most SCANNER_TIMEOUT time
268
+ - Will stop searching immediately once SCANNER_FIND_COUNT is reached
269
+
270
+ If using continous mode, no scanning functions are blocking. However, the user must continously call
271
+ update_nearby_devices to ensure up to date information.
272
+ """
273
+ cls.__lazy_init_scanner()
274
+ cls.SCANNER_CONTINOUS = continous
275
+ if continous: cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.start())
276
+ else: cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.stop())
277
+
278
+ @classmethod
279
+ def update_nearby_devices(cls):
280
+ """
281
+ Updates ThreespaceBLEComClass.discovered_devices using the current configuration.
282
+ """
283
+ cls.__lazy_init_scanner()
284
+ if cls.SCANNER_CONTINOUS:
285
+ #Allow the callbacks for nearby devices to trigger
286
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.__wait_for_callbacks_async())
287
+ #Remove expired devices
288
+ cur_time = time.time()
289
+ to_remove = [] #Avoiding concurrent list modification
290
+ for device in cls.discovered_devices:
291
+ if cur_time - cls.discovered_devices[device]["last_found"] > cls.SCANNER_EXPIRATION_TIME:
292
+ to_remove.append(device)
293
+ for device in to_remove:
294
+ del cls.discovered_devices[device]
295
+
296
+ else:
297
+ #Mark all devices as invalid before searching for nearby devices
298
+ cls.discovered_devices.clear()
299
+ start_time = time.time()
300
+ end_time = cls.SCANNER_TIMEOUT or float('inf')
301
+ end_count = cls.SCANNER_FIND_COUNT or float('inf')
302
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.start())
303
+ while time.time() - start_time < end_time and len(cls.discovered_devices) < end_count:
304
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.__wait_for_callbacks_async())
305
+ cls.SCANNER_EVENT_LOOP.run_until_complete(cls.SCANNER.stop())
306
+
307
+ return cls.discovered_devices
308
+
309
+ @classmethod
310
+ def get_discovered_nearby_devices(cls):
311
+ """
312
+ A helper to get a copy of the discovered devices
313
+ """
314
+ return cls.discovered_devices.copy()
315
+
316
+ @staticmethod
317
+ def auto_detect() -> Generator["ThreespaceBLEComClass", None, None]:
318
+ """
319
+ Returns a list of com classes of the same type called on nearby.
320
+ These ports will start unopened. This allows the caller to get a list of ports without having to connect.
321
+ """
322
+ cls = ThreespaceBLEComClass
323
+ cls.update_nearby_devices()
324
+ for device_info in cls.discovered_devices.values():
325
+ yield(ThreespaceBLEComClass(device_info["device"]))
@@ -1,6 +1,7 @@
1
1
  from yostlabs.communication.base import *
2
2
  import serial
3
3
  import serial.tools.list_ports
4
+ import time
4
5
 
5
6
 
6
7
  class ThreespaceSerialComClass(ThreespaceComClass):
@@ -107,7 +108,10 @@ class ThreespaceSerialComClass(ThreespaceComClass):
107
108
 
108
109
  @timeout.setter
109
110
  def timeout(self, timeout: float):
110
- self.ser.timeout = timeout
111
+ self.ser.timeout = timeout
112
+ #There is a bug in Windows drivers that requires a delay after setting timeout
113
+ #When using certain Serial Interfaces
114
+ time.sleep(0.01)
111
115
 
112
116
  @property
113
117
  def reenumerates(self) -> bool:
@@ -12,16 +12,15 @@ def quat_mul(a: list[float], b: list[float]):
12
12
 
13
13
  #Rotates quaternion b by quaternion a, it does not combine them
14
14
  def quat_rotate(a: list[float], b: list[float]):
15
- inv = a.copy()
16
- inv[0] *= -1
17
- inv[1] *= -1
18
- inv[2] *= -1
15
+ inv = quat_inverse(a)
19
16
  axis = [b[0], b[1], b[2], 0]
20
17
  halfway = quat_mul(a, axis)
21
18
  final = quat_mul(halfway, inv)
22
19
  return [*final[:3], b[3]]
23
20
 
24
21
  def quat_inverse(quat: list[float]):
22
+ #Note: While technically negating just the W is rotationally equivalent, this is not a good idea
23
+ #as it will conflict with rotating vectors, which are not rotations, by quaternions
25
24
  return [-quat[0], -quat[1], -quat[2], quat[3]]
26
25
 
27
26
  def quat_rotate_vec(quat: list[float], vec: list[float]):
@@ -43,7 +42,6 @@ def angles_to_quaternion(angles: list[float], order: str, degrees=True):
43
42
  imaginary = math.sin(angle / 2)
44
43
  unit_vec = [v * imaginary for v in unit_vec]
45
44
  angle_quat = [*unit_vec, w]
46
- #print(f"{axis} {angle} {angle_quat}")
47
45
  quat = quat_mul(quat, angle_quat)
48
46
  return quat
49
47
 
@@ -134,6 +132,37 @@ def quaternion_global_to_local(quat, vec):
134
132
  def quaternion_local_to_global(quat, vec):
135
133
  return quat_rotate_vec(quat, vec)
136
134
 
135
+ def quaternion_swap_axes(quat: list, old_order: str, new_order: str):
136
+ return quaternion_swap_axes_fast(quat, _vec.parse_axis_string_info(old_order), _vec.parse_axis_string_info(new_order))
137
+
138
+ def quaternion_swap_axes_fast(quat: list, old_parsed_order: list[list, list, bool], new_parsed_order: list[list, list, bool]):
139
+ """
140
+ Like quaternion_swap_axes but uses the inputs of parsing the axis strings to avoid having to recompute
141
+ the storage types.
142
+
143
+ each order should be a sequence of [order, mults, right_handed]
144
+ """
145
+ old_order, old_mults, old_right_handed = old_parsed_order
146
+ new_order, new_mults, new_right_handed = new_parsed_order
147
+
148
+ #Undo the old negations
149
+ base_quat = quat.copy()
150
+ for i, mult in enumerate(old_mults):
151
+ base_quat[i] *= mult
152
+
153
+ #Now swap the positions and apply new multipliers
154
+ new_quat = base_quat.copy()
155
+ for i in range(3):
156
+ new_quat[i] = base_quat[old_order.index(new_order[i])]
157
+ new_quat[i] *= new_mults[i]
158
+
159
+ if old_right_handed != new_right_handed:
160
+ #Different handed systems rotate opposite directions. So to maintain the same rotation,
161
+ #invert the quaternion
162
+ new_quat = quat_inverse(new_quat)
163
+
164
+ return new_quat
165
+
137
166
  #https://splines.readthedocs.io/en/latest/rotation/slerp.html
138
167
  def slerp(a, b, t):
139
168
  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,34 +468,43 @@ 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
+ self.verbose = verbose
475
+
476
+ manually_opened_com = False
471
477
  #Auto discover using the supplied com class type
472
478
  if inspect.isclass(com) and issubclass(com, ThreespaceComClass):
473
479
  new_com = None
480
+ self.log("Auto-Discovering Sensor")
474
481
  for serial_com in com.auto_detect():
475
482
  new_com = serial_com
476
483
  break #Exit after getting 1
477
484
  if new_com is None:
478
- raise RuntimeError("Failed to auto discover com port")
485
+ raise RuntimeError("Failed to auto discover com port")
479
486
  self.com = new_com
487
+ manually_opened_com = True
480
488
  self.com.open()
481
489
  #The supplied com already was a com class, nothing to do
482
490
  elif inspect.isclass(type(com)) and issubclass(type(com), ThreespaceComClass):
483
491
  self.com = com
492
+ if not self.com.check_open():
493
+ self.com.open()
494
+ manually_opened_com = True
484
495
  else: #Unknown type, try making a ThreespaceSerialComClass out of this
485
496
  try:
486
497
  self.com = ThreespaceSerialComClass(com)
487
498
  except:
488
499
  raise ValueError("Failed to create default ThreespaceSerialComClass from parameter:", type(com), com)
489
500
 
501
+ self.restart_delay = 0.5
502
+
503
+ self.log("Configuring sensor communication")
490
504
  self.immediate_debug = True #Assume it is on from the start. May cause it to take slightly longer to initialize, but prevents breaking if it is on
491
505
  #Callback gives the debug message and sensor object that caused it
492
506
  self.__debug_cache: list[str] = [] #Used for storing startup debug messages until sensor state is confirmed
493
507
 
494
- self.verbose = verbose
495
508
  self.debug_callback: Callable[[str, ThreespaceSensor],None] = self.__default_debug_callback
496
509
  self.misaligned = False
497
510
  self.dirty_cache = False
@@ -502,7 +515,15 @@ class ThreespaceSensor:
502
515
  self.is_data_streaming = False
503
516
  self.is_log_streaming = False
504
517
  self.is_file_streaming = False
518
+ self.log("Stopping potential streaming")
505
519
  self._force_stop_streaming()
520
+ #Clear out the buffer to allow faster initializing
521
+ #Ex: If a large buffer build up due to streaming, especially if using a slower interface like BLE,
522
+ #it may take a while before the entire garbage data can be parsed when checking for bootloader, causing a timeout
523
+ #even though it would have eventually succeeded
524
+ self.log("Clearing com")
525
+ self.__clear_com(initial_clear_timeout)
526
+
506
527
 
507
528
  #Used to ensure connecting to the correct sensor when reconnecting
508
529
  self.serial_number = None
@@ -514,12 +535,24 @@ class ThreespaceSensor:
514
535
  self.getStreamingBatchCommand: ThreespaceGetStreamingBatchCommand = None
515
536
  self.funcs = {}
516
537
 
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()
538
+ self.log("Checking firmware status")
539
+ try:
540
+ self.__cached_in_bootloader = self.__check_bootloader_status()
541
+ if not self.in_bootloader:
542
+ self.log("Initializing firmware")
543
+ self.__firmware_init()
544
+ else:
545
+ self.log("Initializing bootloader")
546
+ self.__cache_serial_number(self.bootloader_get_sn())
547
+ self.__empty_debug_cache()
548
+ #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
549
+ #If user provides the com class, it is up to them to handle its state on error
550
+ except Exception as e:
551
+ self.log("Failed to initialize sensor")
552
+ if manually_opened_com:
553
+ self.com.close()
554
+ raise e
555
+ self.log("Successfully initialized sensor")
523
556
 
524
557
  #Just a helper for outputting information
525
558
  def log(self, *args):
@@ -528,6 +561,17 @@ class ThreespaceSensor:
528
561
 
529
562
  #-----------------------INITIALIZIATION & REINITIALIZATION-----------------------------------
530
563
 
564
+ def __clear_com(self, refresh_timeout=None):
565
+ data = self.com.read_all()
566
+ if refresh_timeout is None: return
567
+ while len(data) > 0: #Continue until all data is cleared
568
+ self.log(f"Refresh clear Length: {len(data)}")
569
+ start_time = time.time()
570
+ while time.time() - start_time < refresh_timeout: #Wait up to refresh time for a new message
571
+ data = self.com.read_all()
572
+ if len(data) > 0:
573
+ break #Refresh the start time and wait for more data
574
+
531
575
  def __firmware_init(self):
532
576
  """
533
577
  Should only be called when not streaming and known in firmware.
@@ -619,8 +663,8 @@ class ThreespaceSensor:
619
663
  method = types.MethodType(command.custom_func, self)
620
664
  else:
621
665
  #Build the actual method for executing the command
622
- code = f"def {command.info.name}(self, *args):\n"
623
- code += f" return self.execute_command(self.commands[{command.info.num}], *args)"
666
+ code = f"def {command.info.name}(self, *args, **kwargs):\n"
667
+ code += f" return self.execute_command(self.commands[{command.info.num}], *args, **kwargs)"
624
668
  exec(code, globals(), self.funcs)
625
669
  method = types.MethodType(self.funcs[command.info.name], self)
626
670
 
@@ -734,6 +778,17 @@ class ThreespaceSensor:
734
778
 
735
779
  #-----------------------------------------------BASE SETTINGS PROTOCOL------------------------------------------------
736
780
 
781
+ #Helper for converting python types to strings that set_settings can understand
782
+ def __internal_str(self, value):
783
+ if isinstance(value, float):
784
+ return f"{value:.10f}"
785
+ elif isinstance(value, bool):
786
+ return int(value)
787
+ elif isinstance(value, Enum):
788
+ return str(value.value)
789
+ else:
790
+ return str(value)
791
+
737
792
  #Can't just do if "header" in string because log_header_enabled exists and doesn't actually require cacheing the header
738
793
  HEADER_KEYS = ["header", "header_status", "header_timestamp", "header_echo", "header_checksum", "header_serial", "header_length"]
739
794
  def set_settings(self, param_string: str = None, **kwargs):
@@ -745,10 +800,10 @@ class ThreespaceSensor:
745
800
 
746
801
  for key, value in kwargs.items():
747
802
  if isinstance(value, list):
748
- value = [str(v) for v in value]
803
+ value = [self.__internal_str(v) for v in value]
749
804
  value = ','.join(value)
750
- elif isinstance(value, bool):
751
- value = int(value)
805
+ else:
806
+ value = self.__internal_str(value)
752
807
  params.append(f"{key}={value}")
753
808
  cmd = f"!{';'.join(params)}\n"
754
809
 
@@ -757,8 +812,7 @@ class ThreespaceSensor:
757
812
  return 0xFF, 0xFF
758
813
 
759
814
  #For dirty check
760
- keys = cmd[1:-1].split(';')
761
- keys = [v.split('=')[0] for v in keys]
815
+ param_dict = threespaceSetSettingsStringToDict(cmd[1:-1])
762
816
 
763
817
  #Must enable this before sending the set so can properly handle reading the response
764
818
  if "debug_mode=1" in cmd:
@@ -786,14 +840,14 @@ class ThreespaceSensor:
786
840
  #Handle updating state variables based on settings
787
841
  #If the user modified the header, need to cache the settings so the API knows how to interpret responses
788
842
  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
843
+ if any(v in param_dict.keys() for v in ThreespaceSensor.HEADER_KEYS): #Then do a longer check
790
844
  self.__cache_header_settings()
791
845
 
792
846
  if "stream_slots" in cmd.lower():
793
847
  self.__cache_streaming_settings()
794
848
 
795
849
  #All the settings changed, just need to mark dirty
796
- if any(v in keys for v in ("default", "reboot")):
850
+ if any(v in param_dict.keys() for v in ("default", "reboot")):
797
851
  self.set_cached_settings_dirty()
798
852
 
799
853
  if err:
@@ -965,11 +1019,18 @@ class ThreespaceSensor:
965
1019
  """
966
1020
  header_len = len(header.raw_binary)
967
1021
  if header.length > max_data_length:
968
- self.log("DATA TOO BIG:", header.length)
1022
+ if not self.misaligned:
1023
+ self.log("DATA TOO BIG:", header.length)
969
1024
  return False
970
1025
  data = self.com.peek(header_len + header.length)[header_len:]
971
- if len(data) != header.length: return False
1026
+ if len(data) != header.length:
1027
+ if not self.misaligned:
1028
+ self.log(f"Data Length Mismatch - Got: {len(data)} Expected: {header.length}")
1029
+ return False
972
1030
  checksum = sum(data) % 256
1031
+ if checksum != header.checksum and not self.misaligned:
1032
+ self.log(f"Checksum Mismatch - Got: {checksum} Expected: {header.checksum}")
1033
+ self.log(f"Data: {data}")
973
1034
  return checksum == header.checksum
974
1035
 
975
1036
  def __await_command(self, cmd: ThreespaceCommand, timeout=2):
@@ -1006,7 +1067,7 @@ class ThreespaceSensor:
1006
1067
 
1007
1068
  #------------------------------BASE INPUT PARSING--------------------------------------------
1008
1069
 
1009
- def __internal_update(self, header: ThreespaceHeader = None):
1070
+ def __internal_update(self, header: ThreespaceHeader = None, blocking=True):
1010
1071
  """
1011
1072
  Manages checking the datastream for asynchronous responses (Streaming, Immediate Debug Messages).
1012
1073
  If no data is found to match these responses, the data buffer will be considered corrupted/misaligned
@@ -1021,29 +1082,39 @@ class ThreespaceSensor:
1021
1082
 
1022
1083
  Returns
1023
1084
  --------
1024
- False : Misalignment
1025
- True : Internal Data Found/Parsed
1085
+ 0 : Internal Data Found/Parsed
1086
+ 1 : Not enough data (Only possible when blocking == False)
1087
+ 2 : Misalignment
1026
1088
  """
1027
1089
  checksum_match = False #Just for debugging
1028
1090
 
1029
1091
  if header is not None:
1030
1092
  #NOTE: FOR THIS TO WORK IT IS REQUIRED THAT THE HEADER DOES NOT CHANGE WHILE STREAMING ANY FORM OF DATA.
1031
1093
  #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
1094
+ if self.is_data_streaming and header.echo == THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM:
1095
+ if not blocking:
1096
+ expected_output_size = len(header.raw_binary) + self.getStreamingBatchCommand.info.out_size
1097
+ if self.com.length < expected_output_size: return THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA
1033
1098
  if checksum_match := self.__peek_checksum(header, max_data_length=self.getStreamingBatchCommand.info.out_size):
1034
1099
  self.__update_base_streaming()
1035
1100
  self.misaligned = False
1036
- return True
1101
+ return THREESPACE_UPDATE_COMMAND_PARSED
1037
1102
  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
1103
+ if not blocking:
1104
+ expected_output_size = len(header.raw_binary) + min(header.length, THREESPACE_LIVE_LOG_STREAM_MAX_PACKET_SIZE)
1105
+ if self.com.length < expected_output_size: return THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA
1106
+ if checksum_match := self.__peek_checksum(header, max_data_length=THREESPACE_LIVE_LOG_STREAM_MAX_PACKET_SIZE):
1039
1107
  self.__update_log_streaming()
1040
1108
  self.misaligned = False
1041
- return True
1109
+ return THREESPACE_UPDATE_COMMAND_PARSED
1042
1110
  elif self.is_file_streaming and header.echo == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
1111
+ if not blocking:
1112
+ expected_output_size = len(header.raw_binary) + min(header.length, THREESPACE_FILE_STREAMING_MAX_PACKET_SIZE)
1113
+ if self.com.length < expected_output_size: return THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA
1043
1114
  if checksum_match := self.__peek_checksum(header, max_data_length=THREESPACE_FILE_STREAMING_MAX_PACKET_SIZE):
1044
1115
  self.__update_file_streaming()
1045
1116
  self.misaligned = False
1046
- return True
1117
+ return THREESPACE_UPDATE_COMMAND_PARSED
1047
1118
 
1048
1119
  #Debug messages are possible and there is enough data to potentially be a debug message
1049
1120
  #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 +1129,7 @@ class ThreespaceSensor:
1058
1129
  message = self.com.readline() #Read out the whole message!
1059
1130
  self.debug_callback(message.decode('ascii'), self)
1060
1131
  self.misaligned = False
1061
- return True
1132
+ return THREESPACE_UPDATE_COMMAND_PARSED
1062
1133
 
1063
1134
  #The response didn't match any of the expected asynchronous streaming API responses, so assume a misalignment
1064
1135
  if header is not None:
@@ -1069,7 +1140,7 @@ class ThreespaceSensor:
1069
1140
  msg = "Possible Misalignment or corruption/debug message"
1070
1141
  #self.log("Misaligned:", self.com.peek(1))
1071
1142
  self.__handle_misalignment(msg)
1072
- return False
1143
+ return THREESPACE_UPDATE_COMMAND_MISALIGNED
1073
1144
 
1074
1145
  def __handle_misalignment(self, message: str = None):
1075
1146
  if not self.misaligned and message is not None:
@@ -1167,12 +1238,21 @@ class ThreespaceSensor:
1167
1238
  self.streaming_packets.clear()
1168
1239
 
1169
1240
  #This is called for all streaming types
1170
- def updateStreaming(self, max_checks=float('inf')):
1241
+ def updateStreaming(self, max_checks=float('inf'), timeout=None, blocking=False):
1171
1242
  """
1172
1243
  Returns true if any amount of data was processed whether valid or not. This is called for all streaming types.
1244
+
1245
+ Parameters
1246
+ ----------
1247
+ max_checks : Will only attempt to read up to max_checks packets
1248
+ timeout : Will only attempt to read packets for this duration. It is possible for this function to take longer then this timeout \
1249
+ if blocking = True, in which case it could take up to timeout + com.timeout
1250
+ blocking : If False, will immediately stop when not enough data is available. If true, will immediately stop if not enough data \
1251
+ for a header, but will block when trying to retrieve the data associated with that header. For most com classes, this does not matter. \
1252
+ But for communication such as BLE where the header and data may be split between different packets, this will have a clear effect.
1173
1253
  """
1174
1254
  if not self.is_streaming: return False
1175
-
1255
+ if timeout is None: timeout = float('inf')
1176
1256
  #I may need to make this have a max num bytes it will process before exiting to prevent locking up on slower machines
1177
1257
  #due to streaming faster then the program runs
1178
1258
  num_checks = 0
@@ -1182,12 +1262,17 @@ class ThreespaceSensor:
1182
1262
  return data_processed
1183
1263
 
1184
1264
  #Get header
1265
+
1185
1266
  header = self.com.peek(self.header_info.size)
1186
1267
 
1187
1268
  #Get the header and send it to the internal update
1188
1269
  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
1270
+ result = self.__internal_update(header, blocking=blocking)
1271
+ if result == THREESPACE_UPDATE_COMMAND_PARSED:
1272
+ data_processed = True
1273
+ elif result == THREESPACE_UPDATE_COMMAND_NOT_ENOUGH_DATA:
1274
+ return data_processed
1275
+
1191
1276
  num_checks += 1
1192
1277
 
1193
1278
  return data_processed
@@ -1309,7 +1394,7 @@ class ThreespaceSensor:
1309
1394
  cmd.send_command(self.com)
1310
1395
  self.com.close()
1311
1396
  #TODO: Make this actually wait instead of an arbitrary sleep length
1312
- time.sleep(0.5) #Give it time to restart
1397
+ time.sleep(self.restart_delay) #Give it time to restart
1313
1398
  self.com.open()
1314
1399
  self.__firmware_init()
1315
1400
 
@@ -1320,7 +1405,7 @@ class ThreespaceSensor:
1320
1405
  cmd = self.commands[THREESPACE_ENTER_BOOTLOADER_COMMAND_NUM]
1321
1406
  cmd.send_command(self.com)
1322
1407
  #TODO: Make this actually wait instead of an arbitrary sleep length
1323
- time.sleep(0.5) #Give it time to boot into bootloader
1408
+ time.sleep(self.restart_delay) #Give it time to boot into bootloader
1324
1409
  if self.com.reenumerates:
1325
1410
  self.com.close()
1326
1411
  success = self.__attempt_rediscover_self()
@@ -1384,7 +1469,7 @@ class ThreespaceSensor:
1384
1469
  def bootloader_boot_firmware(self):
1385
1470
  if not self.in_bootloader: return
1386
1471
  self.com.write("B".encode())
1387
- time.sleep(0.5) #Give time to boot into firmware
1472
+ time.sleep(self.restart_delay) #Give time to boot into firmware
1388
1473
  if self.com.reenumerates:
1389
1474
  self.com.close()
1390
1475
  success = self.__attempt_rediscover_self()
@@ -1401,13 +1486,14 @@ class ThreespaceSensor:
1401
1486
  This may take a long time
1402
1487
  """
1403
1488
  self.com.write('S'.encode())
1404
- if timeout is not None:
1405
- cached_timeout = self.com.timeout
1406
- self.com.timeout = timeout
1407
- response = self.com.read(1)[0]
1408
- if timeout is not None:
1409
- self.com.timeout = cached_timeout
1410
- return response
1489
+
1490
+ start_time = time.perf_counter()
1491
+ response = []
1492
+ while len(response) == 0 and time.perf_counter() - start_time < timeout:
1493
+ response = self.com.read(1)
1494
+ if len(response) == 0:
1495
+ return -1
1496
+ return response[0]
1411
1497
 
1412
1498
  def bootloader_get_info(self):
1413
1499
  self.com.write('I'.encode())
@@ -1417,14 +1503,20 @@ class ThreespaceSensor:
1417
1503
  bootversion = struct.unpack(f">{_3space_format_to_external('I')}", self.com.read(2))[0]
1418
1504
  return ThreespaceBootloaderInfo(memstart, memend, pagesize, bootversion)
1419
1505
 
1420
- def bootloader_prog_mem(self, bytes: bytearray):
1506
+ def bootloader_prog_mem(self, bytes: bytearray, timeout=5):
1421
1507
  memsize = len(bytes)
1422
1508
  checksum = sum(bytes)
1423
1509
  self.com.write('C'.encode())
1424
1510
  self.com.write(struct.pack(f">{_3space_format_to_external('I')}", memsize))
1425
1511
  self.com.write(bytes)
1426
1512
  self.com.write(struct.pack(f">{_3space_format_to_external('B')}", checksum & 0xFFFF))
1427
- return self.com.read(1)[0]
1513
+ start_time = time.perf_counter()
1514
+ result = []
1515
+ while len(result) == 0 and time.perf_counter() - start_time < timeout:
1516
+ result = self.com.read(1)
1517
+ if len(result) > 0:
1518
+ return result[0]
1519
+ return -1
1428
1520
 
1429
1521
  def bootloader_get_state(self):
1430
1522
  self.com.write('OO'.encode()) #O is sent twice to compensate for a bug in some versions of the bootloader where the next character is ignored (except for R, do NOT send R after O, it will erase all settings)
@@ -1436,20 +1528,26 @@ class ThreespaceSensor:
1436
1528
  self.com.write("RR".encode())
1437
1529
 
1438
1530
  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()
1531
+ error = None
1532
+ try:
1533
+ if not self.in_bootloader:
1534
+ if self.is_data_streaming:
1535
+ self.stopStreaming()
1536
+ if self.is_file_streaming:
1537
+ self.fileStopStream()
1538
+ if self.is_log_streaming:
1539
+ self.stopDataLogging()
1540
+
1541
+ #The sensor may or may not have this command registered. So just try it
1542
+ try:
1543
+ #May not be opened, but also not cacheing that so just attempt to close.
1544
+ self.closeFile()
1545
+ except: pass
1546
+ except Exception as e:
1547
+ error = e
1548
+ 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
1549
+ if error:
1550
+ raise error
1453
1551
 
1454
1552
  #-------------------------START ALL PROTOTYPES------------------------------------
1455
1553
 
@@ -1738,4 +1836,17 @@ def threespaceGetHeaderLabels(header_info: ThreespaceHeaderInfo):
1738
1836
  order.append("serial#")
1739
1837
  if header_info.length_enabled:
1740
1838
  order.append("len")
1741
- return order
1839
+ return order
1840
+
1841
+ def threespaceSetSettingsStringToDict(setting_string: str):
1842
+ d = {}
1843
+ for item in setting_string.split(';'):
1844
+ result = item.split('=')
1845
+ key = result[0]
1846
+ if len(result) == 1:
1847
+ value = None
1848
+ else:
1849
+ value = '='.join(result[1:]) #In case = was part of the value, do a join
1850
+
1851
+ d[key] = value
1852
+ 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