yostlabs 2025.1.3.4__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 +0 -0
- yostlabs/tss3/__init__.py +0 -0
- yostlabs/tss3/api.py +1926 -0
- yostlabs/tss3/consts.py +24 -0
- yostlabs/tss3/eepts.py +228 -0
- yostlabs/tss3/parser.py +258 -0
- yostlabs/tss3/quaternion.py +166 -0
- yostlabs/tss3/utils.py +671 -0
- yostlabs-2025.1.3.4.dist-info/METADATA +18 -0
- yostlabs-2025.1.3.4.dist-info/RECORD +12 -0
- yostlabs-2025.1.3.4.dist-info/WHEEL +4 -0
- yostlabs-2025.1.3.4.dist-info/licenses/LICENSE +21 -0
yostlabs/tss3/consts.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
THREESPACE_HEADER_STATUS_BIT_POS = 0
|
|
2
|
+
THREESPACE_HEADER_TIMESTAMP_BIT_POS = 1
|
|
3
|
+
THREESPACE_HEADER_ECHO_BIT_POS = 2
|
|
4
|
+
THREESPACE_HEADER_CHECKSUM_BIT_POS = 3
|
|
5
|
+
THREESPACE_HEADER_SERIAL_BIT_POS = 4
|
|
6
|
+
THREESPACE_HEADER_LENGTH_BIT_POS = 5
|
|
7
|
+
THREESPACE_HEADER_NUM_BITS = 6
|
|
8
|
+
|
|
9
|
+
THREESPACE_HEADER_STATUS_BIT = (1 << THREESPACE_HEADER_STATUS_BIT_POS)
|
|
10
|
+
THREESPACE_HEADER_TIMESTAMP_BIT = (1 << THREESPACE_HEADER_TIMESTAMP_BIT_POS)
|
|
11
|
+
THREESPACE_HEADER_ECHO_BIT = (1 << THREESPACE_HEADER_ECHO_BIT_POS)
|
|
12
|
+
THREESPACE_HEADER_CHECKSUM_BIT = (1 << THREESPACE_HEADER_CHECKSUM_BIT_POS)
|
|
13
|
+
THREESPACE_HEADER_SERIAL_BIT = (1 << THREESPACE_HEADER_SERIAL_BIT_POS)
|
|
14
|
+
THREESPACE_HEADER_LENGTH_BIT = (1 << THREESPACE_HEADER_LENGTH_BIT_POS)
|
|
15
|
+
|
|
16
|
+
FIRMWARE_VALID_BIT = 0x01
|
|
17
|
+
|
|
18
|
+
PASSIVE_CALIBRATE_GYRO = (1 << 0)
|
|
19
|
+
PASSIVE_CALIBRATE_MAG_REF = (1 << 1)
|
|
20
|
+
|
|
21
|
+
STREAMING_MAX_HZ = 2000
|
|
22
|
+
|
|
23
|
+
THREESPACE_OUTPUT_MODE_ASCII = 1
|
|
24
|
+
THREESPACE_OUTPUT_MODE_BINARY = 2
|
yostlabs/tss3/eepts.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, fields
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
YL_SENSOR_BIT_GYRO: int = 1
|
|
5
|
+
YL_SENSOR_BIT_ACCEL: int = 2
|
|
6
|
+
YL_SENSOR_BIT_MAG: int = 4
|
|
7
|
+
YL_SENSOR_BIT_BARO: int = 8
|
|
8
|
+
YL_SENSOR_BIT_TEMP: int = 16
|
|
9
|
+
YL_SENSOR_BIT_GPS: int = 32
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class YL_EEPTS_INPUT_DATA:
|
|
13
|
+
gyro_data: list[float] = None # XYZ in radians/sec. only needs to be float32
|
|
14
|
+
accel_data: list[float] = None # XYZ in G force units(9.8 m/s^2). only needs to be float32
|
|
15
|
+
mag_data: list[float] = None # XYZ in Gauss. only needs to be float32
|
|
16
|
+
orient_data: list[float] = None # XYZW Optional, may not be here if YL_BUILTIN_ORIENT is false
|
|
17
|
+
barometer_data: float = 0 # in hPa. only needs to be float32
|
|
18
|
+
temperature: float = 0 # in degrees C. only needs to be float32
|
|
19
|
+
gps_longitude: float = 0 # in decimal degrees (positive east, negative west). needs to be float64
|
|
20
|
+
gps_latitude: float = 0 # in decimal degrees (positive north, negative south). needs to be float64
|
|
21
|
+
gps_altitude: float = 0 # in meters. only needs to be float32
|
|
22
|
+
timestamp: int = 0 # in microseconds. uint32_t
|
|
23
|
+
|
|
24
|
+
#Locomotion Modes
|
|
25
|
+
YL_LOCOMOTION_IDLE: int = 0
|
|
26
|
+
YL_LOCOMOTION_WALKING: int = 1
|
|
27
|
+
YL_LOCOMOTION_JOGGING: int = 2
|
|
28
|
+
YL_LOCOMOTION_RUNNING: int = 3
|
|
29
|
+
YL_LOCOMOTION_CRAWLING: int = 4
|
|
30
|
+
YL_LOCOMOTION_OTHER: int = 6
|
|
31
|
+
YL_LOCOMOTION_UNKNOWN: int = 5
|
|
32
|
+
|
|
33
|
+
#Sensor Locations
|
|
34
|
+
YL_SENSOR_RSHOULDER: int = 5
|
|
35
|
+
YL_SENSOR_BACK: int = 3
|
|
36
|
+
YL_SENSOR_RHAND: int = 4
|
|
37
|
+
YL_SENSOR_WAIST: int = 2
|
|
38
|
+
YL_SENSOR_CHEST: int = 1
|
|
39
|
+
YL_SENSOR_UNKNOWN: int = 0
|
|
40
|
+
|
|
41
|
+
LocomotionModes = {
|
|
42
|
+
YL_LOCOMOTION_IDLE: "Idle",
|
|
43
|
+
YL_LOCOMOTION_WALKING: "Walking",
|
|
44
|
+
YL_LOCOMOTION_JOGGING: "Jogging",
|
|
45
|
+
YL_LOCOMOTION_RUNNING: "Running",
|
|
46
|
+
YL_LOCOMOTION_CRAWLING: "Crawling",
|
|
47
|
+
YL_LOCOMOTION_OTHER: "Other",
|
|
48
|
+
YL_LOCOMOTION_UNKNOWN: "Unknown"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
SensorLocations = {
|
|
52
|
+
YL_SENSOR_RSHOULDER: "RShoulder",
|
|
53
|
+
YL_SENSOR_BACK: "Back",
|
|
54
|
+
YL_SENSOR_RHAND: "RHand",
|
|
55
|
+
YL_SENSOR_WAIST: "Waist",
|
|
56
|
+
YL_SENSOR_CHEST: "Chest",
|
|
57
|
+
YL_SENSOR_UNKNOWN: "Unknown"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
PresetMotionWR = 0
|
|
61
|
+
PresetMotionWRC = 1
|
|
62
|
+
|
|
63
|
+
PresetHandDisabled = 0
|
|
64
|
+
PresetHandEnabled = 1
|
|
65
|
+
PresetHandOnly = 2
|
|
66
|
+
|
|
67
|
+
PresetHeadingDynamic = 0
|
|
68
|
+
PresetHeadingStatic = 1
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class YL_EEPTS_OUTPUT_DATA:
|
|
72
|
+
|
|
73
|
+
segment_count: int = 0 #number of segments (a segment refers to a single step action)
|
|
74
|
+
timestamp: int = 0 #in microseconds, set to end of last estimated segment
|
|
75
|
+
estimated_gps_longitude: float = 0 #DDMM.MMMM (positive east, negative west)
|
|
76
|
+
estimated_gps_latitude: float = 0 #DDMM.MMMM (positive north, negative south)
|
|
77
|
+
estimated_gps_altitude: float = 0 #in meters
|
|
78
|
+
estimated_heading_angle: float = 0 #in degrees (0 north, 90 east, 180 south, 270 west)
|
|
79
|
+
estimated_distance_travelled: float = 0 #in meters, since last reset (A reset is a start command)
|
|
80
|
+
estimated_distance_x: float = 0 #in meters since last update, along positive east/negative west (An update is a read)
|
|
81
|
+
estimated_distance_y: float = 0 #in meters, since last update, along positive north/negative south
|
|
82
|
+
estimated_distance_z: float = 0 #in meters, since last update, along positive up/negative down
|
|
83
|
+
estimated_locomotion_mode: int = 0 #0=idle, 1=walking, etc...
|
|
84
|
+
estimated_receiver_location: int = 0 #0=unknown, 1=chest, etc...
|
|
85
|
+
last_event_confidence: float = 1
|
|
86
|
+
overall_confidence: float = 1
|
|
87
|
+
|
|
88
|
+
LOCMOTION_DICT: ClassVar[dict[int, str]] = { YL_LOCOMOTION_IDLE : "Idle", YL_LOCOMOTION_WALKING : "Walking", YL_LOCOMOTION_JOGGING : "Jogging", YL_LOCOMOTION_RUNNING : "Running", YL_LOCOMOTION_CRAWLING: "Crawling", YL_LOCOMOTION_OTHER: "Other", YL_LOCOMOTION_UNKNOWN: "Unknown" }
|
|
89
|
+
LOCATION_DICT: ClassVar[dict[int, str]] = { YL_SENSOR_UNKNOWN : "Unknown", YL_SENSOR_CHEST : "Chest", YL_SENSOR_WAIST : "Waist", YL_SENSOR_BACK : "Back", YL_SENSOR_RHAND : "RHand", YL_SENSOR_RSHOULDER : "RShoulder" }
|
|
90
|
+
|
|
91
|
+
def clone(self, other: "YL_EEPTS_OUTPUT_DATA"):
|
|
92
|
+
self.segment_count = other.segment_count
|
|
93
|
+
self.timestamp = other.timestamp
|
|
94
|
+
self.estimated_gps_longitude = other.estimated_gps_longitude
|
|
95
|
+
self.estimated_gps_latitude = other.estimated_gps_latitude
|
|
96
|
+
self.estimated_gps_altitude = other.estimated_gps_altitude
|
|
97
|
+
self.estimated_heading_angle = other.estimated_heading_angle
|
|
98
|
+
self.estimated_distance_travelled = other.estimated_distance_travelled
|
|
99
|
+
self.estimated_distance_x = other.estimated_distance_x
|
|
100
|
+
self.estimated_distance_y = other.estimated_distance_y
|
|
101
|
+
self.estimated_distance_z = other.estimated_distance_z
|
|
102
|
+
self.estimated_locomotion_mode = other.estimated_locomotion_mode
|
|
103
|
+
self.estimated_receiver_location = other.estimated_receiver_location
|
|
104
|
+
self.last_event_confidence = other.last_event_confidence
|
|
105
|
+
self.overall_confidence = other.overall_confidence
|
|
106
|
+
|
|
107
|
+
def get_locomotion_string(self):
|
|
108
|
+
return YL_EEPTS_OUTPUT_DATA.LOCMOTION_DICT[self.estimated_locomotion_mode]
|
|
109
|
+
|
|
110
|
+
def get_location_string(self):
|
|
111
|
+
return YL_EEPTS_OUTPUT_DATA.LOCATION_DICT[self.estimated_receiver_location]
|
|
112
|
+
|
|
113
|
+
def __str__(self):
|
|
114
|
+
return f"{self.segment_count},{self.timestamp}," \
|
|
115
|
+
f"{self.estimated_gps_longitude},{self.estimated_gps_latitude}," \
|
|
116
|
+
f"{self.estimated_gps_altitude},{self.estimated_heading_angle}," \
|
|
117
|
+
f"{self.estimated_distance_travelled},{self.estimated_distance_x}," \
|
|
118
|
+
f"{self.estimated_distance_y},{self.estimated_distance_z}," \
|
|
119
|
+
f"{self.estimated_locomotion_mode},{self.estimated_receiver_location}"
|
|
120
|
+
|
|
121
|
+
def print_fancy(self):
|
|
122
|
+
print("Segment Count:", self.segment_count)
|
|
123
|
+
print("Timestamp:", self.timestamp)
|
|
124
|
+
print("Longitude:", self.estimated_gps_longitude)
|
|
125
|
+
print("Latitude:", self.estimated_gps_latitude)
|
|
126
|
+
print("Altitude:", self.estimated_gps_altitude)
|
|
127
|
+
print("Heading:", self.estimated_heading_angle)
|
|
128
|
+
print("Distance Travelled:", self.estimated_distance_travelled)
|
|
129
|
+
print("Delta_X:", self.estimated_distance_x)
|
|
130
|
+
print("Delta_Y:", self.estimated_distance_y)
|
|
131
|
+
print("Delta_Z:", self.estimated_distance_z)
|
|
132
|
+
print("Locomotion:", self.get_locomotion_string())
|
|
133
|
+
print("Sensor Location:", self.get_location_string())
|
|
134
|
+
print("Confidence:", self.last_event_confidence)
|
|
135
|
+
print("Overall Confidence:", self.overall_confidence)
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class SensorData:
|
|
139
|
+
accel_x : float
|
|
140
|
+
accel_y : float
|
|
141
|
+
accel_z : float
|
|
142
|
+
|
|
143
|
+
gyro_x : float
|
|
144
|
+
gyro_y : float
|
|
145
|
+
gyro_z : float
|
|
146
|
+
|
|
147
|
+
mag_x : float
|
|
148
|
+
mag_y : float
|
|
149
|
+
mag_z : float
|
|
150
|
+
|
|
151
|
+
accel : list[float] = field(init=False, default_factory=list)
|
|
152
|
+
gyro : list[float] = field(init=False, default_factory=list)
|
|
153
|
+
mag : list[float] = field(init=False, default_factory=list)
|
|
154
|
+
|
|
155
|
+
def __post_init__(self):
|
|
156
|
+
self.accel = [self.accel_x, self.accel_y, self.accel_z]
|
|
157
|
+
self.gyro = [self.gyro_x, self.gyro_y, self.gyro_z]
|
|
158
|
+
self.mag = [self.mag_x, self.mag_y, self.mag_z]
|
|
159
|
+
|
|
160
|
+
def __str__(self):
|
|
161
|
+
return f"{self.accel_x},{self.accel_y},{self.accel_z}," \
|
|
162
|
+
f"{self.gyro_x},{self.gyro_y}, {self.gyro_z}," \
|
|
163
|
+
f"{self.mag_x},{self.mag_y},{self.mag_z}"
|
|
164
|
+
|
|
165
|
+
def print_fancy(self):
|
|
166
|
+
print("Accel:", self.accel)
|
|
167
|
+
print("Gyro:", self.gyro)
|
|
168
|
+
print("Mag:", self.mag)
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class DebugMessage:
|
|
172
|
+
level: int = 0
|
|
173
|
+
module: int = 0
|
|
174
|
+
msg: str = ""
|
|
175
|
+
|
|
176
|
+
def get_display_str(self):
|
|
177
|
+
return f"Level: {self.level} Module: {self.module} {self.msg}"
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class Segment:
|
|
181
|
+
#Output Structure
|
|
182
|
+
segment_count: int = 0
|
|
183
|
+
timestamp: int = 0
|
|
184
|
+
estimated_gps_longitude: float = 0
|
|
185
|
+
estimated_gps_latitude: float = 0
|
|
186
|
+
estimated_gps_altitude: float = 0
|
|
187
|
+
estimated_heading_angle: float = 0
|
|
188
|
+
estimated_distance_travelled: float = 0
|
|
189
|
+
estimated_distance_x: float = 0
|
|
190
|
+
estimated_distance_y: float = 0
|
|
191
|
+
estimated_distance_z: float = 0
|
|
192
|
+
estimated_locomotion_mode: int = 0
|
|
193
|
+
estimated_receiver_location: int = 0
|
|
194
|
+
last_event_confidence: float = 0
|
|
195
|
+
overall_confidence: float = 0
|
|
196
|
+
|
|
197
|
+
#Segment Info Structure
|
|
198
|
+
start_global_index: int = 0
|
|
199
|
+
end_global_index: int = 0
|
|
200
|
+
f_start_index_offset: float = 0
|
|
201
|
+
f_end_index_offset: float = 0
|
|
202
|
+
len: int = 0
|
|
203
|
+
f_len: float = 0
|
|
204
|
+
dir: int = 0
|
|
205
|
+
|
|
206
|
+
#Debug Messages:
|
|
207
|
+
debug_msgs: list[DebugMessage] = field(default_factory=lambda: [])
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def from_only_output_obj(cls, output: YL_EEPTS_OUTPUT_DATA):
|
|
211
|
+
new_obj = cls()
|
|
212
|
+
output_fields = [f for f in dir(output) if not f.startswith('__') and not callable(getattr(output, f))]
|
|
213
|
+
for field in output_fields: #Can NOT just assign. That will cause unintentional problems if ever saving segment info
|
|
214
|
+
setattr(new_obj, field, getattr(output, field))
|
|
215
|
+
|
|
216
|
+
return new_obj
|
|
217
|
+
|
|
218
|
+
def __str__(self):
|
|
219
|
+
s = ""
|
|
220
|
+
for field in fields(self):
|
|
221
|
+
attr = getattr(self, field.name)
|
|
222
|
+
if field.name == "estimated_locomotion_mode":
|
|
223
|
+
s += f"{field.name}: {LocomotionModes[attr]}\n"
|
|
224
|
+
elif field.name == "estimated_receiver_location":
|
|
225
|
+
s += f"{field.name}: {SensorLocations[attr]}\n"
|
|
226
|
+
else:
|
|
227
|
+
s += f"{field.name}: {attr}\n"
|
|
228
|
+
return s
|
yostlabs/tss3/parser.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from yostlabs.tss3.api import *
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
class ThreespaceBufferInputStream(ThreespaceInputStream):
|
|
5
|
+
"""
|
|
6
|
+
Default Input Stream for the binary parser.
|
|
7
|
+
Ignores timeout since this is only used synchronously for now.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.buffer = bytearray()
|
|
12
|
+
|
|
13
|
+
"""Reads specified number of bytes."""
|
|
14
|
+
def read(self, num_bytes) -> bytes:
|
|
15
|
+
num_bytes = min(len(self.buffer), num_bytes)
|
|
16
|
+
result = self.buffer[:num_bytes]
|
|
17
|
+
del self.buffer[:num_bytes]
|
|
18
|
+
#self.buffer = self.buffer[num_bytes:]
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
def read_all(self):
|
|
22
|
+
return self.read(self.length)
|
|
23
|
+
|
|
24
|
+
def read_until(self, expected: bytes) -> bytes:
|
|
25
|
+
if expected not in self.buffer:
|
|
26
|
+
return self.read_all()
|
|
27
|
+
|
|
28
|
+
length = self.buffer.index(expected) + len(expected)
|
|
29
|
+
result = self.buffer[:length]
|
|
30
|
+
del self.buffer[:length]
|
|
31
|
+
#self.buffer = self.buffer[length:]
|
|
32
|
+
raise result
|
|
33
|
+
|
|
34
|
+
"""Allows reading without removing the data from the buffer"""
|
|
35
|
+
def peek(self, num_bytes) -> bytes:
|
|
36
|
+
num_bytes = min(len(self.buffer), num_bytes)
|
|
37
|
+
return self.buffer[:num_bytes]
|
|
38
|
+
|
|
39
|
+
def peek_until(self, expected: bytes, max_length=None) -> bytes:
|
|
40
|
+
if expected in self.buffer: #Read until the expected
|
|
41
|
+
length = self.buffer.index(expected) + len(expected)
|
|
42
|
+
if max_length is not None and length > max_length:
|
|
43
|
+
length = max_length
|
|
44
|
+
return self.buffer[:length]
|
|
45
|
+
|
|
46
|
+
#There is no expected, so read as far as possible
|
|
47
|
+
length = len(self.buffer)
|
|
48
|
+
if max_length is not None:
|
|
49
|
+
length = min(length, max_length)
|
|
50
|
+
return self.buffer[:length]
|
|
51
|
+
|
|
52
|
+
def readline(self) -> bytes:
|
|
53
|
+
return self.read_until(b"\n")
|
|
54
|
+
|
|
55
|
+
def peekline(self, max_length=None) -> bytes:
|
|
56
|
+
return self.peek_until(b"\n", max_length=max_length)
|
|
57
|
+
|
|
58
|
+
def insert(self, data: bytes):
|
|
59
|
+
self.buffer.extend(data)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def length(self) -> int:
|
|
63
|
+
return len(self.buffer)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def timeout(self) -> float:
|
|
67
|
+
raise NotImplementedError()
|
|
68
|
+
|
|
69
|
+
@timeout.setter
|
|
70
|
+
def timeout(self, timeout: float):
|
|
71
|
+
raise NotImplementedError()
|
|
72
|
+
|
|
73
|
+
class ThreespaceBinaryParser:
|
|
74
|
+
"""
|
|
75
|
+
A class that can be used to parse a stream of binary data
|
|
76
|
+
that could contain multiple different command responses and validates
|
|
77
|
+
the responses to handle misalignment/data corruption.
|
|
78
|
+
|
|
79
|
+
Requires all expected responses to have the same header enabled.
|
|
80
|
+
|
|
81
|
+
The header should contain the cmd_echo, checksum, and data_length fields
|
|
82
|
+
for full functionality. The lack of any of those fields could limit functionality
|
|
83
|
+
of the parser.
|
|
84
|
+
|
|
85
|
+
If cmd_echo is missing, only one command can be registered with the binary parser as it has no way
|
|
86
|
+
of knowing what of verifying what the current incoming response is.
|
|
87
|
+
|
|
88
|
+
If checksum is missing, data integrity can not be checked.
|
|
89
|
+
|
|
90
|
+
If data_length is missing, commands that do not return a static length may cause blocking operations
|
|
91
|
+
or errors while parsing. (These are planned to be fixed in a future version)
|
|
92
|
+
|
|
93
|
+
NOTE: For speed, a custom implementation will be better then this parser class. This parser handles allowing
|
|
94
|
+
multiple commands as well as data validation and misalignment correction. It also formats the data into the ThreespaceCmdResult
|
|
95
|
+
response type. The overhead added because of all these additional checks/calculations can add a significant amount of time
|
|
96
|
+
to processing binary data compared to just reading a known amount of data and instantly unpacking it to a tuple in the desired format.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
COMMAND_EXCEPTIONS = [84, 177] #getStreamingBatch and fileReadBytes need additional info and so need registered via the
|
|
100
|
+
|
|
101
|
+
def __init__(self, data_stream: ThreespaceInputStream = None, verbose=False):
|
|
102
|
+
"""
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
data_stream - (optional) The data stream to use with the Binary Parser. If not supplied, will default to a new ThreespaceBufferInputStream
|
|
106
|
+
"""
|
|
107
|
+
self.data_stream = data_stream
|
|
108
|
+
self.registered_commands: dict[int,ThreespaceCommand] = {}
|
|
109
|
+
|
|
110
|
+
if self.data_stream is None:
|
|
111
|
+
self.data_stream = ThreespaceBufferInputStream()
|
|
112
|
+
self.header_info = None
|
|
113
|
+
|
|
114
|
+
self.__parsing_header: ThreespaceHeader = None #Used to optimize preventing reading to much by cacheing the header seperately from the cmd data
|
|
115
|
+
self.__parsing_command: ThreespaceCommand = None
|
|
116
|
+
self.__parsing_msg_length: int = None #Used seperately from the __parsing_header so can handle msg lengths that are static without modifying the header
|
|
117
|
+
|
|
118
|
+
self.misaligned = False
|
|
119
|
+
self.verbose = verbose
|
|
120
|
+
|
|
121
|
+
def register_command(self, cmd: int|ThreespaceCommand, **kwargs):
|
|
122
|
+
"""
|
|
123
|
+
Registers the given cmd number/cmd with the binary parser.
|
|
124
|
+
|
|
125
|
+
Some commands may require additional information:
|
|
126
|
+
stream_slots - list[int] Required when registering command 84 (getStreamingBatch) a list of command numbers that are being streamed.
|
|
127
|
+
read_size - 'auto' or int Required when registering a command that requires a given length such as fileReadBytes. If 'auto' will use the header length to determine length.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(cmd, int):
|
|
130
|
+
cmd = threespaceCommandGet(cmd)
|
|
131
|
+
if cmd is None:
|
|
132
|
+
raise ValueError(f"Invalid Cmd {cmd}")
|
|
133
|
+
|
|
134
|
+
if cmd.info.num in self.registered_commands:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
#These command types are special and need additional info
|
|
138
|
+
if cmd.info.num == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
|
|
139
|
+
if "read_size" not in kwargs:
|
|
140
|
+
raise ValueError("Missing arguement 'read_size' when registering the fileReadBytes command with the binary parser")
|
|
141
|
+
raise NotImplementedError("The fileReadBytes command has yet to be implemented for the ThreespaceBinaryParser")
|
|
142
|
+
elif cmd.info.num == THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM:
|
|
143
|
+
if "stream_slots" not in kwargs:
|
|
144
|
+
raise ValueError("Missing arguement 'stream_slots' when registering the getStreamingBatch command with the binary parser")
|
|
145
|
+
cmd = ThreespaceGetStreamingBatchCommand(kwargs['stream_slots'])
|
|
146
|
+
|
|
147
|
+
self.registered_commands[cmd.info.num] = cmd
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def unregister_command(self, cmd: int|ThreespaceCommand):
|
|
151
|
+
if cmd.info.num not in self.registered_commands:
|
|
152
|
+
return False
|
|
153
|
+
del self.registered_commands[cmd.info.num]
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def set_header(self, header_info: ThreespaceHeaderInfo):
|
|
157
|
+
self.header_info = header_info
|
|
158
|
+
|
|
159
|
+
def insert_data(self, data: bytes):
|
|
160
|
+
"""
|
|
161
|
+
Add the given data to the default ThreespaceBufferInputStream.
|
|
162
|
+
This method will raise an exception if used on a different type of InputStream
|
|
163
|
+
"""
|
|
164
|
+
if not isinstance(self.data_stream, ThreespaceBufferInputStream):
|
|
165
|
+
raise Exception("Insert data with Binary Parser only valid when using the default data_stream")
|
|
166
|
+
self.data_stream.insert(data)
|
|
167
|
+
|
|
168
|
+
def parse_message(self) -> ThreespaceCmdResult:
|
|
169
|
+
if self.__parsing_header is None:
|
|
170
|
+
self.__parse_header()
|
|
171
|
+
|
|
172
|
+
if self.__parsing_command is None:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
return self.__parse_command()
|
|
176
|
+
|
|
177
|
+
def __parse_header(self):
|
|
178
|
+
if self.data_stream.length < self.header_info.size:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
header = self.data_stream.peek(self.header_info.size)
|
|
182
|
+
header = ThreespaceHeader.from_bytes(header, self.header_info)
|
|
183
|
+
|
|
184
|
+
cmd_found = False
|
|
185
|
+
if self.header_info.echo_enabled: #Search for the command to parse
|
|
186
|
+
for command in self.registered_commands.values():
|
|
187
|
+
if header.echo != command.info.num: continue
|
|
188
|
+
|
|
189
|
+
#Command matches! Attempt to parse
|
|
190
|
+
self.__parsing_command = command
|
|
191
|
+
self.__parsing_header = header
|
|
192
|
+
|
|
193
|
+
cmd_found = True
|
|
194
|
+
else: #Can only parse one command
|
|
195
|
+
if len(self.registered_commands) > 1:
|
|
196
|
+
raise Exception("Only one command type can be parsed when the 'cmd echo' is not enabled in the header")
|
|
197
|
+
self.__parsing_command = list(self.registered_commands.values())[0]
|
|
198
|
+
self.__parsing_header = header
|
|
199
|
+
cmd_found = True
|
|
200
|
+
|
|
201
|
+
if cmd_found:
|
|
202
|
+
if self.header_info.length_enabled:
|
|
203
|
+
self.__parsing_msg_length = self.__parsing_header.length
|
|
204
|
+
else:
|
|
205
|
+
self.__parsing_msg_length = self.__parsing_command.info.out_size
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
#This header is not related to any command, so it needs skipped
|
|
209
|
+
if self.verbose and not self.misaligned:
|
|
210
|
+
print("Unexpected header:", header)
|
|
211
|
+
self.misaligned = True
|
|
212
|
+
self.data_stream.read(1)
|
|
213
|
+
|
|
214
|
+
def __peek_checksum(self):
|
|
215
|
+
header_len = len(self.__parsing_header.raw_binary)
|
|
216
|
+
data = self.data_stream.peek(header_len + self.__parsing_msg_length)[header_len:]
|
|
217
|
+
checksum = sum(data) % 256
|
|
218
|
+
return checksum == self.__parsing_header.checksum
|
|
219
|
+
|
|
220
|
+
def __parse_command(self):
|
|
221
|
+
#Not enough data to parse yet
|
|
222
|
+
if self.data_stream.length < self.header_info.size + self.__parsing_msg_length:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
if self.header_info.checksum_enabled and not math.isnan(self.__parsing_msg_length): #Can validate checksum before parsing
|
|
226
|
+
print("Pre validating checksum")
|
|
227
|
+
if not self.__peek_checksum():
|
|
228
|
+
#Data corruption/Misalignment error
|
|
229
|
+
if self.verbose and not self.misaligned:
|
|
230
|
+
print("Checksum mismatch for command", self.__parsing_command.info.num)
|
|
231
|
+
self.misaligned = True
|
|
232
|
+
self.data_stream.read(1)
|
|
233
|
+
self.__parsing_command = None
|
|
234
|
+
self.__parsing_header = None
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
#Header and pre validation checksum checks out! Now just parse the actual command result and return it
|
|
238
|
+
header = self.__parsing_header
|
|
239
|
+
self.data_stream.read(len(header.raw_binary)) #Skip these bytes since they are already parsed
|
|
240
|
+
result, raw = self.__parsing_command.read_command(self.data_stream)
|
|
241
|
+
|
|
242
|
+
#Validate checksum if couldn't pre-validate due to unknown message length
|
|
243
|
+
if math.isnan(self.__parsing_msg_length) and self.header_info.checksum_enabled:
|
|
244
|
+
checksum = sum(raw) % 256
|
|
245
|
+
if checksum != header.checksum:
|
|
246
|
+
if self.verbose and not self.misaligned:
|
|
247
|
+
print("Checksum mismatch for command", self.__parsing_command.info.num)
|
|
248
|
+
self.misaligned = True
|
|
249
|
+
self.__parsing_command = None
|
|
250
|
+
self.__parsing_header = None
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
#Reset and return
|
|
254
|
+
self.__parsing_header = None
|
|
255
|
+
self.__parsing_command = None
|
|
256
|
+
self.misaligned = False
|
|
257
|
+
return ThreespaceCmdResult(result, header, data_raw_binary=raw)
|
|
258
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
def vec_len(vector: list[float|int]):
|
|
4
|
+
return sum(v ** 2 for v in vector) ** 0.5
|
|
5
|
+
|
|
6
|
+
def vec_dot(a: list[float], b: list[float]):
|
|
7
|
+
return sum(a[i] * b[i] for i in range(len(a)))
|
|
8
|
+
|
|
9
|
+
def vec_cross(a: list[float], b: list[float]):
|
|
10
|
+
cross = [0, 0, 0]
|
|
11
|
+
cross[0] = a[1] * b[2] - a[2] * b[1]
|
|
12
|
+
cross[1] = a[2] * b[0] - a[0] * b[2]
|
|
13
|
+
cross[2] = a[0] * b[1] - a[1] * b[0]
|
|
14
|
+
return cross
|
|
15
|
+
|
|
16
|
+
def vec_normalize(vec: list[float]):
|
|
17
|
+
l = vec_len(vec)
|
|
18
|
+
if l == 0:
|
|
19
|
+
return vec
|
|
20
|
+
return [v / l for v in vec]
|
|
21
|
+
|
|
22
|
+
def quat_mul(a: list[float], b: list[float]):
|
|
23
|
+
out = [0, 0, 0, 0]
|
|
24
|
+
x = 0; y = 1; z = 2; w = 3
|
|
25
|
+
out[w] = a[w]*b[w] - a[x]*b[x] - a[y]*b[y] - a[z]*b[z]
|
|
26
|
+
out[x] = a[w]*b[x] + a[x]*b[w] + a[y]*b[z] - a[z]*b[y]
|
|
27
|
+
out[y] = a[w]*b[y] + a[y]*b[w] + a[z]*b[x] - a[x]*b[z]
|
|
28
|
+
out[z] = a[w]*b[z] + a[z]*b[w] + a[x]*b[y] - a[y]*b[x]
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
#Rotates quaternion b by quaternion a, it does not combine them
|
|
32
|
+
def quat_rotate(a: list[float], b: list[float]):
|
|
33
|
+
inv = a.copy()
|
|
34
|
+
inv[0] *= -1
|
|
35
|
+
inv[1] *= -1
|
|
36
|
+
inv[2] *= -1
|
|
37
|
+
axis = [b[0], b[1], b[2], 0]
|
|
38
|
+
halfway = quat_mul(a, axis)
|
|
39
|
+
final = quat_mul(halfway, inv)
|
|
40
|
+
return [*final[:3], b[3]]
|
|
41
|
+
|
|
42
|
+
def quat_inverse(quat: list[float]):
|
|
43
|
+
return [-quat[0], -quat[1], -quat[2], quat[3]]
|
|
44
|
+
|
|
45
|
+
def quat_rotate_vec(quat: list[float], vec: list[float]):
|
|
46
|
+
inv = quat_inverse(quat)
|
|
47
|
+
tmp = [vec[0], vec[1], vec[2], 0]
|
|
48
|
+
halfway = quat_mul(quat, tmp)
|
|
49
|
+
final = quat_mul(halfway, inv)
|
|
50
|
+
return [final[0], final[1], final[2]]
|
|
51
|
+
|
|
52
|
+
def is_right_handed(order: str, negations: list[bool]):
|
|
53
|
+
right_handed = order.lower() in ("xzy", "yxz", "zyx")
|
|
54
|
+
for i in range(3):
|
|
55
|
+
if negations[i]:
|
|
56
|
+
right_handed = not right_handed
|
|
57
|
+
return right_handed
|
|
58
|
+
|
|
59
|
+
def axis_to_unit_vector(axis: str):
|
|
60
|
+
axis = axis.lower()
|
|
61
|
+
if axis == 'x' or axis == 0: return [1, 0, 0]
|
|
62
|
+
if axis == 'y' or axis == 1: return [0, 1, 0]
|
|
63
|
+
if axis == 'z' or axis == 2: return [0, 0, 1]
|
|
64
|
+
|
|
65
|
+
def angles_to_quaternion(angles: list[float], order: str, degrees=True):
|
|
66
|
+
quat = [0, 0, 0, 1]
|
|
67
|
+
for i in range(len(angles)):
|
|
68
|
+
axis = order[i]
|
|
69
|
+
angle = angles[i]
|
|
70
|
+
if degrees:
|
|
71
|
+
angle = math.radians(angle)
|
|
72
|
+
unit_vec = axis_to_unit_vector(axis)
|
|
73
|
+
w = math.cos(angle / 2)
|
|
74
|
+
imaginary = math.sin(angle / 2)
|
|
75
|
+
unit_vec = [v * imaginary for v in unit_vec]
|
|
76
|
+
angle_quat = [*unit_vec, w]
|
|
77
|
+
#print(f"{axis} {angle} {angle_quat}")
|
|
78
|
+
quat = quat_mul(quat, angle_quat)
|
|
79
|
+
return quat
|
|
80
|
+
|
|
81
|
+
def quat_from_axis_angle(axis: list[float], angle: float):
|
|
82
|
+
imaginary = math.sin(angle / 2)
|
|
83
|
+
quat = [imaginary * v for v in axis]
|
|
84
|
+
quat.append(math.cos(angle / 2))
|
|
85
|
+
return quat
|
|
86
|
+
|
|
87
|
+
def quat_from_two_vectors(forward: list[float], down: list[float]):
|
|
88
|
+
"""
|
|
89
|
+
This function requires two orthogonal vectors to work
|
|
90
|
+
"""
|
|
91
|
+
forward_reference = [0, 0, 1]
|
|
92
|
+
down_reference = [0, -1, 0]
|
|
93
|
+
|
|
94
|
+
forward = vec_normalize(forward)
|
|
95
|
+
down = vec_normalize(down)
|
|
96
|
+
|
|
97
|
+
#Create the first rotation to align the forward axis
|
|
98
|
+
axis_of_rotation = vec_cross(forward_reference, forward)
|
|
99
|
+
axis_of_rotation = vec_normalize(axis_of_rotation)
|
|
100
|
+
if not any(abs(v) > 0 for v in axis_of_rotation):
|
|
101
|
+
axis_of_rotation = down_reference #This is just a direct 180 degree rotation around any orthogonal axis, so just use the down_ref
|
|
102
|
+
dot = min(1, max(-1, vec_dot(forward_reference, forward)))
|
|
103
|
+
angle1 = math.acos(dot)
|
|
104
|
+
imaginary = math.sin(angle1/2)
|
|
105
|
+
quat = [v * imaginary for v in axis_of_rotation] #XYZ
|
|
106
|
+
quat.append(math.cos(angle1/2)) #W
|
|
107
|
+
|
|
108
|
+
#Update the reference to figure out where it is after the turn
|
|
109
|
+
down_reference = quat_rotate_vec(quat, down_reference)
|
|
110
|
+
|
|
111
|
+
#find the rotation to make the remaining reference align with its given value
|
|
112
|
+
axis_of_rotation = vec_cross(down_reference, down)
|
|
113
|
+
axis_of_rotation = vec_normalize(axis_of_rotation)
|
|
114
|
+
if not any(abs(v) > 0 for v in axis_of_rotation):
|
|
115
|
+
axis_of_rotation = forward #Rotate along the final forward vector until up is aligned
|
|
116
|
+
dot = min(1, max(-1, vec_dot(down_reference, down)))
|
|
117
|
+
angle2 = math.acos(dot)
|
|
118
|
+
imaginary = math.sin(angle2/2)
|
|
119
|
+
rotation = [v * imaginary for v in axis_of_rotation] #XYZ
|
|
120
|
+
rotation.append(math.cos(angle2/2)) #W
|
|
121
|
+
|
|
122
|
+
quat = quat_mul(rotation, quat)
|
|
123
|
+
return quat
|
|
124
|
+
|
|
125
|
+
def quaternion_to_3x3_rotation_matrix(quat):
|
|
126
|
+
"""
|
|
127
|
+
Convert a quaternion in form x, y, z, w to a rotation matrix
|
|
128
|
+
"""
|
|
129
|
+
x, y, z, w = quat
|
|
130
|
+
|
|
131
|
+
fTx = 2.0 * x
|
|
132
|
+
fTy = 2.0 * y
|
|
133
|
+
fTz = 2.0 * z
|
|
134
|
+
fTwx = fTx * w
|
|
135
|
+
fTwy = fTy * w
|
|
136
|
+
fTwz = fTz * w
|
|
137
|
+
fTxx = fTx * x
|
|
138
|
+
fTxy = fTy * x
|
|
139
|
+
fTxz = fTz * x
|
|
140
|
+
fTyy = fTy * y
|
|
141
|
+
fTyz = fTz * y
|
|
142
|
+
fTzz = fTz * z
|
|
143
|
+
|
|
144
|
+
out = [0] * 9
|
|
145
|
+
out[0] = 1.0-(fTyy+fTzz)
|
|
146
|
+
out[1] = fTxy-fTwz
|
|
147
|
+
out[2] = fTxz+fTwy
|
|
148
|
+
out[3] = fTxy+fTwz
|
|
149
|
+
out[4] = 1.0-(fTxx+fTzz)
|
|
150
|
+
out[5] = fTyz-fTwx
|
|
151
|
+
out[6] = fTxz-fTwy
|
|
152
|
+
out[7] = fTyz + fTwx
|
|
153
|
+
out[8] = 1.0 - (fTxx+fTyy)
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
[out[0], out[1], out[2]],
|
|
157
|
+
[out[3], out[4], out[5]],
|
|
158
|
+
[out[6], out[7], out[8]],
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
def quaternion_global_to_local(quat, vec):
|
|
162
|
+
inverse = quat_inverse(quat)
|
|
163
|
+
return quat_rotate_vec(inverse, vec)
|
|
164
|
+
|
|
165
|
+
def quaternion_local_to_global(quat, vec):
|
|
166
|
+
return quat_rotate_vec(quat, vec)
|