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.
@@ -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
File without changes
@@ -0,0 +1,153 @@
1
+ import yostlabs.math.quaternion as quat
2
+ import yostlabs.math.vector as vec
3
+
4
+ import numpy as np
5
+ from dataclasses import dataclass
6
+ import copy
7
+
8
+ class ThreespaceGradientDescentCalibration:
9
+
10
+ @dataclass
11
+ class StageInfo:
12
+ start_vector: int
13
+ end_vector: int
14
+ stage: int
15
+ scale: float
16
+
17
+ count: int = 0
18
+
19
+ MAX_SCALE = 1000000000
20
+ MIN_SCALE = 1
21
+ STAGES = [
22
+ StageInfo(0, 6, 0, MAX_SCALE),
23
+ StageInfo(0, 12, 1, MAX_SCALE),
24
+ StageInfo(0, 24, 2, MAX_SCALE)
25
+ ]
26
+
27
+ #Note that each entry has a positive and negative vector included in this list
28
+ CHANGE_VECTORS = [
29
+ np.array([0,0,0,0,0,0,0,0,0,.0001,0,0], dtype=np.float64),
30
+ np.array([0,0,0,0,0,0,0,0,0,-.0001,0,0], dtype=np.float64),
31
+ np.array([0,0,0,0,0,0,0,0,0,0,.0001,0], dtype=np.float64),
32
+ np.array([0,0,0,0,0,0,0,0,0,0,-.0001,0], dtype=np.float64),
33
+ np.array([0,0,0,0,0,0,0,0,0,0,0,.0001], dtype=np.float64),
34
+ np.array([0,0,0,0,0,0,0,0,0,0,0,-.0001], dtype=np.float64), #First 6 only try to change the bias
35
+ np.array([.001,0,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
36
+ np.array([-.001,0,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
37
+ np.array([0,0,0,0,.001,0,0,0,0,0,0,0], dtype=np.float64),
38
+ np.array([0,0,0,0,-.001,0,0,0,0,0,0,0], dtype=np.float64),
39
+ np.array([0,0,0,0,0,0,0,0,.001,0,0,0], dtype=np.float64),
40
+ np.array([0,0,0,0,0,0,0,0,-.001,0,0,0], dtype=np.float64), #Next 6 only try to change the scale
41
+ np.array([0,.0001,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
42
+ np.array([0,-.0001,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
43
+ np.array([0,0,.0001,0,0,0,0,0,0,0,0,0], dtype=np.float64),
44
+ np.array([0,0,-.0001,0,0,0,0,0,0,0,0,0], dtype=np.float64),
45
+ np.array([0,0,0,.0001,0,0,0,0,0,0,0,0], dtype=np.float64),
46
+ np.array([0,0,0,-.0001,0,0,0,0,0,0,0,0], dtype=np.float64),
47
+ np.array([0,0,0,0,0,.0001,0,0,0,0,0,0], dtype=np.float64),
48
+ np.array([0,0,0,0,0,-.0001,0,0,0,0,0,0], dtype=np.float64),
49
+ np.array([0,0,0,0,0,0,.0001,0,0,0,0,0], dtype=np.float64),
50
+ np.array([0,0,0,0,0,0,-.0001,0,0,0,0,0], dtype=np.float64),
51
+ np.array([0,0,0,0,0,0,0,.0001,0,0,0,0], dtype=np.float64),
52
+ np.array([0,0,0,0,0,0,0,-.0001,0,0,0,0], dtype=np.float64), #Next 12 only try to change the shear
53
+ ]
54
+
55
+ def __init__(self, relative_sensor_orients: list[np.ndarray[float]], no_inverse=False):
56
+ """
57
+ Params
58
+ ------
59
+ relative_sensor_orients : The orientation of the sensor during which each sample is taken if it was tared as if pointing into the screen.
60
+ The inverse of these will be used to calculate where the axes should be located relative to the sensor
61
+ no_inverse : The relative_sensor_orients will be treated as the sample_rotations
62
+ """
63
+ if no_inverse:
64
+ self.rotation_quats = relative_sensor_orients
65
+ else:
66
+ self.rotation_quats = [np.array(quat.quat_inverse(orient)) for orient in relative_sensor_orients]
67
+
68
+ def apply_parameters(self, sample: np.ndarray[float], params: np.ndarray[float]):
69
+ bias = params[9:]
70
+ scale = params[:9]
71
+ scale = scale.reshape((3, 3))
72
+ return scale @ (sample + bias)
73
+
74
+ def rate_parameters(self, params: np.ndarray[float], samples: list[np.ndarray[float]], targets: list[np.ndarray[float]]):
75
+ total_error = 0
76
+ for i in range(len(samples)):
77
+ sample = samples[i]
78
+ target = targets[i]
79
+
80
+ sample = self.apply_parameters(sample, params)
81
+
82
+ error = target - sample
83
+ total_error += vec.vec_len(error)
84
+ return total_error
85
+
86
+ def generate_target_list(self, origin: np.ndarray):
87
+ targets = []
88
+ for orient in self.rotation_quats:
89
+ new_vec = np.array(quat.quat_rotate_vec(orient, origin), dtype=np.float64)
90
+ targets.append(new_vec)
91
+ return targets
92
+
93
+ def __get_stage(self, stage_number: int):
94
+ if stage_number >= len(self.STAGES):
95
+ return None
96
+ #Always get a shallow copy of the stage so can modify without removing the initial values
97
+ return copy.copy(self.STAGES[stage_number])
98
+
99
+ def calculate(self, samples: list[np.ndarray[float]], origin: np.ndarray[float], verbose=False, max_cycles_per_stage=1000):
100
+ targets = self.generate_target_list(origin)
101
+ initial_params = np.array([1,0,0,0,1,0,0,0,1,0,0,0], dtype=np.float64)
102
+ stage = self.__get_stage(0)
103
+
104
+ best_params = initial_params
105
+ best_rating = self.rate_parameters(best_params, samples, targets)
106
+ count = 0
107
+ while True:
108
+ last_best_rating = best_rating
109
+ params = best_params
110
+
111
+ #Apply all the changes to see if any improve the result
112
+ for change_index in range(stage.start_vector, stage.end_vector):
113
+ change_vector = self.CHANGE_VECTORS[change_index]
114
+ new_params = params + (change_vector * stage.scale)
115
+ rating = self.rate_parameters(new_params, samples, targets)
116
+
117
+ #A better rating, store it
118
+ if rating < best_rating:
119
+ best_params = new_params
120
+ best_rating = rating
121
+
122
+ if verbose and count % 100 == 0:
123
+ print(f"Round {count}: {best_rating=} {stage=}")
124
+
125
+ #Decide if need to go to the next stage or not
126
+ count += 1
127
+ stage.count += 1
128
+ if stage.count >= max_cycles_per_stage:
129
+ stage = self.__get_stage(stage.stage + 1)
130
+ if stage is None:
131
+ if verbose: print("Done from reaching count limit")
132
+ break
133
+ if verbose: print("Going to next stage from count limit")
134
+
135
+ if best_rating == last_best_rating: #The rating did not improve
136
+ if stage.scale == self.MIN_SCALE: #Go to the next stage since can't get any better in this stage!
137
+ stage = self.__get_stage(stage.stage + 1)
138
+ if stage is None:
139
+ if verbose: print("Done from exhaustion")
140
+ break
141
+ if verbose: print("Going to next stage from exhaustion")
142
+ else: #Reduce the size of the changes to hopefully get more accurate tuning
143
+ stage.scale *= 0.1
144
+ if stage.scale < self.MIN_SCALE:
145
+ stage.scale = self.MIN_SCALE
146
+ else: #Rating got better! To help avoid falling in a local minimum, increase the size of the change to see if that could make it better
147
+ stage.scale *= 1.1
148
+
149
+ if verbose:
150
+ print(f"Final Rating: {best_rating}")
151
+ print(f"Final Params: {best_params}")
152
+
153
+ return best_params
@@ -0,0 +1,256 @@
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
+ return result
19
+
20
+ def read_all(self):
21
+ return self.read(self.length)
22
+
23
+ def read_until(self, expected: bytes) -> bytes:
24
+ if expected not in self.buffer:
25
+ return self.read_all()
26
+
27
+ length = self.buffer.index(expected) + len(expected)
28
+ result = self.buffer[:length]
29
+ del self.buffer[:length]
30
+ raise result
31
+
32
+ """Allows reading without removing the data from the buffer"""
33
+ def peek(self, num_bytes) -> bytes:
34
+ num_bytes = min(len(self.buffer), num_bytes)
35
+ return self.buffer[:num_bytes]
36
+
37
+ def peek_until(self, expected: bytes, max_length=None) -> bytes:
38
+ if expected in self.buffer: #Read until the expected
39
+ length = self.buffer.index(expected) + len(expected)
40
+ if max_length is not None and length > max_length:
41
+ length = max_length
42
+ return self.buffer[:length]
43
+
44
+ #There is no expected, so read as far as possible
45
+ length = len(self.buffer)
46
+ if max_length is not None:
47
+ length = min(length, max_length)
48
+ return self.buffer[:length]
49
+
50
+ def readline(self) -> bytes:
51
+ return self.read_until(b"\n")
52
+
53
+ def peekline(self, max_length=None) -> bytes:
54
+ return self.peek_until(b"\n", max_length=max_length)
55
+
56
+ def insert(self, data: bytes):
57
+ self.buffer.extend(data)
58
+
59
+ @property
60
+ def length(self) -> int:
61
+ return len(self.buffer)
62
+
63
+ @property
64
+ def timeout(self) -> float:
65
+ raise NotImplementedError()
66
+
67
+ @timeout.setter
68
+ def timeout(self, timeout: float):
69
+ raise NotImplementedError()
70
+
71
+ class ThreespaceBinaryParser:
72
+ """
73
+ A class that can be used to parse a stream of binary data
74
+ that could contain multiple different command responses and validates
75
+ the responses to handle misalignment/data corruption.
76
+
77
+ Requires all expected responses to have the same header enabled.
78
+
79
+ The header should contain the cmd_echo, checksum, and data_length fields
80
+ for full functionality. The lack of any of those fields could limit functionality
81
+ of the parser.
82
+
83
+ If cmd_echo is missing, only one command can be registered with the binary parser as it has no way
84
+ of knowing what of verifying what the current incoming response is.
85
+
86
+ If checksum is missing, data integrity can not be checked.
87
+
88
+ If data_length is missing, commands that do not return a static length may cause blocking operations
89
+ or errors while parsing. (These are planned to be fixed in a future version)
90
+
91
+ NOTE: For speed, a custom implementation will be better then this parser class. This parser handles allowing
92
+ multiple commands as well as data validation and misalignment correction. It also formats the data into the ThreespaceCmdResult
93
+ response type. The overhead added because of all these additional checks/calculations can add a significant amount of time
94
+ to processing binary data compared to just reading a known amount of data and instantly unpacking it to a tuple in the desired format.
95
+ """
96
+
97
+ COMMAND_EXCEPTIONS = [84, 177] #getStreamingBatch and fileReadBytes need additional info and so need registered via the
98
+
99
+ def __init__(self, data_stream: ThreespaceInputStream = None, verbose=False):
100
+ """
101
+ Parameters
102
+ ----------
103
+ data_stream - (optional) The data stream to use with the Binary Parser. If not supplied, will default to a new ThreespaceBufferInputStream
104
+ """
105
+ self.data_stream = data_stream
106
+ self.registered_commands: dict[int,ThreespaceCommand] = {}
107
+
108
+ if self.data_stream is None:
109
+ self.data_stream = ThreespaceBufferInputStream()
110
+ self.header_info = None
111
+
112
+ self.__parsing_header: ThreespaceHeader = None #Used to optimize preventing reading to much by cacheing the header seperately from the cmd data
113
+ self.__parsing_command: ThreespaceCommand = None
114
+ self.__parsing_msg_length: int = None #Used seperately from the __parsing_header so can handle msg lengths that are static without modifying the header
115
+
116
+ self.misaligned = False
117
+ self.verbose = verbose
118
+
119
+ def register_command(self, cmd: int|ThreespaceCommand, **kwargs):
120
+ """
121
+ Registers the given cmd number/cmd with the binary parser.
122
+
123
+ Some commands may require additional information:
124
+ stream_slots - list[int] Required when registering command 84 (getStreamingBatch) a list of command numbers that are being streamed.
125
+ 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.
126
+ """
127
+ if isinstance(cmd, int):
128
+ cmd = threespaceCommandGet(cmd)
129
+ if cmd is None:
130
+ raise ValueError(f"Invalid Cmd {cmd}")
131
+
132
+ if cmd.info.num in self.registered_commands:
133
+ return False
134
+
135
+ #These command types are special and need additional info
136
+ if cmd.info.num == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
137
+ if "read_size" not in kwargs:
138
+ raise ValueError("Missing arguement 'read_size' when registering the fileReadBytes command with the binary parser")
139
+ raise NotImplementedError("The fileReadBytes command has yet to be implemented for the ThreespaceBinaryParser")
140
+ elif cmd.info.num == THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM:
141
+ if "stream_slots" not in kwargs:
142
+ raise ValueError("Missing arguement 'stream_slots' when registering the getStreamingBatch command with the binary parser")
143
+ cmd = ThreespaceGetStreamingBatchCommand(kwargs['stream_slots'])
144
+
145
+ self.registered_commands[cmd.info.num] = cmd
146
+ return True
147
+
148
+ def unregister_command(self, cmd: int|ThreespaceCommand):
149
+ if cmd.info.num not in self.registered_commands:
150
+ return False
151
+ del self.registered_commands[cmd.info.num]
152
+ return True
153
+
154
+ def set_header(self, header_info: ThreespaceHeaderInfo):
155
+ self.header_info = header_info
156
+
157
+ def insert_data(self, data: bytes):
158
+ """
159
+ Add the given data to the default ThreespaceBufferInputStream.
160
+ This method will raise an exception if used on a different type of InputStream
161
+ """
162
+ if not isinstance(self.data_stream, ThreespaceBufferInputStream):
163
+ raise Exception("Insert data with Binary Parser only valid when using the default data_stream")
164
+ self.data_stream.insert(data)
165
+
166
+ def parse_message(self) -> ThreespaceCmdResult:
167
+ if self.__parsing_header is None:
168
+ self.__parse_header()
169
+
170
+ if self.__parsing_command is None:
171
+ return None
172
+
173
+ return self.__parse_command()
174
+
175
+ def __parse_header(self):
176
+ if self.data_stream.length < self.header_info.size:
177
+ return
178
+
179
+ header = self.data_stream.peek(self.header_info.size)
180
+ header = ThreespaceHeader.from_bytes(header, self.header_info)
181
+
182
+ cmd_found = False
183
+ if self.header_info.echo_enabled: #Search for the command to parse
184
+ for command in self.registered_commands.values():
185
+ if header.echo != command.info.num: continue
186
+
187
+ #Command matches! Attempt to parse
188
+ self.__parsing_command = command
189
+ self.__parsing_header = header
190
+
191
+ cmd_found = True
192
+ else: #Can only parse one command
193
+ if len(self.registered_commands) > 1:
194
+ raise Exception("Only one command type can be parsed when the 'cmd echo' is not enabled in the header")
195
+ self.__parsing_command = list(self.registered_commands.values())[0]
196
+ self.__parsing_header = header
197
+ cmd_found = True
198
+
199
+ if cmd_found:
200
+ if self.header_info.length_enabled:
201
+ self.__parsing_msg_length = self.__parsing_header.length
202
+ else:
203
+ self.__parsing_msg_length = self.__parsing_command.info.out_size
204
+ return
205
+
206
+ #This header is not related to any command, so it needs skipped
207
+ if self.verbose and not self.misaligned:
208
+ print("Unexpected header:", header)
209
+ self.misaligned = True
210
+ self.data_stream.read(1)
211
+
212
+ def __peek_checksum(self):
213
+ header_len = len(self.__parsing_header.raw_binary)
214
+ data = self.data_stream.peek(header_len + self.__parsing_msg_length)[header_len:]
215
+ checksum = sum(data) % 256
216
+ return checksum == self.__parsing_header.checksum
217
+
218
+ def __parse_command(self):
219
+ #Not enough data to parse yet
220
+ if self.data_stream.length < self.header_info.size + self.__parsing_msg_length:
221
+ return None
222
+
223
+ if self.header_info.checksum_enabled and not math.isnan(self.__parsing_msg_length): #Can validate checksum before parsing
224
+ print("Pre validating checksum")
225
+ if not self.__peek_checksum():
226
+ #Data corruption/Misalignment error
227
+ if self.verbose and not self.misaligned:
228
+ print("Checksum mismatch for command", self.__parsing_command.info.num)
229
+ self.misaligned = True
230
+ self.data_stream.read(1)
231
+ self.__parsing_command = None
232
+ self.__parsing_header = None
233
+ return None
234
+
235
+ #Header and pre validation checksum checks out! Now just parse the actual command result and return it
236
+ header = self.__parsing_header
237
+ self.data_stream.read(len(header.raw_binary)) #Skip these bytes since they are already parsed
238
+ result, raw = self.__parsing_command.read_command(self.data_stream)
239
+
240
+ #Validate checksum if couldn't pre-validate due to unknown message length
241
+ if math.isnan(self.__parsing_msg_length) and self.header_info.checksum_enabled:
242
+ checksum = sum(raw) % 256
243
+ if checksum != header.checksum:
244
+ if self.verbose and not self.misaligned:
245
+ print("Checksum mismatch for command", self.__parsing_command.info.num)
246
+ self.misaligned = True
247
+ self.__parsing_command = None
248
+ self.__parsing_header = None
249
+ return None
250
+
251
+ #Reset and return
252
+ self.__parsing_header = None
253
+ self.__parsing_command = None
254
+ self.misaligned = False
255
+ return ThreespaceCmdResult(result, header, data_raw_binary=raw)
256
+