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.
- yostlabs-2025.4.28/Examples/example_ble.py +23 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/PKG-INFO +22 -1
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/README.md +20 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/pyproject.toml +3 -2
- yostlabs-2025.4.28/src/yostlabs/communication/ble.py +325 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/communication/serial.py +5 -1
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/math/quaternion.py +34 -5
- yostlabs-2025.4.28/src/yostlabs/math/vector.py +81 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/api.py +173 -62
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/consts.py +1 -0
- yostlabs-2025.2.17/src/yostlabs/math/vector.py +0 -31
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/.gitignore +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/embedded_2024_dec_20.xml +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_commands.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_component_specific_settings_and_commands.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_firmware_upload.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_parsing_stored_binary.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_read_settings.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_streaming.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_streaming_manager.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/Examples/example_write_settings.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/LICENSE +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/__init__.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/communication/__init__.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/communication/base.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/math/__init__.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/__init__.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/eepts.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/__init__.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/calibration.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/parser.py +0 -0
- {yostlabs-2025.2.17 → yostlabs-2025.4.28}/src/yostlabs/tss3/utils/streaming.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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.
|
|
518
|
-
|
|
519
|
-
self.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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 = [
|
|
803
|
+
value = [self.__internal_str(v) for v in value]
|
|
749
804
|
value = ','.join(value)
|
|
750
|
-
|
|
751
|
-
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
1025
|
-
|
|
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:
|
|
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
|
|
1101
|
+
return THREESPACE_UPDATE_COMMAND_PARSED
|
|
1037
1102
|
elif self.is_log_streaming and header.echo == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
|
|
1038
|
-
if
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
response
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
#
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|