yostlabs 2025.10.29.1__tar.gz → 2026.2.4__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 (36) hide show
  1. yostlabs-2026.2.4/Examples/example_commands.py +33 -0
  2. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/PKG-INFO +1 -1
  3. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/pyproject.toml +1 -1
  4. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/communication/bluetooth.py +73 -9
  5. yostlabs-2026.2.4/src/yostlabs/math/quaternion.py +354 -0
  6. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/math/vector.py +3 -2
  7. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/api.py +66 -1
  8. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/consts.py +2 -0
  9. yostlabs-2025.10.29.1/Examples/example_commands.py +0 -26
  10. yostlabs-2025.10.29.1/src/yostlabs/math/quaternion.py +0 -187
  11. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/.gitignore +0 -0
  12. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/embedded_2024_dec_20.xml +0 -0
  13. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_ble.py +0 -0
  14. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_component_specific_settings_and_commands.py +0 -0
  15. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_firmware_upload.py +0 -0
  16. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_parsing_stored_binary.py +0 -0
  17. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_read_settings.py +0 -0
  18. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_streaming.py +0 -0
  19. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_streaming_manager.py +0 -0
  20. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/Examples/example_write_settings.py +0 -0
  21. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/LICENSE +0 -0
  22. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/README.md +0 -0
  23. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/__init__.py +0 -0
  24. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/communication/__init__.py +0 -0
  25. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/communication/base.py +0 -0
  26. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/communication/ble.py +0 -0
  27. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/communication/serial.py +0 -0
  28. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/communication/socket.py +0 -0
  29. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/math/__init__.py +0 -0
  30. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/__init__.py +0 -0
  31. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/eepts.py +0 -0
  32. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/utils/__init__.py +0 -0
  33. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/utils/calibration.py +0 -0
  34. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/utils/parser.py +0 -0
  35. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/utils/streaming.py +0 -0
  36. {yostlabs-2025.10.29.1 → yostlabs-2026.2.4}/src/yostlabs/tss3/utils/version.py +0 -0
@@ -0,0 +1,33 @@
1
+ from yostlabs.tss3.api import ThreespaceSensor
2
+ from yostlabs.communication.serial import ThreespaceSerialComClass
3
+
4
+ #Create a sensor by auto detecting a ThreespaceSerialComClass
5
+ sensor = ThreespaceSensor(ThreespaceSerialComClass)
6
+
7
+ result = sensor.getPrimaryCorrectedAccelVec()
8
+
9
+ #Read out the result
10
+ accel_vec = result.data
11
+
12
+ #The result is a dataclass that allows easily accessing
13
+ # individual parts of the data and multiple ways of interpreting it
14
+ print("Result:")
15
+ #The base ThreespaceCmdResult dataclass
16
+ print(result)
17
+ #The actual byte response from the sensor that made this result (header + data)
18
+ print(result.raw_binary)
19
+ print()
20
+ print("Header:")
21
+ #The ThreespaceHeader data class
22
+ print(result.header)
23
+ #The individual components of the header as an array
24
+ #similar to how the old API functioned
25
+ print(result.header.raw)
26
+ #The actual byte representation of the header from the sensor
27
+ print(result.header.raw_binary)
28
+ print()
29
+ print("Data:")
30
+ print(f"{result.data=}")
31
+ print(f"{result.raw_data=}")
32
+
33
+ sensor.cleanup()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yostlabs
3
- Version: 2025.10.29.1
3
+ Version: 2026.2.4
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
@@ -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.10.29.1"
8
+ version = "2026.2.4"
9
9
  authors = [
10
10
  { name="Yost Labs Inc.", email="techsupport@yostlabs.com" },
11
11
  { name="Andy Riedlinger", email="techsupport@yostlabs.com" },
@@ -2,6 +2,8 @@ from yostlabs.communication.socket import ThreespaceSocketComClass
2
2
  import bluetooth
3
3
  import time
4
4
  import threading
5
+ import sys
6
+ import math
5
7
 
6
8
  from dataclasses import dataclass
7
9
  from typing import Callable, Generator
@@ -94,17 +96,21 @@ class ScannerResult:
94
96
 
95
97
  class Scanner:
96
98
 
97
- def __init__(self, interval=5):
99
+ def __init__(self, desired_scan_time=5):
98
100
  self.enabled = False
99
101
  self.done = True
100
102
 
101
- self.nearby = None
102
- self.thread = None
103
- self.updated = False
104
- self.duration = int(interval / 1.2)
103
+ self.continous = False #If true, will constantly update the nearby devices
104
+ self.execute = False #Set to true to trigger a read when not in continous mode
105
+ self.nearby = None #The list of nearby devices
106
+ self.thread = None #The thread running the asynchronous scanner
107
+ self.updated = False #Set to true when self.nearby has been updated
108
+ self.duration = math.ceil(desired_scan_time / 1.28) * 1.28 #The time, in second, each scan lasts. Scanning is in 1.28 second intervals, so this may differ from the desired scan time
105
109
 
106
110
  def start(self):
107
- if not self.done: return
111
+ if not self.done:
112
+ self.execute = True
113
+ return
108
114
  self.thread = threading.Thread(target=self.process, daemon=True)
109
115
  self.enabled = True
110
116
  self.done = False
@@ -114,6 +120,9 @@ class Scanner:
114
120
  def stop(self):
115
121
  self.enabled = False
116
122
 
123
+ def set_continous(self, continous: bool):
124
+ self.continous = continous
125
+
117
126
  def get_most_recent(self):
118
127
  if not self.updated: return None
119
128
  self.updated = False
@@ -125,20 +134,57 @@ class Scanner:
125
134
 
126
135
  def process(self):
127
136
  while self.enabled:
128
- nearby = bluetooth.discover_devices(duration=self.duration, lookup_names=True, lookup_class=True)
137
+ if not self.continous and not self.execute: continue
138
+ nearby = bluetooth.discover_devices(duration=math.ceil(self.duration/1.28), lookup_names=True, lookup_class=True)
129
139
  self.nearby = [ScannerResult(addr, name, decode_class_of_device(cod)) for addr, name, cod in nearby]
140
+ self.execute = False
130
141
  self.updated = True
131
142
  self.done = True
132
143
 
144
+ if sys.platform == "win32":
145
+ import ctypes
146
+ from ctypes import wintypes
147
+
148
+ # Define BLUETOOTH_ADDRESS structure
149
+ class BLUETOOTH_ADDRESS(ctypes.Structure):
150
+ _fields_ = [("rgBytes", ctypes.c_byte * 6)]
151
+
152
+ # Load Bluetooth API
153
+ win_bluetooth = ctypes.WinDLL("BluetoothAPIs.dll")
154
+
155
+ # Define function prototype
156
+ BluetoothRemoveDevice = win_bluetooth.BluetoothRemoveDevice
157
+ BluetoothRemoveDevice.argtypes = [ctypes.POINTER(BLUETOOTH_ADDRESS)]
158
+ BluetoothRemoveDevice.restype = wintypes.BOOL
159
+
160
+ def remove_device(mac_address: str):
161
+ # Convert MAC string "XX:XX:XX:XX:XX:XX" -> bytes reversed
162
+ addr_bytes = bytes.fromhex(mac_address.replace(":", ""))[::-1]
163
+ addr = BLUETOOTH_ADDRESS()
164
+ ctypes.memmove(addr.rgBytes, addr_bytes, 6)
165
+
166
+ err = BluetoothRemoveDevice(ctypes.byref(addr))
167
+ print("Remove device status:", err)
168
+ if not err:
169
+ print(f"Successfully removed device {mac_address}")
170
+ else:
171
+ if err == 1168: # ERROR_NOT_FOUND
172
+ print(f"Device {mac_address} not found or already unpaired.")
173
+ else:
174
+ print(f"Failed to remove device {mac_address}. Error code: {err}")
175
+ else:
176
+ def remove_device(mac_address: str):
177
+ raise NotImplementedError("Only implemented on windows")
178
+
133
179
  class ThreespaceBluetoothComClass(ThreespaceSocketComClass):
134
180
 
135
- SCANNER = None
181
+ SCANNER: Scanner = None
136
182
 
137
183
  def __init__(self, addr: str, name: str = None, connection_timeout=None):
138
184
  super().__init__(bluetooth.BluetoothSocket(bluetooth.Protocols.RFCOMM), (addr, 1), connection_timeout=connection_timeout)
139
185
  self.address = addr
140
186
  self.__name = name or addr
141
-
187
+
142
188
  @property
143
189
  def name(self) -> str:
144
190
  return self.__name
@@ -149,6 +195,11 @@ class ThreespaceBluetoothComClass(ThreespaceSocketComClass):
149
195
  cls.SCANNER = Scanner()
150
196
  cls.SCANNER.start()
151
197
 
198
+ @classmethod
199
+ def set_scanner_continous(cls, continous: bool):
200
+ cls.__lazy_init_scanner()
201
+ cls.SCANNER.set_continous(continous)
202
+
152
203
  @staticmethod
153
204
  def __default_filter(result: ScannerResult):
154
205
  return result.class_of_device.major_class == "Wearable" and result.class_of_device.minor_class == "Minor code 0"
@@ -162,6 +213,7 @@ class ThreespaceBluetoothComClass(ThreespaceSocketComClass):
162
213
  cls.__lazy_init_scanner()
163
214
  if filter is None:
164
215
  filter = cls.__default_filter
216
+ cls.SCANNER.start()
165
217
  if wait_for_update:
166
218
  cls.SCANNER.updated = False
167
219
  while not cls.SCANNER.updated: time.sleep(0.1)
@@ -171,6 +223,18 @@ class ThreespaceBluetoothComClass(ThreespaceSocketComClass):
171
223
  name = device_info.name or None
172
224
  yield ThreespaceBluetoothComClass(device_info.address, name)
173
225
 
226
+ @staticmethod
227
+ def unpair(address: str):
228
+ """
229
+ It is recommended to call this after done with a ThreespaceBluetoothComClass object
230
+ so that windows will not report the device as still being available when powered off.
231
+ This occurs because windows Bluetooth detect functions report nearby devices & paired devices,
232
+ regardless of their power status. By removing the device from the list of paired devices, auto detect
233
+ will only report actual nearby and powered devices.
234
+ EX: ThreespaceBluetoothComClass.unpair(com.address)
235
+ """
236
+ remove_device(address)
237
+
174
238
 
175
239
  if __name__ == "__main__":
176
240
  from yostlabs.tss3.api import ThreespaceSensor
@@ -0,0 +1,354 @@
1
+ import math
2
+ import yostlabs.math.vector as _vec
3
+
4
+ def quat_mul(a: list[float], b: list[float]):
5
+ out = [0, 0, 0, 0]
6
+ x = 0; y = 1; z = 2; w = 3
7
+ out[w] = a[w]*b[w] - a[x]*b[x] - a[y]*b[y] - a[z]*b[z]
8
+ out[x] = a[w]*b[x] + a[x]*b[w] + a[y]*b[z] - a[z]*b[y]
9
+ out[y] = a[w]*b[y] + a[y]*b[w] + a[z]*b[x] - a[x]*b[z]
10
+ out[z] = a[w]*b[z] + a[z]*b[w] + a[x]*b[y] - a[y]*b[x]
11
+ return out
12
+
13
+ #Rotates quaternion b by quaternion a, it does not combine them
14
+ def quat_rotate(a: list[float], b: list[float]):
15
+ inv = quat_inverse(a)
16
+ axis = [b[0], b[1], b[2], 0]
17
+ halfway = quat_mul(a, axis)
18
+ final = quat_mul(halfway, inv)
19
+ return [*final[:3], b[3]]
20
+
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
24
+ return [-quat[0], -quat[1], -quat[2], quat[3]]
25
+
26
+ def quat_rotate_vec(quat: list[float], vec: list[float]):
27
+ inv = quat_inverse(quat)
28
+ tmp = [vec[0], vec[1], vec[2], 0]
29
+ halfway = quat_mul(quat, tmp)
30
+ final = quat_mul(halfway, inv)
31
+ return [final[0], final[1], final[2]]
32
+
33
+ def angles_to_quaternion(angles: list[float], order: str, degrees=True, extrinsic=False):
34
+ quat = [0, 0, 0, 1]
35
+ for i in range(len(angles)):
36
+ axis = order[i]
37
+ angle = angles[i]
38
+ if degrees:
39
+ angle = math.radians(angle)
40
+ unit_vec = _vec.axis_to_unit_vector(axis)
41
+ w = math.cos(angle / 2)
42
+ imaginary = math.sin(angle / 2)
43
+ unit_vec = [v * imaginary for v in unit_vec]
44
+ angle_quat = [*unit_vec, w]
45
+ if extrinsic:
46
+ quat = quat_mul(angle_quat, quat)
47
+ else:
48
+ quat = quat_mul(quat, angle_quat)
49
+ return quat
50
+
51
+ def quat_from_angles(angles: list[float], order: str, degrees=True, extrinsic=False):
52
+ return angles_to_quaternion(angles, order, degrees=degrees, extrinsic=extrinsic)
53
+
54
+ def quat_from_euler(angles: list[float], order: list[int], degrees=False, extrinsic=False):
55
+ return angles_to_quaternion(angles, order, degrees=degrees, extrinsic=extrinsic)
56
+
57
+ def quat_from_axis_angle(axis: list[float], angle: float):
58
+ imaginary = math.sin(angle / 2)
59
+ quat = [imaginary * v for v in axis]
60
+ quat.append(math.cos(angle / 2))
61
+ return quat
62
+
63
+ #There are multiple valid quats that can be returned by this. The intention of this function
64
+ #is to be able to rotate an arrow by the quat such that it points the correct direction. The rotation
65
+ #of that arrow along its axis may differ though
66
+ def quat_from_one_vector(vec: list[float]):
67
+ vec = _vec.vec_normalize(vec)
68
+ perpendicular = _vec.vec_normalize(_vec.vec_cross([0, 0, 1], vec))
69
+ angle = math.acos(_vec.vec_dot([0, 0, 1], vec))
70
+ return quat_from_axis_angle(perpendicular, angle)
71
+
72
+ def quat_from_two_vectors(forward: list[float], down: list[float]):
73
+ """
74
+ This function requires two orthogonal vectors to work
75
+ """
76
+ forward_reference = [0, 0, 1]
77
+ down_reference = [0, -1, 0]
78
+
79
+ forward = _vec.vec_normalize(forward)
80
+ down = _vec.vec_normalize(down)
81
+
82
+ #Create the first rotation to align the forward axis
83
+ axis_of_rotation = _vec.vec_cross(forward_reference, forward)
84
+ axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
85
+ if not any(abs(v) > 0 for v in axis_of_rotation):
86
+ axis_of_rotation = down_reference #This is just a direct 180 degree rotation around any orthogonal axis, so just use the down_ref
87
+ dot = min(1, max(-1, _vec.vec_dot(forward_reference, forward)))
88
+ angle1 = math.acos(dot)
89
+ imaginary = math.sin(angle1/2)
90
+ quat = [v * imaginary for v in axis_of_rotation] #XYZ
91
+ quat.append(math.cos(angle1/2)) #W
92
+
93
+ #Update the reference to figure out where it is after the turn
94
+ down_reference = quat_rotate_vec(quat, down_reference)
95
+
96
+ #find the rotation to make the remaining reference align with its given value
97
+ axis_of_rotation = _vec.vec_cross(down_reference, down)
98
+ axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
99
+ if not any(abs(v) > 0 for v in axis_of_rotation):
100
+ axis_of_rotation = forward #Rotate along the final forward vector until up is aligned
101
+ dot = min(1, max(-1, _vec.vec_dot(down_reference, down)))
102
+ angle2 = math.acos(dot)
103
+ imaginary = math.sin(angle2/2)
104
+ rotation = [v * imaginary for v in axis_of_rotation] #XYZ
105
+ rotation.append(math.cos(angle2/2)) #W
106
+
107
+ quat = quat_mul(rotation, quat)
108
+ return quat
109
+
110
+ def quaternion_to_3x3_rotation_matrix(quat):
111
+ """
112
+ Convert a quaternion in form x, y, z, w to a rotation matrix
113
+ """
114
+ x, y, z, w = quat
115
+
116
+ fTx = 2.0 * x
117
+ fTy = 2.0 * y
118
+ fTz = 2.0 * z
119
+ fTwx = fTx * w
120
+ fTwy = fTy * w
121
+ fTwz = fTz * w
122
+ fTxx = fTx * x
123
+ fTxy = fTy * x
124
+ fTxz = fTz * x
125
+ fTyy = fTy * y
126
+ fTyz = fTz * y
127
+ fTzz = fTz * z
128
+
129
+ out = [0] * 9
130
+ out[0] = 1.0-(fTyy+fTzz)
131
+ out[1] = fTxy-fTwz
132
+ out[2] = fTxz+fTwy
133
+ out[3] = fTxy+fTwz
134
+ out[4] = 1.0-(fTxx+fTzz)
135
+ out[5] = fTyz-fTwx
136
+ out[6] = fTxz-fTwy
137
+ out[7] = fTyz + fTwx
138
+ out[8] = 1.0 - (fTxx+fTyy)
139
+
140
+ return [
141
+ [out[0], out[1], out[2]],
142
+ [out[3], out[4], out[5]],
143
+ [out[6], out[7], out[8]],
144
+ ]
145
+
146
+ #Quat is expected in XYZW order
147
+ #https://www.euclideanspace.com/maths/geometry/rotations/conversions/quaternionToEuler/quat_2_euler_paper_ver2-1.pdf
148
+ def q2ea(in_quat: list[float], order: list[int]) -> list[float]:
149
+ X, Y, Z, W = 0, 1, 2, 3 #Index order helpers
150
+ if len(order) != 3: raise Exception()
151
+ if len(in_quat) != 4: raise Exception()
152
+ out = [0, 0, 0]
153
+
154
+ i1, i2, i3 = order
155
+ i1n = (i1+ 1) % 3
156
+ i1nn = (i1n + 1) % 3
157
+
158
+ #Find the direction the final axis ends up pointing after the full rotation
159
+ #Note that this vector does not change when the third rotation is being applied.
160
+ v3_rot = [0, 0, 0]
161
+ v3_rot[i3] = 1
162
+ v3_rot = quat_rotate_vec(in_quat, v3_rot)
163
+
164
+ #NOTE: Whenever using asin/acos, ensure the input is in range of -1 <= x <= 1
165
+ #All this math should result in that, but floating point sometimes causes values like 1.0000002 which can cause NANs
166
+ v3_rot = [max(-1, min(1, v)) for v in v3_rot]
167
+
168
+ #Can now discover the first 2 rotations
169
+ #This is because the first two rotations determine the direction of the final axis,
170
+ #and each rotation only affects 1 plane, therefore think of it like the first rotation
171
+ #positions the third axis underneath its final spot, and the second rotation swings it up to its final position.
172
+ #These can be calculated using trig and the known axis pattern.
173
+
174
+ #Non-Circular, Repeated Axes
175
+ #XZX, YXY, ZYZ
176
+ if (i1 == 0 and i2 == 2 and i3 == 0) or \
177
+ (i1 == 1 and i2 == 0 and i3 == 1) or \
178
+ (i1 == 2 and i2 == 1 and i3 == 2):
179
+ out[0] = math.atan2(v3_rot[i1nn], v3_rot[i1n])
180
+ out[1] = math.acos(v3_rot[i1])
181
+ #Non-Circular, Non-Repeated Axes
182
+ #XZY, YXZ, ZYX
183
+ elif (i1 == 0 and i2 == 2 and i3 == 1) or \
184
+ (i1 == 1 and i2 == 0 and i3 == 2) or \
185
+ (i1 == 2 and i2 == 1 and i3 == 0):
186
+ out[0] = math.atan2(v3_rot[i1nn], v3_rot[i1n])
187
+ out[1] = -math.asin(v3_rot[i1])
188
+ #Circular, Repeated Axes
189
+ #XYX, YZY, ZXZ
190
+ elif (i1 == 0 and i2 == 1 and i3 == 0) or \
191
+ (i1 == 1 and i2 == 2 and i3 == 1) or \
192
+ (i1 == 2 and i2 == 0 and i3 == 2):
193
+ out[0] = math.atan2(v3_rot[i1n], -v3_rot[i1nn])
194
+ out[1] = math.acos(v3_rot[i1])
195
+ #Circular, Non-Repeated Axes
196
+ #XYZ, YZX, ZXY
197
+ elif (i1 == 0 and i2 == 1 and i3 == 2) or \
198
+ (i1 == 1 and i2 == 2 and i3 == 0) or \
199
+ (i1 == 2 and i2 == 0 and i3 == 1):
200
+ out[0] = math.atan2(-v3_rot[i1n], v3_rot[i1nn])
201
+ out[1] = math.asin(v3_rot[i1]) #Note, the paper incorrectly says -asin
202
+ else:
203
+ raise ValueError("Invalid Order")
204
+
205
+ #Now compute the third angle
206
+
207
+ #Create a quaternion that applies the first two rotations
208
+ q1_rotation = [0, 0, 0, 0]
209
+ q1_rotation[W] = math.cos(out[0] / 2)
210
+ q1_rotation[i1] = math.sin(out[0] / 2)
211
+
212
+ q2_rotation = [0, 0, 0, 0]
213
+ q2_rotation[W] = math.cos(out[1] / 2)
214
+ q2_rotation[i2] = math.sin(out[1] / 2)
215
+
216
+ q12 = quat_mul(q1_rotation, q2_rotation)
217
+
218
+ #Now apply that quaternion and the original quaternion to the axis that will be rotated by the last axis
219
+ #(Rotating along Axis 3 does not change the position of Axis 3, this is done to obtain an axis that will be rotated
220
+ #so that the difference can be computed to decide how much rotation is needed for the last angle)
221
+ i3n = (i3 + 1) % 3
222
+ v3n = [0, 0, 0]
223
+ v3n[i3n] = 1
224
+
225
+ v3_q12 = v3n.copy()
226
+ v3_q = v3n.copy()
227
+ v3_q12 = quat_rotate_vec(q12, v3_q12)
228
+ v3_q = quat_rotate_vec(in_quat, v3_q)
229
+
230
+ #Now do trig to figure out how much rotation, and what direction, is needed to rotate v3_q12 to v3_q
231
+ d = _vec.vec_dot(v3_q12, v3_q) #Get angle between the two, ensure in range of acos
232
+ d = max(-1, min(1, d))
233
+ magnitude = math.acos(d)
234
+
235
+ #Determine the sign of the rotation
236
+ cross = _vec.vec_cross(v3_q12, v3_q)
237
+ sign = _vec.vec_dot(cross, v3_rot)
238
+ if sign < 0: sign = -1
239
+ else: sign = 1
240
+
241
+ out[2] = sign * abs(magnitude)
242
+
243
+ return out
244
+
245
+ def string_order_to_indices(order: str):
246
+ order = order.lower()
247
+ int_order = []
248
+ if len(order) != 3: raise ValueError()
249
+ for c in order:
250
+ if c == 'x': int_order.append(0)
251
+ elif c == 'y': int_order.append(1)
252
+ elif c == 'z': int_order.append(2)
253
+ else: raise ValueError()
254
+
255
+ return int_order
256
+
257
+ def quat_to_euler_angles(in_quat: list[float], order: str|list[int], extrinsic=False):
258
+ if isinstance(order, str):
259
+ if 'e' in order.lower():
260
+ extrinsic = True
261
+ elif 'i' in order.lower():
262
+ extrinsic = False
263
+ #Only care about the first 3 chars, the 4th optional char is extrinsic vs intrinsic
264
+ order = string_order_to_indices(order[:3])
265
+
266
+ #Extrinsic is performing the rotations around the axes of the original fixed system (Rotates around world axes)
267
+ #Intrinsic is performing the rotations around the axes of a coordinate system that rotates with each step (It rotates around the local axes of the rotating object).
268
+ #These two representations are very similar, and even related. An extrinsic rotation is the same as an intrinsic rotation by the same angles, but with the order inverted.
269
+ if extrinsic:
270
+ #Taking advantage of the above info that intrinsic = extrinsic but in reverse and vice versa.
271
+ order[0], order[2] = order[2], order[0]
272
+
273
+ #Actual conversion, everything else is just setup
274
+ angles = q2ea(in_quat, order)
275
+
276
+ if extrinsic:
277
+ #Swap back to the original desired order
278
+ angles[0], angles[2] = angles[2], angles[0]
279
+
280
+ return angles
281
+
282
+ def quat_from_euler_angles(angles: list[float], order: list[int], degrees=False, extrinsic=False):
283
+ if isinstance(order, str):
284
+ order = _vec.parse_axis_string(order)[0]
285
+ quat = [0, 0, 0, 1]
286
+ for i in range(len(angles)):
287
+ axis = order[i]
288
+ angle = angles[i]
289
+ if degrees:
290
+ angle = math.radians(angle)
291
+
292
+ #Create unit vector for this
293
+ unit_vec = [0, 0, 0]
294
+ unit_vec[axis] = 1
295
+
296
+ #Create quaternion for this rotation and apply to overall rotation
297
+ angle_quat = quat_from_axis_angle(unit_vec, angle)
298
+ if extrinsic:
299
+ quat = quat_mul(angle_quat, quat)
300
+ else:
301
+ quat = quat_mul(quat, angle_quat)
302
+ return quat
303
+
304
+ def quaternion_global_to_local(quat, vec):
305
+ inverse = quat_inverse(quat)
306
+ return quat_rotate_vec(inverse, vec)
307
+
308
+ def quaternion_local_to_global(quat, vec):
309
+ return quat_rotate_vec(quat, vec)
310
+
311
+ def quaternion_swap_axes(quat: list, old_order: str, new_order: str):
312
+ return quaternion_swap_axes_fast(quat, _vec.parse_axis_string_info(old_order), _vec.parse_axis_string_info(new_order))
313
+
314
+ def quaternion_swap_axes_fast(quat: list, old_parsed_order: list[list, list, bool], new_parsed_order: list[list, list, bool]):
315
+ """
316
+ Like quaternion_swap_axes but uses the inputs of parsing the axis strings to avoid having to recompute
317
+ the storage types.
318
+
319
+ each order should be a sequence of [order, mults, right_handed]
320
+ """
321
+ old_order, old_mults, old_right_handed = old_parsed_order
322
+ new_order, new_mults, new_right_handed = new_parsed_order
323
+
324
+ #Undo the old negations
325
+ base_quat = quat.copy()
326
+ for i, mult in enumerate(old_mults):
327
+ base_quat[i] *= mult
328
+
329
+ #Now swap the positions and apply new multipliers
330
+ new_quat = base_quat.copy()
331
+ for i in range(3):
332
+ new_quat[i] = base_quat[old_order.index(new_order[i])]
333
+ new_quat[i] *= new_mults[i]
334
+
335
+ if old_right_handed != new_right_handed:
336
+ #Different handed systems rotate opposite directions. So to maintain the same rotation,
337
+ #invert the quaternion
338
+ new_quat = quat_inverse(new_quat)
339
+
340
+ return new_quat
341
+
342
+ #https://splines.readthedocs.io/en/latest/rotation/slerp.html
343
+ def slerp(a, b, t):
344
+ dot = _vec.vec_dot(a, b)
345
+ if dot < 0: #To force it to be the shortest route
346
+ b = [-v for v in b]
347
+
348
+ theta = math.acos(dot)
349
+ sin_theta = math.sin(theta)
350
+ r1 = math.sin(1 - t) * theta / sin_theta
351
+ r2 = math.sin(t * theta) / sin_theta
352
+ a = [r1 * v for v in a]
353
+ b = [r2 * v for v in b]
354
+ return _vec.vec_normalize([v + w for v, w in zip(a, b)])
@@ -33,7 +33,8 @@ def vec_is_right_handed(order: str, negations: list[bool] = None):
33
33
  return right_handed
34
34
 
35
35
  def axis_to_unit_vector(axis: str):
36
- axis = axis.lower()
36
+ if isinstance(axis, str):
37
+ axis = axis.lower()
37
38
  if axis == 'x' or axis == 0: return [1, 0, 0]
38
39
  if axis == 'y' or axis == 1: return [0, 1, 0]
39
40
  if axis == 'z' or axis == 2: return [0, 0, 1]
@@ -45,7 +46,7 @@ def parse_axis_string(axis: str):
45
46
  axis = axis.lower()
46
47
  order = [0, 1, 2]
47
48
  multipliers = [1, 1, 1]
48
- if 'x' in axis: #Using XYZ notation
49
+ if any(c in axis for c in ['x', 'y', 'z']): #Using XYZ notation
49
50
  index = 0
50
51
  for c in axis:
51
52
  if c == '-':
@@ -63,6 +63,9 @@ class ThreespaceCommand:
63
63
  BINARY_START_BYTE = 0xf7
64
64
  BINARY_START_BYTE_HEADER = 0xf9
65
65
 
66
+ BINARY_READ_SETTINGS_START_BYTE = 0xFA
67
+ BINARY_READ_SETTINGS_START_BYTE_HEADER = 0xFC
68
+
66
69
  def __init__(self, name: str, num: int, in_format: str, out_format: str, custom_func: Callable = None):
67
70
  self.info = ThreespaceCommandInfo(name, num, in_format, out_format)
68
71
  self.in_format = _3space_format_to_external(self.info.in_format)
@@ -82,7 +85,7 @@ class ThreespaceCommand:
82
85
 
83
86
  def send_command(self, com: ThreespaceOutputStream, *args, header_enabled = False):
84
87
  cmd = self.format_cmd(*args, header_enabled=header_enabled)
85
- com.write(cmd)
88
+ com.write(cmd)
86
89
 
87
90
  #Read the command result from an already read buffer. This will modify the given buffer to remove
88
91
  #that data as well
@@ -415,6 +418,7 @@ class StreamableCommands(Enum):
415
418
  GetBatteryPercent = 202
416
419
  GetBatteryStatus = 203
417
420
 
421
+ GetGpsActiveState = 214
418
422
  GetGpsCoord = 215
419
423
  GetGpsAltitude = 216
420
424
  GetGpsFixState = 217
@@ -917,6 +921,40 @@ class ThreespaceSensor:
917
921
  return list(response_dict.values())[0]
918
922
  return response_dict
919
923
 
924
+ """
925
+ WIP. Currently does not work with string values or have full validation.
926
+ """
927
+ def get_setting_binary(self, key: str, format: str, use_threespace_format=True):
928
+ if use_threespace_format:
929
+ format = _3space_format_to_external(format)
930
+ checksum = sum(ord(v) for v in key) % 256
931
+ #Format = StartByte + Key + Null Terminator of key + Checksum
932
+ cmd = struct.pack(f"<B{len(key)}sBB", ThreespaceCommand.BINARY_READ_SETTINGS_START_BYTE_HEADER, key.encode(), 0, checksum)
933
+ self.com.write(cmd)
934
+ try:
935
+ min_response_len = key.index(';')
936
+ except ValueError:
937
+ min_response_len = len(key)
938
+ if min_response_len < len(THREESPACE_GET_SETTINGS_ERROR_RESPONSE):
939
+ min_response_len = len(THREESPACE_GET_SETTINGS_ERROR_RESPONSE)
940
+ min_response_len += (1 + THREESPACE_BINARY_SETTINGS_ID_SIZE) #Null terminator and header size
941
+ if self.__await_get_settings_binary(min_response_len) != THREESPACE_AWAIT_COMMAND_FOUND:
942
+ raise RuntimeError(f"Failed to get binary setting: {key}")
943
+ self.com.read(THREESPACE_BINARY_SETTINGS_ID_SIZE) #Read pass the Header ID
944
+
945
+ try:
946
+ echoed_key = self.com.read_until(b'\0')[:-1].decode()
947
+ if echoed_key != key.lower():
948
+ raise ValueError()
949
+ except:
950
+ raise ValueError()
951
+ result = struct.unpack(f"<{format}", self.com.read(struct.calcsize(format)))
952
+ self.com.read(2) #Remove the null terminator and the checksum
953
+ if len(result) == 1:
954
+ return result[0]
955
+ return result
956
+
957
+
920
958
  #-----------Base Settings Parsing----------------
921
959
 
922
960
  def __await_set_settings(self, timeout=2):
@@ -1004,6 +1042,31 @@ class ThreespaceSensor:
1004
1042
  self.misaligned = False
1005
1043
  return THREESPACE_AWAIT_COMMAND_FOUND
1006
1044
 
1045
+ """
1046
+ Incomplete, binary settings protocol is Work In Progress
1047
+ """
1048
+ def __await_get_settings_binary(self, min_resp_length: int, timeout=2, check_bootloader=False):
1049
+ start_time = time.time()
1050
+
1051
+ while True:
1052
+ remaining_time = timeout - (time.time() - start_time)
1053
+ if remaining_time <= 0:
1054
+ return THREESPACE_AWAIT_COMMAND_TIMEOUT
1055
+
1056
+ if self.com.length < min_resp_length: continue
1057
+ if check_bootloader and self.com.peek(2) == b'OK':
1058
+ return THREESPACE_AWAIT_BOOTLOADER
1059
+
1060
+ id = self.com.peek(THREESPACE_BINARY_SETTINGS_ID_SIZE)
1061
+ if struct.unpack("<L", id)[0] != THREESPACE_BINARY_READ_SETTINGS_ID:
1062
+ self.__internal_update(self.__try_peek_header())
1063
+ continue
1064
+
1065
+ #TODO: Check the rest of the message structure, not just the ID
1066
+
1067
+ self.misaligned = False
1068
+ return THREESPACE_AWAIT_COMMAND_FOUND
1069
+
1007
1070
  #---------------------------------BASE COMMAND PARSING--------------------------------------
1008
1071
  def __try_peek_header(self):
1009
1072
  """
@@ -1651,6 +1714,7 @@ class ThreespaceSensor:
1651
1714
  def getBatteryVoltage(self) -> ThreespaceCmdResult[float]: ...
1652
1715
  def getBatteryPercent(self) -> ThreespaceCmdResult[int]: ...
1653
1716
  def getBatteryStatus(self) -> ThreespaceCmdResult[int]: ...
1717
+ def getGpsActiveState(self) -> ThreespaceCmdResult[bool]: ...
1654
1718
  def getGpsCoord(self) -> ThreespaceCmdResult[list[float]]: ...
1655
1719
  def getGpsAltitude(self) -> ThreespaceCmdResult[float]: ...
1656
1720
  def getGpsFixState(self) -> ThreespaceCmdResult[int]: ...
@@ -1810,6 +1874,7 @@ _threespace_commands: list[ThreespaceCommand] = [
1810
1874
  ThreespaceCommand("getBatteryPercent", 202, "", "b"),
1811
1875
  ThreespaceCommand("getBatteryStatus", 203, "", "b"),
1812
1876
 
1877
+ ThreespaceCommand("getGpsActiveState", 214, "", "b"),
1813
1878
  ThreespaceCommand("getGpsCoord", 215, "", "dd"),
1814
1879
  ThreespaceCommand("getGpsAltitude", 216, "", "f"),
1815
1880
  ThreespaceCommand("getGpsFixState", 217, "", "b"),
@@ -24,6 +24,8 @@ THREESPACE_OUTPUT_MODE_ASCII = 1
24
24
  THREESPACE_OUTPUT_MODE_BINARY = 2
25
25
 
26
26
  THREESPACE_GET_SETTINGS_ERROR_RESPONSE = "<KEY_ERROR>"
27
+ THREESPACE_BINARY_SETTINGS_ID_SIZE = 4
28
+ THREESPACE_BINARY_READ_SETTINGS_ID = 0xC695B5E1
27
29
 
28
30
  #This is not comprehensive, just enough to seperate keys from debug messages
29
31
  THREESPACE_SETTING_KEY_INVALID_CHARS = " :;"
@@ -1,26 +0,0 @@
1
- from yostlabs.tss3.api import ThreespaceSensor
2
- from yostlabs.communication.serial import ThreespaceSerialComClass
3
-
4
- #Create a sensor by auto detecting a ThreespaceSerialComClass
5
- sensor = ThreespaceSensor(ThreespaceSerialComClass)
6
-
7
- result = sensor.getPrimaryCorrectedAccelVec()
8
-
9
- #Read out the result
10
- accel_vec = result.data
11
-
12
- #The result is a dataclass that allows easily accessing individual parts of the data and multiple ways of interpreting it
13
- print("Result:")
14
- print(result) #The base ThreespaceCmdResult dataclass
15
- print(result.raw_binary) #The actual byte response from the sensor that made this result (header + data)
16
- print()
17
- print("Header:")
18
- print(result.header) #The ThreespaceHeader data class
19
- print(result.header.raw) #The individual components of the header as an array, similar to how the old API functioned
20
- print(result.header.raw_binary) #The actual byte representation of the header from the sensor
21
- print()
22
- print("Data:")
23
- print(f"{result.data=}")
24
- print(f"{result.raw_data=}")
25
-
26
- sensor.cleanup()
@@ -1,187 +0,0 @@
1
- import math
2
- import yostlabs.math.vector as _vec
3
-
4
- def quat_mul(a: list[float], b: list[float]):
5
- out = [0, 0, 0, 0]
6
- x = 0; y = 1; z = 2; w = 3
7
- out[w] = a[w]*b[w] - a[x]*b[x] - a[y]*b[y] - a[z]*b[z]
8
- out[x] = a[w]*b[x] + a[x]*b[w] + a[y]*b[z] - a[z]*b[y]
9
- out[y] = a[w]*b[y] + a[y]*b[w] + a[z]*b[x] - a[x]*b[z]
10
- out[z] = a[w]*b[z] + a[z]*b[w] + a[x]*b[y] - a[y]*b[x]
11
- return out
12
-
13
- #Rotates quaternion b by quaternion a, it does not combine them
14
- def quat_rotate(a: list[float], b: list[float]):
15
- inv = quat_inverse(a)
16
- axis = [b[0], b[1], b[2], 0]
17
- halfway = quat_mul(a, axis)
18
- final = quat_mul(halfway, inv)
19
- return [*final[:3], b[3]]
20
-
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
24
- return [-quat[0], -quat[1], -quat[2], quat[3]]
25
-
26
- def quat_rotate_vec(quat: list[float], vec: list[float]):
27
- inv = quat_inverse(quat)
28
- tmp = [vec[0], vec[1], vec[2], 0]
29
- halfway = quat_mul(quat, tmp)
30
- final = quat_mul(halfway, inv)
31
- return [final[0], final[1], final[2]]
32
-
33
- def angles_to_quaternion(angles: list[float], order: str, degrees=True):
34
- quat = [0, 0, 0, 1]
35
- for i in range(len(angles)):
36
- axis = order[i]
37
- angle = angles[i]
38
- if degrees:
39
- angle = math.radians(angle)
40
- unit_vec = _vec.axis_to_unit_vector(axis)
41
- w = math.cos(angle / 2)
42
- imaginary = math.sin(angle / 2)
43
- unit_vec = [v * imaginary for v in unit_vec]
44
- angle_quat = [*unit_vec, w]
45
- quat = quat_mul(quat, angle_quat)
46
- return quat
47
-
48
- def quat_from_axis_angle(axis: list[float], angle: float):
49
- imaginary = math.sin(angle / 2)
50
- quat = [imaginary * v for v in axis]
51
- quat.append(math.cos(angle / 2))
52
- return quat
53
-
54
- #There are multiple valid quats that can be returned by this. The intention of this function
55
- #is to be able to rotate an arrow by the quat such that it points the correct direction. The rotation
56
- #of that arrow along its axis may differ though
57
- def quat_from_one_vector(vec: list[float]):
58
- vec = _vec.vec_normalize(vec)
59
- perpendicular = _vec.vec_normalize(_vec.vec_cross([0, 0, 1], vec))
60
- angle = math.acos(_vec.vec_dot([0, 0, 1], vec))
61
- return quat_from_axis_angle(perpendicular, angle)
62
-
63
- def quat_from_two_vectors(forward: list[float], down: list[float]):
64
- """
65
- This function requires two orthogonal vectors to work
66
- """
67
- forward_reference = [0, 0, 1]
68
- down_reference = [0, -1, 0]
69
-
70
- forward = _vec.vec_normalize(forward)
71
- down = _vec.vec_normalize(down)
72
-
73
- #Create the first rotation to align the forward axis
74
- axis_of_rotation = _vec.vec_cross(forward_reference, forward)
75
- axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
76
- if not any(abs(v) > 0 for v in axis_of_rotation):
77
- axis_of_rotation = down_reference #This is just a direct 180 degree rotation around any orthogonal axis, so just use the down_ref
78
- dot = min(1, max(-1, _vec.vec_dot(forward_reference, forward)))
79
- angle1 = math.acos(dot)
80
- imaginary = math.sin(angle1/2)
81
- quat = [v * imaginary for v in axis_of_rotation] #XYZ
82
- quat.append(math.cos(angle1/2)) #W
83
-
84
- #Update the reference to figure out where it is after the turn
85
- down_reference = quat_rotate_vec(quat, down_reference)
86
-
87
- #find the rotation to make the remaining reference align with its given value
88
- axis_of_rotation = _vec.vec_cross(down_reference, down)
89
- axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
90
- if not any(abs(v) > 0 for v in axis_of_rotation):
91
- axis_of_rotation = forward #Rotate along the final forward vector until up is aligned
92
- dot = min(1, max(-1, _vec.vec_dot(down_reference, down)))
93
- angle2 = math.acos(dot)
94
- imaginary = math.sin(angle2/2)
95
- rotation = [v * imaginary for v in axis_of_rotation] #XYZ
96
- rotation.append(math.cos(angle2/2)) #W
97
-
98
- quat = quat_mul(rotation, quat)
99
- return quat
100
-
101
- def quaternion_to_3x3_rotation_matrix(quat):
102
- """
103
- Convert a quaternion in form x, y, z, w to a rotation matrix
104
- """
105
- x, y, z, w = quat
106
-
107
- fTx = 2.0 * x
108
- fTy = 2.0 * y
109
- fTz = 2.0 * z
110
- fTwx = fTx * w
111
- fTwy = fTy * w
112
- fTwz = fTz * w
113
- fTxx = fTx * x
114
- fTxy = fTy * x
115
- fTxz = fTz * x
116
- fTyy = fTy * y
117
- fTyz = fTz * y
118
- fTzz = fTz * z
119
-
120
- out = [0] * 9
121
- out[0] = 1.0-(fTyy+fTzz)
122
- out[1] = fTxy-fTwz
123
- out[2] = fTxz+fTwy
124
- out[3] = fTxy+fTwz
125
- out[4] = 1.0-(fTxx+fTzz)
126
- out[5] = fTyz-fTwx
127
- out[6] = fTxz-fTwy
128
- out[7] = fTyz + fTwx
129
- out[8] = 1.0 - (fTxx+fTyy)
130
-
131
- return [
132
- [out[0], out[1], out[2]],
133
- [out[3], out[4], out[5]],
134
- [out[6], out[7], out[8]],
135
- ]
136
-
137
- def quaternion_global_to_local(quat, vec):
138
- inverse = quat_inverse(quat)
139
- return quat_rotate_vec(inverse, vec)
140
-
141
- def quaternion_local_to_global(quat, vec):
142
- return quat_rotate_vec(quat, vec)
143
-
144
- def quaternion_swap_axes(quat: list, old_order: str, new_order: str):
145
- return quaternion_swap_axes_fast(quat, _vec.parse_axis_string_info(old_order), _vec.parse_axis_string_info(new_order))
146
-
147
- def quaternion_swap_axes_fast(quat: list, old_parsed_order: list[list, list, bool], new_parsed_order: list[list, list, bool]):
148
- """
149
- Like quaternion_swap_axes but uses the inputs of parsing the axis strings to avoid having to recompute
150
- the storage types.
151
-
152
- each order should be a sequence of [order, mults, right_handed]
153
- """
154
- old_order, old_mults, old_right_handed = old_parsed_order
155
- new_order, new_mults, new_right_handed = new_parsed_order
156
-
157
- #Undo the old negations
158
- base_quat = quat.copy()
159
- for i, mult in enumerate(old_mults):
160
- base_quat[i] *= mult
161
-
162
- #Now swap the positions and apply new multipliers
163
- new_quat = base_quat.copy()
164
- for i in range(3):
165
- new_quat[i] = base_quat[old_order.index(new_order[i])]
166
- new_quat[i] *= new_mults[i]
167
-
168
- if old_right_handed != new_right_handed:
169
- #Different handed systems rotate opposite directions. So to maintain the same rotation,
170
- #invert the quaternion
171
- new_quat = quat_inverse(new_quat)
172
-
173
- return new_quat
174
-
175
- #https://splines.readthedocs.io/en/latest/rotation/slerp.html
176
- def slerp(a, b, t):
177
- dot = _vec.vec_dot(a, b)
178
- if dot < 0: #To force it to be the shortest route
179
- b = [-v for v in b]
180
-
181
- theta = math.acos(dot)
182
- sin_theta = math.sin(theta)
183
- r1 = math.sin(1 - t) * theta / sin_theta
184
- r2 = math.sin(t * theta) / sin_theta
185
- a = [r1 * v for v in a]
186
- b = [r2 * v for v in b]
187
- return _vec.vec_normalize([v + w for v, w in zip(a, b)])
File without changes
File without changes
File without changes