yostlabs 2025.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yostlabs/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,90 @@
1
+ from typing import Generator
2
+
3
+ class ThreespaceInputStream:
4
+
5
+ """
6
+ Reads specified number of bytes.
7
+ If that many bytes are not available after timeout, less data will be returned
8
+ """
9
+ def read(self, num_bytes: int) -> bytes:
10
+ raise NotImplementedError()
11
+
12
+ def read_all(self):
13
+ return self.read(self.length)
14
+
15
+ def read_until(self, expected: bytes) -> bytes:
16
+ raise NotImplementedError()
17
+
18
+ """Allows reading without removing the data from the buffer"""
19
+ def peek(self, num_bytes: int) -> bytes:
20
+ raise NotImplementedError()
21
+
22
+ def peek_until(self, expected: bytes, max_length: int = None) -> bytes:
23
+ raise NotImplementedError()
24
+
25
+ def readline(self) -> bytes:
26
+ return self.read_until(b"\n")
27
+
28
+ def peekline(self, max_length: int = None) -> bytes:
29
+ return self.peek_until(b"\n", max_length=max_length)
30
+
31
+ @property
32
+ def length(self) -> int:
33
+ raise NotImplementedError()
34
+
35
+ @property
36
+ def timeout(self) -> float:
37
+ raise NotImplementedError()
38
+
39
+ @timeout.setter
40
+ def timeout(self, timeout: float):
41
+ raise NotImplementedError()
42
+
43
+ class ThreespaceOutputStream:
44
+
45
+ """Write the given bytes"""
46
+ def write(self, bytes):
47
+ raise NotImplementedError()
48
+
49
+ class ThreespaceComClass(ThreespaceInputStream, ThreespaceOutputStream):
50
+ """
51
+ Base class for a com class to use with the sensor object.
52
+ Com classes should be initialized without connection and require
53
+ there open called before use
54
+ """
55
+
56
+ def close(self):
57
+ raise NotImplementedError()
58
+
59
+ def open(self) -> bool:
60
+ """
61
+ Should return True on success, False on failure
62
+ If already open, should stay open
63
+ """
64
+ raise NotImplementedError()
65
+
66
+ def check_open(self) -> bool:
67
+ """
68
+ Should return True if the port is currently open, False otherwise.
69
+ Must give the current state, not a cached state
70
+ """
71
+ raise NotImplementedError()
72
+
73
+ @staticmethod
74
+ def auto_detect() -> Generator["ThreespaceComClass", None, None]:
75
+ """
76
+ Returns a list of com classes of the same type called on nearby
77
+ """
78
+ raise NotImplementedError()
79
+
80
+ @property
81
+ def reenumerates(self) -> bool:
82
+ """
83
+ If the device Re-Enumerates when going from bootloader to firmware or vice versa, this must return True.
84
+ This indicates to the API that it must search for the new com class representing the object when switching between bootloader and firmware
85
+ """
86
+ raise NotImplementedError
87
+
88
+ @property
89
+ def name(self) -> str:
90
+ raise NotImplementedError()
@@ -0,0 +1,141 @@
1
+ from yostlabs.communication.base import *
2
+ import serial
3
+ import serial.tools.list_ports
4
+
5
+
6
+ class ThreespaceSerialComClass(ThreespaceComClass):
7
+
8
+ PID_V3_MASK = 0x3000
9
+ PID_BOOTLOADER = 0x1000
10
+
11
+ VID = 0x2476
12
+
13
+ DEFAULT_BAUDRATE = 115200
14
+ DEFAULT_TIMEOUT = 2
15
+
16
+ def __init__(self, ser: serial.Serial | str):
17
+ if isinstance(ser, serial.Serial):
18
+ self.ser = ser
19
+ elif isinstance(ser, str):
20
+ self.ser = serial.Serial(ser, baudrate=ThreespaceSerialComClass.DEFAULT_BAUDRATE, timeout=ThreespaceSerialComClass.DEFAULT_TIMEOUT)
21
+ else:
22
+ raise TypeError("Invalid type for creating a ThreespaceSerialComClass:", type(ser), ser)
23
+
24
+ self.ser = ser
25
+
26
+ self.peek_buffer = bytearray()
27
+ self.peek_length = 0
28
+
29
+ def write(self, bytes: bytes):
30
+ self.ser.write(bytes)
31
+
32
+ def read(self, num_bytes: int):
33
+ if self.peek_length >= num_bytes:
34
+ result = self.peek_buffer[:num_bytes]
35
+ self.peek_length -= num_bytes
36
+ del self.peek_buffer[:num_bytes]
37
+ else:
38
+ result = self.peek_buffer + self.ser.read(num_bytes - self.peek_length) #Must supply the amount desired to read instead of just buffering so the timeout works
39
+ self.peek_buffer.clear()
40
+ self.peek_length = 0
41
+ return result
42
+
43
+ def peek(self, num_bytes: int):
44
+ if self.peek_length >= num_bytes:
45
+ return self.peek_buffer[:num_bytes]
46
+ else:
47
+ self.peek_buffer += self.ser.read(num_bytes - self.peek_length) #Must supply the amount desired to read instead of just buffering so the timeout works
48
+ self.peek_length = len(self.peek_buffer) #The read may have timed out, so calculate new size
49
+ return self.peek_buffer.copy()
50
+
51
+ def read_until(self, expected: bytes) -> bytes:
52
+ if expected in self.peek_buffer:
53
+ length = self.peek_buffer.index(expected) + len(expected)
54
+ result = self.peek_buffer[:length]
55
+ self.peek_length -= length
56
+ del self.peek_buffer[:length]
57
+ return result
58
+ #Have to actually read from serial port until the data is available
59
+ result = self.peek_buffer + self.ser.read_until(expected)
60
+ self.peek_buffer.clear()
61
+ self.peek_length = 0
62
+ return result
63
+
64
+ def peek_until(self, expected: bytes, max_length: int = None) -> bytes:
65
+ if expected in self.peek_buffer:
66
+ length = self.peek_buffer.index(expected) + len(expected)
67
+ if max_length is not None and length > max_length:
68
+ length = max_length
69
+ result = self.peek_buffer[:length]
70
+ return result
71
+ if max_length is not None and self.peek_length >= max_length:
72
+ return self.peek_buffer[:max_length]
73
+
74
+ #Have to actually read from serial port until the data is available
75
+ if max_length is not None:
76
+ max_length = max(0, max_length - self.peek_length)
77
+ self.peek_buffer += self.ser.read_until(expected, size=max_length)
78
+ self.peek_length = len(self.peek_buffer)
79
+ return self.peek_buffer.copy()
80
+
81
+ def close(self):
82
+ self.ser.close()
83
+ self.peek_buffer.clear()
84
+ self.peek_length = 0
85
+
86
+ def open(self):
87
+ try:
88
+ self.ser.open()
89
+ except:
90
+ return False
91
+ return True
92
+
93
+ def check_open(self):
94
+ try:
95
+ self.ser.in_waiting
96
+ except:
97
+ return False
98
+ return True
99
+
100
+ @property
101
+ def length(self):
102
+ return self.peek_length + self.ser.in_waiting
103
+
104
+ @property
105
+ def timeout(self) -> float:
106
+ return self.ser.timeout
107
+
108
+ @timeout.setter
109
+ def timeout(self, timeout: float):
110
+ self.ser.timeout = timeout
111
+
112
+ @property
113
+ def reenumerates(self) -> bool:
114
+ return True
115
+
116
+ @property
117
+ def name(self) -> str:
118
+ return self.ser.port
119
+
120
+ #This is not part of the ThreespaceComClass interface, but is useful as a utility for those directly using the ThreespaceSerialComClass
121
+ @staticmethod
122
+ def enumerate_ports():
123
+ cls = ThreespaceSerialComClass
124
+ ports = serial.tools.list_ports.comports()
125
+ for port in ports:
126
+ if port.vid == cls.VID and (port.pid & cls.PID_V3_MASK == cls.PID_V3_MASK or port.pid == cls.PID_BOOTLOADER):
127
+ yield port
128
+
129
+ @staticmethod
130
+ def auto_detect(default_timeout: float = 2, default_baudrate: int = 115200) -> Generator["ThreespaceSerialComClass", None, None]:
131
+ """
132
+ Returns a list of com classes of the same type called on nearby.
133
+ These ports will start unopened. This allows the caller to get a list of ports without having to connect.
134
+ """
135
+ cls = ThreespaceSerialComClass
136
+ ports = serial.tools.list_ports.comports()
137
+ for port in ports:
138
+ if port.vid == cls.VID and (port.pid & cls.PID_V3_MASK == cls.PID_V3_MASK or port.pid == cls.PID_BOOTLOADER):
139
+ ser = serial.Serial(None, baudrate=default_baudrate, timeout=default_timeout) #By setting port as None, can create an object without immediately opening the port
140
+ ser.port = port.device #Now assign the port, allowing the serial object to exist without being opened yet
141
+ yield ThreespaceSerialComClass(ser)
@@ -0,0 +1 @@
1
+ __all__ = ["quaternion", "vector"]
@@ -0,0 +1,149 @@
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 = a.copy()
16
+ inv[0] *= -1
17
+ inv[1] *= -1
18
+ inv[2] *= -1
19
+ axis = [b[0], b[1], b[2], 0]
20
+ halfway = quat_mul(a, axis)
21
+ final = quat_mul(halfway, inv)
22
+ return [*final[:3], b[3]]
23
+
24
+ def quat_inverse(quat: list[float]):
25
+ return [-quat[0], -quat[1], -quat[2], quat[3]]
26
+
27
+ def quat_rotate_vec(quat: list[float], vec: list[float]):
28
+ inv = quat_inverse(quat)
29
+ tmp = [vec[0], vec[1], vec[2], 0]
30
+ halfway = quat_mul(quat, tmp)
31
+ final = quat_mul(halfway, inv)
32
+ return [final[0], final[1], final[2]]
33
+
34
+ def angles_to_quaternion(angles: list[float], order: str, degrees=True):
35
+ quat = [0, 0, 0, 1]
36
+ for i in range(len(angles)):
37
+ axis = order[i]
38
+ angle = angles[i]
39
+ if degrees:
40
+ angle = math.radians(angle)
41
+ unit_vec = _vec.axis_to_unit_vector(axis)
42
+ w = math.cos(angle / 2)
43
+ imaginary = math.sin(angle / 2)
44
+ unit_vec = [v * imaginary for v in unit_vec]
45
+ angle_quat = [*unit_vec, w]
46
+ #print(f"{axis} {angle} {angle_quat}")
47
+ quat = quat_mul(quat, angle_quat)
48
+ return quat
49
+
50
+ def quat_from_axis_angle(axis: list[float], angle: float):
51
+ imaginary = math.sin(angle / 2)
52
+ quat = [imaginary * v for v in axis]
53
+ quat.append(math.cos(angle / 2))
54
+ return quat
55
+
56
+ def quat_from_two_vectors(forward: list[float], down: list[float]):
57
+ """
58
+ This function requires two orthogonal vectors to work
59
+ """
60
+ forward_reference = [0, 0, 1]
61
+ down_reference = [0, -1, 0]
62
+
63
+ forward = _vec.vec_normalize(forward)
64
+ down = _vec.vec_normalize(down)
65
+
66
+ #Create the first rotation to align the forward axis
67
+ axis_of_rotation = _vec.vec_cross(forward_reference, forward)
68
+ axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
69
+ if not any(abs(v) > 0 for v in axis_of_rotation):
70
+ axis_of_rotation = down_reference #This is just a direct 180 degree rotation around any orthogonal axis, so just use the down_ref
71
+ dot = min(1, max(-1, _vec.vec_dot(forward_reference, forward)))
72
+ angle1 = math.acos(dot)
73
+ imaginary = math.sin(angle1/2)
74
+ quat = [v * imaginary for v in axis_of_rotation] #XYZ
75
+ quat.append(math.cos(angle1/2)) #W
76
+
77
+ #Update the reference to figure out where it is after the turn
78
+ down_reference = quat_rotate_vec(quat, down_reference)
79
+
80
+ #find the rotation to make the remaining reference align with its given value
81
+ axis_of_rotation = _vec.vec_cross(down_reference, down)
82
+ axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
83
+ if not any(abs(v) > 0 for v in axis_of_rotation):
84
+ axis_of_rotation = forward #Rotate along the final forward vector until up is aligned
85
+ dot = min(1, max(-1, _vec.vec_dot(down_reference, down)))
86
+ angle2 = math.acos(dot)
87
+ imaginary = math.sin(angle2/2)
88
+ rotation = [v * imaginary for v in axis_of_rotation] #XYZ
89
+ rotation.append(math.cos(angle2/2)) #W
90
+
91
+ quat = quat_mul(rotation, quat)
92
+ return quat
93
+
94
+ def quaternion_to_3x3_rotation_matrix(quat):
95
+ """
96
+ Convert a quaternion in form x, y, z, w to a rotation matrix
97
+ """
98
+ x, y, z, w = quat
99
+
100
+ fTx = 2.0 * x
101
+ fTy = 2.0 * y
102
+ fTz = 2.0 * z
103
+ fTwx = fTx * w
104
+ fTwy = fTy * w
105
+ fTwz = fTz * w
106
+ fTxx = fTx * x
107
+ fTxy = fTy * x
108
+ fTxz = fTz * x
109
+ fTyy = fTy * y
110
+ fTyz = fTz * y
111
+ fTzz = fTz * z
112
+
113
+ out = [0] * 9
114
+ out[0] = 1.0-(fTyy+fTzz)
115
+ out[1] = fTxy-fTwz
116
+ out[2] = fTxz+fTwy
117
+ out[3] = fTxy+fTwz
118
+ out[4] = 1.0-(fTxx+fTzz)
119
+ out[5] = fTyz-fTwx
120
+ out[6] = fTxz-fTwy
121
+ out[7] = fTyz + fTwx
122
+ out[8] = 1.0 - (fTxx+fTyy)
123
+
124
+ return [
125
+ [out[0], out[1], out[2]],
126
+ [out[3], out[4], out[5]],
127
+ [out[6], out[7], out[8]],
128
+ ]
129
+
130
+ def quaternion_global_to_local(quat, vec):
131
+ inverse = quat_inverse(quat)
132
+ return quat_rotate_vec(inverse, vec)
133
+
134
+ def quaternion_local_to_global(quat, vec):
135
+ return quat_rotate_vec(quat, vec)
136
+
137
+ #https://splines.readthedocs.io/en/latest/rotation/slerp.html
138
+ def slerp(a, b, t):
139
+ dot = _vec.vec_dot(a, b)
140
+ if dot < 0: #To force it to be the shortest route
141
+ b = [-v for v in b]
142
+
143
+ theta = math.acos(dot)
144
+ sin_theta = math.sin(theta)
145
+ r1 = math.sin(1 - t) * theta / sin_theta
146
+ r2 = math.sin(t * theta) / sin_theta
147
+ a = [r1 * v for v in a]
148
+ b = [r2 * v for v in b]
149
+ return _vec.vec_normalize([v + w for v, w in zip(a, b)])
@@ -0,0 +1,31 @@
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