pigeon-transmission 0.0.2__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,1056 @@
1
+ """
2
+ device_h5.py
3
+
4
+ This library provides a high-level interface for communicating with IMT Analytics CITREX measurement
5
+ devices via serial connection. It supports executing commands, reading and writing settings,
6
+ retrieving measurements, system states, system information and using the imt protocol.
7
+ It is not an official library provided or endorsed by IMT Analytics.
8
+
9
+ Author: Dennis Schramm
10
+ Date: 06.02.2026
11
+ Version: 0.0.2
12
+ Python-version: 3.10
13
+ Coding conventions: Is complied with PEP8 Style Guide for Python Code. E501 max line length is ignored.
14
+ """
15
+ import serial
16
+ import copy
17
+ import time
18
+ import re
19
+
20
+
21
+ message_core_structure = { # the standard syntax for a msg
22
+ "start_token": ['%', 1],
23
+ "operation": [None, None],
24
+ "separator_token": ['#', 1],
25
+ "operation_identifier": [None, None]
26
+ }
27
+
28
+ message_value_optional = { # the syntax when a msg or response has a value
29
+ "value_prefix": ['$', 1],
30
+ "value": [None, None]
31
+ }
32
+
33
+ message_terminator = { # the standard syntax for terminator
34
+ "end_token": ['\r', 1]
35
+ }
36
+
37
+ message_value_mapping = { # Overview of whether a command and a response can contain a value
38
+ "CM": {"command_has_value": None, "response_has_value": None}, # None mean its variable (exist or not)
39
+ "WS": {"command_has_value": True, "response_has_value": True}, # True means a value exist
40
+ "RS": {"command_has_value": False, "response_has_value": True}, # False mean a value do not exist
41
+ "RM": {"command_has_value": False, "response_has_value": True},
42
+ "RI": {"command_has_value": False, "response_has_value": True},
43
+ "ST": {"command_has_value": False, "response_has_value": True}
44
+ }
45
+
46
+ stream_protocol_specs = {
47
+ "stream": {"baudrate": 19200, "msg length": 9},
48
+ "fast stream": {"baudrate": 115200, "msg length": 27}
49
+ }
50
+
51
+ execute_command_dictionary = { # this dict translate the string for the operation identifier and contain the conversion factor
52
+ "start_pressure/flow_offset_adjust": {"operation_identifier": 1, "conversion_factor": None},
53
+ "start_oxygen_calibration": {"operation_identifier": 2, "conversion_factor": None},
54
+ "next_calibration_step": {"operation_identifier": 3, "conversion_factor": None},
55
+ "stop_running_calibration": {"operation_identifier": 4, "conversion_factor": None},
56
+ "switch_echo": {"operation_identifier": 5, "conversion_factor": None, "value": {
57
+ "off": 0,
58
+ "on": 1}},
59
+ "start_stream": {"operation_identifier": 64, "conversion_factor": None},
60
+ "stop_stream": {"operation_identifier": 65, "conversion_factor": None},
61
+ "start_automated_zero_calibration": {"operation_identifier": 66, "conversion_factor": None, "range": {"min": 1, "max": 1}},
62
+ "screen": {"operation_identifier": 67, "conversion_factor": None, "value": {
63
+ "unlocked": 0,
64
+ "locked": 1}},
65
+ "touch": {"operation_identifier": 68, "conversion_factor": None, "value": {
66
+ "unlocked": 0,
67
+ "locked": 1}}
68
+ }
69
+
70
+ read_measurement_dictionary = { # this dict translate the string for the operation identifier and contain the conversion factor
71
+ "high_flow": {"operation_identifier": 0, "conversion_factor": 10, "range": {"min": -3000, "max": 3000}},
72
+ "differential_pressure": {"operation_identifier": 3, "conversion_factor": 100, "range": {"min": -15000, "max": 15000}},
73
+ "pressure_high_flow": {"operation_identifier": 4, "conversion_factor": 100, "range": {"min": 0, "max": 15000}},
74
+ "volume_high_flow": {"operation_identifier": 6, "conversion_factor": 10, "range": {"min": 0, "max": 65536}},
75
+ "current_breath_phase": {"operation_identifier": 8, "conversion_factor": 1, "range": {"min": 0, "max": 1}, "value": {
76
+ "expiration": 0,
77
+ "inspiration": 1}},
78
+ "oxygen": {"operation_identifier": 9, "conversion_factor": 10, "range": {"min": 0, "max": 1100}},
79
+ "temperature": {"operation_identifier": 11, "conversion_factor": 10, "range": {"min": 0, "max": 1000}},
80
+ "high_pressure": {"operation_identifier": 13, "conversion_factor": 1, "range": {"min": 0, "max": 10000}},
81
+ "ambient_pressure": {"operation_identifier": 14, "conversion_factor": 1, "range": {"min": 0, "max": 1150}},
82
+ "inspiration_time": {"operation_identifier": 19, "conversion_factor": 100, "range": {"min": 20, "max": 6000}},
83
+ "expiration_time": {"operation_identifier": 20, "conversion_factor": 100, "range": {"min": 20, "max": 6000}},
84
+ "i_e_ratio": {"operation_identifier": 21, "conversion_factor": 10, "range": {"min": 1, "max": 3000}},
85
+ "breath_rate": {"operation_identifier": 22, "conversion_factor": 10, "range": {"min": 0, "max": 1500}},
86
+ "volume_inspiration": {"operation_identifier": 23, "conversion_factor": 1, "range": {"min": 0, "max": 10000}},
87
+ "volume_expiration": {"operation_identifier": 24, "conversion_factor": 1, "range": {"min": 0, "max": 10000}},
88
+ "volume_flow_inspiration": {"operation_identifier": 25, "conversion_factor": 10, "range": {"min": 0, "max": 3000}},
89
+ "volume_flow_expiration": {"operation_identifier": 26, "conversion_factor": 10, "range": {"min": 0, "max": 3000}},
90
+ "peak_pressure": {"operation_identifier": 27, "conversion_factor": 10, "range": {"min": 0, "max": 1500}},
91
+ "mean_pressure": {"operation_identifier": 28, "conversion_factor": 10, "range": {"min": 0, "max": 1500}},
92
+ "peep": {"operation_identifier": 29, "conversion_factor": 10, "range": {"min": 0, "max": 1500}},
93
+ "inspiratory_time_fraction": {"operation_identifier": 30, "conversion_factor": 10, "range": {"min": 0, "max": 1000}},
94
+ "peak_flow_inspiration": {"operation_identifier": 31, "conversion_factor": 10, "range": {"min": -3000, "max": 3000}},
95
+ "peak_flow_expiration": {"operation_identifier": 32, "conversion_factor": 10, "range": {"min": -3000, "max": 3000}},
96
+ "plateau_pressure": {"operation_identifier": 41, "conversion_factor": 10, "range": {"min": 0, "max": 1500}},
97
+ # ## the range is not possible
98
+ "compliance": {"operation_identifier": 42, "conversion_factor": 10, "range": {"min": 0, "max": 1000000}},
99
+ "ipap": {"operation_identifier": 43, "conversion_factor": 10, "range": {"min": 0, "max": 1500}}
100
+ }
101
+
102
+ read_write_setting_dictionary = { # this dict translate the string for the operation identifier and contain the conversion factor
103
+ "gas_type": {"operation_identifier": 1, "conversion_factor": None, "value": {
104
+ "air": 0,
105
+ "air_o2_manually": 1,
106
+ "air_o2_automatically": 2,
107
+ "n2o_o2_manually": 3,
108
+ "n2o_o2_automatically": 4,
109
+ "heliox": 5,
110
+ "he_o2_manually": 6,
111
+ "he_o2_automatically": 7,
112
+ "n2": 8,
113
+ "co2": 9,
114
+ "custom": 10}},
115
+ "manual_oxygen_concentration": {"operation_identifier": 2, "conversion_factor": 1, "range": {"min": 21, "max": 100}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
116
+ "gas_standard": {"operation_identifier": 3, "conversion_factor": None, "value": {
117
+ "atp": 0,
118
+ "stp": 1,
119
+ "btps": 2,
120
+ "btpd": 3,
121
+ "0/1013": 4,
122
+ "20/981": 5,
123
+ "15/1013": 6,
124
+ "20/1013": 7,
125
+ "25/991": 8,
126
+ "ap21": 9,
127
+ "stph": 10,
128
+ "atpd": 11,
129
+ "atps": 12,
130
+ "btps-a": 13,
131
+ "btpd-a": 14,
132
+ "ntpd": 15,
133
+ "ntps": 16}},
134
+ "vol_trigger_response_mode": {"operation_identifier": 4, "conversion_factor": None, "value": {
135
+ "adult": 0,
136
+ "pediatric": 1,
137
+ "high_frequency": 2,
138
+ "auto": 3}},
139
+ "vol_trigger_trigger_source": {"operation_identifier": 5, "conversion_factor": None, "value": {
140
+ "internal_high_flow_channel": 1,
141
+ "external_high_flow_channel": 3}},
142
+ "vol_trigger_start_trigger_signal": {"operation_identifier": 6, "conversion_factor": None, "value": {
143
+ "flow": 0,
144
+ "pressure_flow_channel": 1}},
145
+ "vol_trigger_start_trigger_edge": {"operation_identifier": 7, "conversion_factor": None, "value": {
146
+ "rising_edge": 0,
147
+ "falling_edge": 1}},
148
+ "vol_trigger_start_trigger_signal_value": {"operation_identifier": 8, "conversion_factor": 10, "range": {"min": -2500, "max": 2500}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
149
+ "vol_trigger_end_trigger_signal": {"operation_identifier": 9, "conversion_factor": None, "value": {
150
+ "flow": 0,
151
+ "pressure": 1}},
152
+ "vol_trigger_end_trigger_edge": {"operation_identifier": 10, "conversion_factor": None, "value": {
153
+ "rising_edge": 0,
154
+ "falling_edge": 1}},
155
+ "vol_trigger_end_trigger_signal_value": {"operation_identifier": 11, "conversion_factor": 10, "range": {"min": -2500, "max": 2500}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
156
+ "trigger_delay": {"operation_identifier": 12, "conversion_factor": 1, "range": {"min": 10, "max": 120}, "value": True},
157
+ "baseflow": {"operation_identifier": 13, "conversion_factor": None, "value": {
158
+ "disabled": 0,
159
+ "enabled": 1}},
160
+ "baseflow_volume_trigger": {"operation_identifier": 14, "conversion_factor": 10, "range": {"min": -3000, "max": 3000}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
161
+ "filter_type": {"operation_identifier": 15, "conversion_factor": None, "value": {
162
+ "None": 0,
163
+ "filter_low": 1,
164
+ "filter_medium": 2,
165
+ "filter_high": 3}},
166
+ "start_trigger_delay": {"operation_identifier": 19, "conversion_factor": 1, "range": {"min": 10, "max": 120}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
167
+ "end_trigger_delay": {"operation_identifier": 20, "conversion_factor": 1, "range": {"min": 10, "max": 120}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
168
+ "gas_humidity": {"operation_identifier": 21, "conversion_factor": 1, "range": {"min": 0, "max": 100}, "value": True}, # Note: "value" = True mean do not exist a translation - The value must be taken directly
169
+ "respiratory_parameter_pressure_source": {"operation_identifier": 22, "conversion_factor": None, "value": {
170
+ "pressure_channel": 0,
171
+ "differential_pressure": 1,
172
+ "high_pressure": 2}},
173
+ "dynamic_pressure_compensation": {"operation_identifier": 23, "conversion_factor": None, "value": {
174
+ "disable": 0,
175
+ "enable": 1}},
176
+ "stream_first_value": {"operation_identifier": 64, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}}, # use the operation identifier from read_measurement_dictionary
177
+ "stream_second_value": {"operation_identifier": 65, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}}, # use the operation identifier from read_measurement_dictionary
178
+ "stream_third_value": {"operation_identifier": 66, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}}, # use the operation identifier from read_measurement_dictionary
179
+ "stream_fourth_value": {"operation_identifier": 160, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
180
+ "stream_fifth_value": {"operation_identifier": 161, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
181
+ "stream_sixth_value": {"operation_identifier": 162, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
182
+ "stream_seventh_value": {"operation_identifier": 163, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
183
+ "stream_eighth_value": {"operation_identifier": 164, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
184
+ "stream_ninth_value": {"operation_identifier": 165, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
185
+ "stream_tenth_value": {"operation_identifier": 166, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
186
+ "stream_eleventh_value": {"operation_identifier": 167, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
187
+ "stream_twelfth_value": {"operation_identifier": 168, "conversion_factor": None, "value": {key: value["operation_identifier"] for key, value in read_measurement_dictionary.items()}},
188
+ "usb_mass_storage": {"operation_identifier": 70, "conversion_factor": None, "value": {
189
+ "disabled": 0,
190
+ "enabled": 1}}
191
+ }
192
+
193
+ read_system_information_dictionary = { # this dict translate the string for the operation identifier and contain the conversion factor
194
+ "hardware_version": {"operation_identifier": 1, "conversion_factor": None},
195
+ "software_major_version": {"operation_identifier": 2, "conversion_factor": None},
196
+ "software_minor_version": {"operation_identifier": 3, "conversion_factor": None},
197
+ "software_release": {"operation_identifier": 4, "conversion_factor": None},
198
+ "last_calibration_day": {"operation_identifier": 5, "conversion_factor": None},
199
+ "last_calibration_month": {"operation_identifier": 6, "conversion_factor": None},
200
+ "last_calibration_year": {"operation_identifier": 7, "conversion_factor": None},
201
+ "serial_number": {"operation_identifier": 8, "conversion_factor": None},
202
+ "next_calibration_day": {"operation_identifier": 9, "conversion_factor": None},
203
+ "next_calibration_month": {"operation_identifier": 10, "conversion_factor": None},
204
+ "next_calibration_year": {"operation_identifier": 11, "conversion_factor": None},
205
+ "device_type": {"operation_identifier": 12, "conversion_factor": None, "value": {
206
+ "invalid": 0,
207
+ "Citrex H4": 1,
208
+ "VT305": 2,
209
+ "Citrex H5": 3,
210
+ "Citrex H3": 4,
211
+ "PF-300 Pro": 5,
212
+ "Flowmeter F1": 6,
213
+ "Flowmeter F2": 7}}
214
+ }
215
+
216
+ read_state_dictionary = { # this dict translate the string for the operation identifier and contain the conversion factor
217
+ "calibration_state": {"operation_identifier": 1, "conversion_factor": None, "range": {"min": 0, "max": 27}, "value": {
218
+ "Calibration state: idle": 0,
219
+ "Calibration state: error during calibration": 1,
220
+ "Oxygen calibration: waiting for 100pct oxygen": 2,
221
+ "Oxygen calibration: reading 100pct oxygen": 3,
222
+ "Oxygen calibration: waiting for 21pct oxygen": 4,
223
+ "Oxygen calibration: reading 21pct oxygen": 5,
224
+ "Oxygen calibration: finished, waiting for user acknowledge": 6,
225
+ "Oxygen calibration: finished": 7,
226
+ "Pressure/flow offset adjust: wait for user acknowledge": 8,
227
+ "Pressure/flow offset adjust: reading offset": 9,
228
+ "Pressure/flow offset adjust: finished, waiting for user acknowledge": 10,
229
+ "Pressure/flow offset adjust: finished": 11,
230
+ "Pressure sensor gain adjust: read offset, waiting for user acknowledge": 12,
231
+ "Pressure sensor gain adjust: reading offset": 13,
232
+ "Pressure sensor gain adjust: read high adjustment pressure 1, waiting for user acknowledge": 14,
233
+ "Pressure sensor gain adjust: read high adjustment pressure 1": 15,
234
+ "Pressure sensor gain adjust: read high adjustment pressure 2, waiting for user acknowledge": 16,
235
+ "Pressure sensor gain adjust: read high adjustment pressure 2": 17,
236
+ "Pressure sensor gain adjust: finished, waiting for user acknowledge": 18,
237
+ "Pressure sensor gain adjust: finished": 19,
238
+ "Flow calibration: next flow, waiting for user acknowledge": 20,
239
+ "Flow calibration: read next flow": 21,
240
+ "Flow calibration: finished, waiting for user acknowledge": 22,
241
+ "Flow calibration: finished": 23,
242
+ "Drift compensation: started": 24,
243
+ "Drift compensation: read reference": 25,
244
+ "Drift compensation: waiting for next temperature": 26,
245
+ "Drift compensation: read temperature and offset": 27}}
246
+ }
247
+
248
+
249
+ class DeviceH5:
250
+ def __init__(self, port: str | None = None, baudrate: int | None = None):
251
+ """
252
+ This method is used to initialize the class.
253
+ """
254
+ self.serial_interface = SerialInterface()
255
+ self.io_validator = IOValidator()
256
+ self.port = port # Set the argument for port – Note: if it is not called with a with block, it will be None
257
+ self.baudrate = baudrate # set the argument for baud rate
258
+ self.serial_object = None # set the serial object
259
+
260
+ def __enter__(self):
261
+ """
262
+ Context manager entry method.
263
+
264
+ This method is automatically called when entering a `with` block.
265
+ The serial interface is opened automatically
266
+
267
+ Return:
268
+ self (object): The current instance of the class
269
+ """
270
+ self.open_serial_interface(self.port, self.baudrate) # open the serial object
271
+ return self # return
272
+
273
+ def __exit__(self, exc_type, exc_val, exc_tb):
274
+ """
275
+ Context manager exit method.
276
+
277
+ This method is automatically called when leaving a `with` block.
278
+ It ensures that the serial interface is properly closed, even if
279
+ an exception occurs during execution inside the block.
280
+
281
+ Args:
282
+ exc_type (type): The exception type if an error occurred, else None
283
+ exc_val (Exception): The exception instance if an error occurred, else None
284
+ exc_tb (traceback): The traceback object if an error occurred, else None
285
+ """
286
+ self.close_serial_interface() # close the serial port
287
+
288
+ def open_serial_interface(self, port: str, baudrate: int) -> serial.Serial:
289
+ """
290
+ This method is used to open a serial interface.
291
+
292
+ Args:
293
+ port (str): The port where serial communication is to be established
294
+ baudrate (int): The baudrate for the serial communication
295
+
296
+ Return:
297
+ serial_object (serial.Serial): The serial object of the session
298
+ """
299
+ allowed = [spec["baudrate"] for spec in stream_protocol_specs.values()]
300
+ if baudrate not in allowed:
301
+ raise ValueError(f"Invalid baudrate '{baudrate}'. Allowed baudrates are {allowed}")
302
+ serial_object, e = self.serial_interface.open_port(port=port, baudrate=baudrate) # call method for open serial port
303
+ if serial_object is None: # if serial Object is None
304
+ raise ConnectionError(f"Error while open the serial port: {e}") # raise a connection error
305
+ self.serial_interface._get_echo_state(serial_object=serial_object) # call the method for read the echo mode state and save as attribute of serial object
306
+ self.serial_object = serial_object
307
+ return serial_object # return the serial object
308
+
309
+ def close_serial_interface(self, serial_object: serial.Serial | None = None) -> None:
310
+ """
311
+ This method closed the serial object.
312
+
313
+ Args:
314
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
315
+ """
316
+ serial_object = serial_object or self.serial_object
317
+ self.serial_interface.close_port(serial_object=serial_object) # call the method for close the serial port
318
+
319
+ def execute_command(self, operation_name: str, value: str | int | None = None, serial_object: serial.Serial | None = None) -> str | None: # CM
320
+ """
321
+ This method is used to execute command(CM).
322
+
323
+ Args:
324
+ operation_name (str): The operation name of the command
325
+ value (str | int | None): The value for the setting
326
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
327
+
328
+ Return:
329
+ value (str | None): The return value of the response
330
+ """
331
+ serial_object = serial_object or self.serial_object
332
+ result = self._handle_communication(operation="CM", operation_name=operation_name, value=value, dictionary=execute_command_dictionary, serial_object=serial_object) # call the method
333
+ if operation_name == "switch_echo":
334
+ serial_object.echo_state = True if value == "on" else False
335
+ return result
336
+
337
+ def write_setting(self, operation_name: str, value: str | int | None = None, serial_object: serial.Serial | None = None) -> str: # WS
338
+ """
339
+ This method is used to write setting (WS).
340
+
341
+ Args:
342
+ operation_name (str): The operation name of the command
343
+ value (str | int | None): The value for the setting
344
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
345
+
346
+ Return:
347
+ value (str): The return value of the response
348
+ """
349
+ serial_object = serial_object or self.serial_object
350
+ return self._handle_communication(operation="WS", operation_name=operation_name, value=value, dictionary=read_write_setting_dictionary, serial_object=serial_object) # call the method
351
+
352
+ def read_setting(self, operation_name: str, serial_object: serial.Serial | None = None) -> str: # RS
353
+ """
354
+ This method is used to read setting (RS).
355
+
356
+ Args:
357
+ operation_name (str): The operation name of the command
358
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
359
+
360
+ Return:
361
+ value (str): The return value of the response
362
+ """
363
+ serial_object = serial_object or self.serial_object
364
+ return self._handle_communication(operation="RS", operation_name=operation_name, value=None, dictionary=read_write_setting_dictionary, serial_object=serial_object) # call the method
365
+
366
+ def read_measurement(self, operation_name: str, serial_object: serial.Serial | None = None) -> str: # RM
367
+ """
368
+ This method is used to read measurement (RM).
369
+
370
+ Args:
371
+ operation_name (str): The operation name of the command
372
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
373
+
374
+ Return:
375
+ value (str): The return value of the response
376
+ """
377
+ serial_object = serial_object or self.serial_object
378
+ return self._handle_communication(operation="RM", operation_name=operation_name, value=None, dictionary=read_measurement_dictionary, serial_object=serial_object) # call the method
379
+
380
+ def read_system_information(self, operation_name: str, serial_object: serial.Serial | None = None) -> str: # RI
381
+ """
382
+ This method is used to read system information (RI).
383
+
384
+ Args:
385
+ operation_name (str): The operation name of the command
386
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
387
+
388
+ Return:
389
+ value (str): The return value of the response
390
+ """
391
+ serial_object = serial_object or self.serial_object
392
+ return self._handle_communication(operation="RI", operation_name=operation_name, value=None, dictionary=read_system_information_dictionary, serial_object=serial_object) # call the method
393
+
394
+ def read_state(self, operation_name: str, serial_object: serial.Serial | None = None) -> str: # ST
395
+ """
396
+ This method is used to read state (ST).
397
+
398
+ Args:
399
+ operation_name (str): The operation name of the command
400
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
401
+
402
+ Return:
403
+ value (str): The return value of the response
404
+ """
405
+ serial_object = serial_object or self.serial_object
406
+ return self._handle_communication(operation="ST", operation_name=operation_name, value=None, dictionary=read_state_dictionary, serial_object=serial_object) # call the method
407
+
408
+ def read_multiple_measurements(self, operation_name: str, sample_count: int | str | float, sample_rate: int | str | float, serial_object: serial.Serial | None = None) -> list: # RM multiple
409
+ """
410
+ This method is used to read measurements (RM).
411
+
412
+ Args:
413
+ operation_name (str): The operation name of the command
414
+ sample_count (int | str | float): The number of measured values
415
+ sample_rate (int | str | float): The sample rate to use in Hz (min: 0.01 | max: 20)
416
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
417
+
418
+ Return:
419
+ value (list): The return values of the response as list
420
+ """
421
+ serial_object = serial_object or self.serial_object
422
+ sample_count = self.io_validator.validate_and_cast(value=sample_count, target_types=int, allowed_types=(str, float, int), min_value=1, max_value=999999) # call the method for validate and cast the sample count
423
+ sample_rate = self.io_validator.validate_and_cast(value=sample_rate, target_types=float, allowed_types=(str, float, int), min_value=0.01, max_value=20) # call the method for validate and cast the sample rate
424
+ value_list = [] # initialize an empty list
425
+ wait_time = 0 if (result := 1 / float(sample_rate) - 0.05) < 0 else result # if the sample rate is less than 50 ms, use 0 else result - Note: each command with a response takes an average of 50 ms
426
+ for i in range(sample_count): # start for loop
427
+ value = self.read_measurement(operation_name=operation_name, serial_object=serial_object) # read the measurement
428
+ value_list.append(value) # append the value to list
429
+ if i < sample_count - 1: # if no is the last iteration
430
+ time.sleep(wait_time) # wait
431
+ return value_list # return the list
432
+
433
+ def start_stream(self, first_value: str, second_value: str, third_value: str, data_queue, stop_queue, streaming_duration: int | float | str | None, serial_object: serial.Serial | None = None) -> None:
434
+ """
435
+ This method is used to start the stream.\n
436
+ Note: Please read the imt protocol stream documentation before using this feature.
437
+
438
+ Args:
439
+ first_value (str): The first value of stream
440
+ second_value (str): The second value of stream
441
+ third_value (str): The third value of stream
442
+ data_queue (queue): The data queue where the measurement data is saved
443
+ stop_queue (queue): The stop queue that handles cancellation and termination signals
444
+ streaming_duration (int | float | str | None): The maximum duration of the stream in seconds
445
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
446
+ """
447
+ serial_object = serial_object or self.serial_object
448
+ baudrate = stream_protocol_specs["stream"]["baudrate"]
449
+ msg_length = stream_protocol_specs["stream"]["msg length"]
450
+ if serial_object.baudrate != baudrate: # check that the correct baudrate is use
451
+ raise ValueError(f"Invalid baudrate '{serial_object.baudrate}'. " f"This method requires {baudrate} for stream.")
452
+ streaming_duration = self.io_validator.validate_and_cast(value=streaming_duration, target_types=float, allowed_types=(str, float, int, type(None)), min_value=1, max_value=999999) # call the method to validate and cast the streaming duration
453
+ self._set_stream_pre_configuration(serial_object, first_value, second_value, third_value) # call method for set stream configuration
454
+ self.execute_command(operation_name="start_stream", value=None, serial_object=serial_object) # call the method to start the stream
455
+ self._handle_read_stream(serial_object, data_queue, stop_queue, streaming_duration, msg_length, first_value, second_value, third_value) # call the method for handle the stream
456
+ self.execute_command(operation_name="stop_stream", value=None, serial_object=serial_object) # call the method to stop the stream
457
+ self.execute_command(operation_name="screen", value="unlocked", serial_object=serial_object) # call method for unlock the screen
458
+
459
+ def start_fast_stream(self, first_value: str, second_value: str, third_value: str,
460
+ fourth_value: str, fifth_value: str, sixth_value: str,
461
+ seventh_value: str, eighth_value: str, ninth_value: str,
462
+ tenth_value: str, eleventh_value: str, twelfth_value: str,
463
+ data_queue, stop_queue, streaming_duration: int | float | str | None, serial_object: serial.Serial | None = None) -> None:
464
+ """
465
+ This method is used to start the fast stream.\n
466
+ Note: Please read the imt fast protocol stream documentation before using this feature.
467
+
468
+ Args:
469
+ first_value (str): The first value of fast stream
470
+ second_value (str): The second value of fast stream
471
+ third_value (str): The third value of fast stream
472
+ fourth_value (str): The fourth value of the fast stream
473
+ fifth_value (str): The fifth value of the fast stream
474
+ sixth_value (str):The sixth value of the fast stream
475
+ seventh_value (str): The seventh value of the fast stream
476
+ eighth_value (str): The eighth value of the fast stream
477
+ ninth_value (str): The ninth value of the fast stream
478
+ tenth_value (str): The tenth value of the fast stream
479
+ eleventh_value (str): The eleventh value of the fast stream
480
+ twelfth_value (str): The twelfth value of the fast stream
481
+ data_queue (queue): The data queue where the measurement data is saved
482
+ stop_queue (queue): The stop queue that handles cancellation and termination signals
483
+ streaming_duration (int | float | str | None): The maximum duration of the fast stream in seconds
484
+ serial_object (serial.Serial | None): Optional - uses the instances serial object if omitted.
485
+ """
486
+ serial_object = serial_object or self.serial_object
487
+ baudrate = stream_protocol_specs["fast stream"]["baudrate"]
488
+ msg_length = stream_protocol_specs["fast stream"]["msg length"]
489
+ if serial_object.baudrate != stream_protocol_specs["fast stream"]["baudrate"]: # check that the correct baudrate is use - Note: only 115200 else raise an error
490
+ raise ValueError(f"Invalid baudrate '{serial_object.baudrate}'. " f"This method requires {baudrate} for fast stream.")
491
+ streaming_duration = self.io_validator.validate_and_cast(value=streaming_duration, target_types=float, allowed_types=(str, float, int, type(None)), min_value=1, max_value=999999) # call the method to validate and cast the streaming duration
492
+ self._set_stream_pre_configuration(serial_object, first_value, second_value, third_value, fourth_value, fifth_value, sixth_value, seventh_value, eighth_value, ninth_value, tenth_value, eleventh_value, twelfth_value) # call method for set fast stream configuration
493
+ self.execute_command(operation_name="start_stream", value=None, serial_object=serial_object) # call the method to start the fast stream
494
+ self._handle_read_stream(serial_object, data_queue, stop_queue, streaming_duration, msg_length, first_value, second_value, third_value, fourth_value, fifth_value, sixth_value, seventh_value, eighth_value, ninth_value, tenth_value, eleventh_value, twelfth_value) # call the method for handle the fast stream
495
+ self.execute_command(operation_name="stop_stream", value=None, serial_object=serial_object) # call the method to stop the fast stream
496
+ self.execute_command(operation_name="screen", value="unlocked", serial_object=serial_object) # call method for unlock the screen
497
+
498
+ def _handle_communication(self, operation: str, operation_name: str, value: str | int | None, dictionary: dict, serial_object: serial.Serial) -> str | None:
499
+ """
500
+ This method is used to handle the communication with the device.
501
+
502
+ Args:
503
+ operation (str): The execute operation - RM/CM/RS...
504
+ operation_name (str): The operation name of the command
505
+ value (str | int | None): The value to be setting
506
+ dictionary (dict): The dictionary to use for the command
507
+ serial_object (serial.Serial): The serial interface for communication
508
+
509
+ Return:
510
+ value (str | None): The return value of the response
511
+ """
512
+ operation_name = operation_name.lower().replace(" ", "") # all characters lower case and remove spaces
513
+ operation_identifier = self.io_validator.check_operation_name(dictionary=dictionary, operation_name=operation_name) # call the method to verify the operation_name and get the operation identifier
514
+ conversion_factor = dictionary.get(operation_name, None).get("conversion_factor") # get the conversion factor
515
+ if value is not None: # if the value is not none
516
+ value = str(value).lower().replace(" ", "") # delete spaces
517
+ if conversion_factor is not None: # if the conversion fac tor is not none
518
+ value = int(float(value) * conversion_factor) # calculate the needed value for the device
519
+ searched_value = self.io_validator.check_values_argument(dictionary=dictionary, operation=operation, operation_name=operation_name, value=value) # call the method to verify the value
520
+ if serial_object is None: # if the serial object is None (not valid)
521
+ raise ConnectionError("The serial object is not valid")
522
+ for _ in range(5): # start for loop
523
+ value, error_occurred = self._execute_communication(operation=operation, operation_identifier=operation_identifier, value=searched_value, serial_object=serial_object) # call the method to execute the communication
524
+ if error_occurred: # if an error occurred
525
+ continue # continue the loop
526
+ if operation == "WS": # if the operation is WS - command value and response value must be the same, otherwise something has gone wrong
527
+ if str(searched_value) != str(value): # if is not same
528
+ time.sleep(2) # wait 2 seconds
529
+ continue # continue the loop
530
+ if (dict_range := execute_command_dictionary.get(operation_name, {}).get("range")) is not None: # if range exist
531
+ if not (dict_range.get("min") <= int(value) <= dict_range.get("max")): # if the value is in range
532
+ time.sleep(2) # wait 2 seconds
533
+ continue # continue the loop
534
+ if dictionary.get(operation_name, {}).get("value") not in (None, True) and value != "": # if a translation for the value exist
535
+ value = next((k for k, v in dictionary.get(operation_name, {}).get("value", {}).items() if str(v) == value), None) # translate the response value
536
+ if value is None:
537
+ continue # continue the loop
538
+ break # break the loop no error
539
+ else: # only is called if the for loop runs without a break command
540
+ raise ValueError("Invalid device response") # raise an error
541
+ if (conversion_factor := dictionary.get(operation_name, {}).get("conversion_factor")) is not None: # if conversion factor exist
542
+ value = str(float(value) / float(conversion_factor)) # convert the value
543
+ return value # return the value
544
+
545
+ def _execute_communication(self, operation: str, operation_identifier: str, value: str | int | None, serial_object: serial.Serial) -> tuple[str | None, bool]:
546
+ """
547
+ This method is used to handle the communication with the device.
548
+
549
+ Args:
550
+ operation (str): The execute operation - RM/CM/RS...
551
+ operation_identifier (str): The operation name of the command
552
+ value (str | int | None): The value to be setting
553
+ serial_object (serial.Serial): The serial interface for communication
554
+
555
+ Return:
556
+ value (str | None): The return value of the response
557
+ error (bool): The error
558
+ """
559
+ command = self.io_validator.build_command(operation=operation, operation_identifier=operation_identifier, value=value) # call the method for build command
560
+ # print(f"sent: {command}")
561
+ self.serial_interface.write_command(command=command, serial_object=serial_object) # call the method for write the command
562
+ chunk = self.serial_interface.read_command(length=1, serial_object=serial_object) # call the method for read the command
563
+ if chunk == b'': # if the chunk is empty
564
+ return "", True # bool mean error occurred - A timeout often occurs then.
565
+ response_dict = self.io_validator.build_message_structure(operation, operation_identifier, value, has_value_key="response_has_value") # call the method for build the structure of response
566
+ response, error_occurred = self.serial_interface.read_packet(chunk=chunk, serial_object=serial_object, response_dict=response_dict) # call the method for read the msg
567
+ # print(f"received: {response}")
568
+ if error_occurred: # if an error occurred
569
+ return response, True # bool mean error occurred
570
+ value, error_occurred = self.io_validator.check_response(response, response_dict) # call the method for check the response
571
+ if error_occurred:
572
+ return value, True # return the value and True for msg no valid
573
+ return value, False # return the value and false for no error
574
+
575
+ def _set_stream_pre_configuration(self, serial_object: serial.Serial, *values: str) -> None:
576
+ """
577
+ This method is used to set the configuration of the stream.
578
+
579
+ Args:
580
+ serial_object (serial.Serial): The serial interface for communication
581
+ *values (str): All values to be measured
582
+ """
583
+
584
+ execute_command_list = [
585
+ ("screen", "locked") # only for citrex - Note: The temporal resolution can only be ensured when the screen is locked
586
+ ]
587
+ for operation_name, value in execute_command_list: # get all commands
588
+ self.execute_command(operation_name=operation_name, value=value, serial_object=serial_object) # call method to execute the command
589
+
590
+ names = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth", "eleventh", "twelfth"]
591
+ write_setting_list = [
592
+ (f"stream_{names[i]}_value", values[i]) for i in range(len(values))
593
+ ] # create the list for set all values
594
+ for operation_name, value in write_setting_list: # get all commands
595
+ self.write_setting(operation_name=operation_name, value=value, serial_object=serial_object) # call method to execute the command
596
+
597
+ def _handle_read_stream(self, serial_object: serial.Serial, data_queue: list, stop_queue: bool, streaming_duration: int | float | None, msg_length: int, *values: str) -> None:
598
+ """
599
+ This method is used to handle the communication with the device.
600
+
601
+ Args:
602
+ serial_object (serial.Serial): The serial interface for communication
603
+ data_queue (queue): The data queue where the measurement data is saved
604
+ stop_queue (queue): The stop queue that handles cancellation and termination signals
605
+ streaming_duration (int | float | str | None): The maximum duration of the stream in seconds
606
+ msg_length (int): The length of the streaming message
607
+ *value (str): The values which are inside in the stream message
608
+ """
609
+ last_raw_timestamp, timestamp_rollover_count = None, 0 # initialize the variables
610
+ streaming_duration = float('inf') if streaming_duration is None else streaming_duration # if streaming duration is None set infinity else use the values of streaming duration
611
+ conversion_factors = [read_measurement_dictionary.get(value, {}).get("conversion_factor") for value in values] # get all conversion factor
612
+ ranges = [(float(read_measurement_dictionary.get(value, {}).get("range").get("min")), float(read_measurement_dictionary.get(value, {}).get("range").get("max"))) for value in values] # get the limit of the values min and max
613
+ while stop_queue.empty(): # while stop_queue is empty
614
+ response = self.serial_interface.read_streaming_msg(serial_object=serial_object, msg_length=msg_length) # call method for read the msg x byte of stream
615
+ if response is True: # if response is True the device is without reaction
616
+ stop_queue.put((True)) # write true to stop_queue
617
+ return # return
618
+ timestamp, last_raw_timestamp, timestamp_rollover_count = self.io_validator.calculate_real_timestamp(response=response, last_raw_timestamp=last_raw_timestamp, timestamp_rollover_count=timestamp_rollover_count) # calculate the real time step with overflow with 16bit
619
+ error_occurred = self.io_validator.check_range(response, msg_length, ranges) # call the method for check the range if all values
620
+ if error_occurred: # if error occurred
621
+ continue
622
+ measured_values = self.io_validator.extract_measured_values(response, msg_length, conversion_factors) # get measured values
623
+ data_queue.put((timestamp, *measured_values)) # write the data to data_queue
624
+ if timestamp >= streaming_duration: # when the maximum streaming duration has expired
625
+ stop_queue.put((True)) # write True to stop_queue
626
+
627
+
628
+ class SerialInterface:
629
+ @staticmethod
630
+ def open_port(port: str, baudrate: int) -> serial.Serial:
631
+ """
632
+ This method is used to open serial ports.
633
+
634
+ Args:
635
+ port (str): The target port
636
+ baudrate (int): The target baudrate
637
+
638
+ Return:
639
+ serial_port (serial.Serial): The session for this serial communication
640
+ """
641
+ for _ in range(3): # start for loop
642
+ try: # try
643
+ serial_port = serial.Serial(port, baudrate, timeout=11) # open serial port with 11 seconds because the device need 10 second plus transport
644
+ return serial_port, None # return serial_port, error
645
+ except serial.SerialException as e: # except
646
+ last_exception = e # save the error
647
+ time.sleep(2) # retry after 2 seconds - but max 3 times
648
+ return None, last_exception # return None, error exception - that means error occurred
649
+
650
+ @staticmethod
651
+ def close_port(serial_object: serial.Serial) -> None:
652
+ """
653
+ This static method closed the serial object.
654
+
655
+ Args:
656
+ serial_object (serial.Serial): The serial object to be closed
657
+ """
658
+ try:
659
+ serial_object.close() # close the serial port
660
+ except Exception:
661
+ pass
662
+
663
+ @staticmethod
664
+ def write_command(command: bytes, serial_object: serial.Serial) -> None:
665
+ """
666
+ This static method write a command to serial object.
667
+
668
+ Args:
669
+ command (bytes): The command to be written
670
+ serial_object (serial.Serial): The serial object to be closed
671
+ """
672
+ serial_object.write(command) # write the command
673
+
674
+ def read_command(self, length: int, serial_object: serial.Serial) -> bytes:
675
+ """
676
+ This method read a response from serial interface.
677
+
678
+ Args:
679
+ length (int): The length of read response
680
+ serial_object (serial.Serial): The serial object where is read
681
+
682
+ Return:
683
+ response (bytes): The read response
684
+ """
685
+ return serial_object.read(length) # return the response
686
+
687
+ def read_packet(self, chunk: bytes, serial_object: serial.Serial, response_dict: dict) -> tuple[bytes, bool]:
688
+ """
689
+ This method read a response from serial interface.
690
+
691
+ Args:
692
+ chunk (bytes): The chunk of bytes msg
693
+ serial_object (serial.Serial): The serial object where is read
694
+ response_dict: The dict for get the end_token
695
+
696
+ Return:
697
+ message (bytes): The read response
698
+ error (bool): The error as bool
699
+ """
700
+ start_token = response_dict["start_token"][0].encode().decode('unicode-escape').encode('utf-8') # convert to bytes (b'%')
701
+ separator_token = response_dict["separator_token"][0].encode().decode('unicode-escape').encode('utf-8') # convert to bytes (b'#')
702
+ end_token = response_dict["end_token"][0].encode().decode('unicode-escape').encode('utf-8') # convert to bytes (b'\r')
703
+ pattern = re.compile(rb'.*' + start_token + rb'(.{1,3})' + separator_token + rb'(.{0,18})' + end_token) # build pattern b'{not important} % {1-3bytes} # {0-18 bytes} \r {not important}'
704
+ message = chunk # set the first chunk as message
705
+ echo_message = None
706
+ while True: # run until the flag is false - only for term character
707
+ chunk = self.read_command(1, serial_object) # read the first byte
708
+ message += chunk # add the byte to message
709
+ if b'' == chunk: # if the chunk is empty
710
+ return message, True # mean timeout occurred
711
+ if chunk == b'?': # if the chunk is ?
712
+ original_timeout = serial_object.timeout # get the original _timeout
713
+ serial_object.timeout = 1 # set timeout to 1 sec
714
+ next_chunk = self.read_command(1, serial_object) # read the next chunk
715
+ serial_object.timeout = original_timeout # set timeout to original
716
+ if not next_chunk: # if next chunk not exist
717
+ return message, True # the response is this error
718
+ message += next_chunk # add next chunk to message
719
+ if pattern.search(message):
720
+ message.replace(b'\x00', b'') # replace all nul in the msg with nothing - the device send some times 0x00 (nul)
721
+ if serial_object.echo_state and echo_message is None:
722
+ echo_message = message # write message to echo_messages
723
+ message = b'' # clear the message
724
+ # print(f"echo: {echo_message}") # echo_message
725
+ continue
726
+ return message, False # return the complete message incl. term_character as bytes - bool meaning no error occurred
727
+
728
+ @staticmethod
729
+ def read_streaming_msg(serial_object: serial.Serial, msg_length: int) -> bytes | bool:
730
+ """
731
+ This method read a nine byte response from serial interface.
732
+
733
+ Args:
734
+ serial_object (serial.Serial): The serial object where is read
735
+ msg_length (int): The length of the message
736
+
737
+ Return:
738
+ message (bytes | bool): The read response or True if not enough bytes were read
739
+ """
740
+ response = serial_object.read(msg_length) # read x byte - note: every complete msg has x bytes
741
+ if len(response) < msg_length: # if less than x bytes were read
742
+ return True # return true for time out und less than x bytes
743
+ while sum(response) % 256 != 0: # while the chk sum is not equal to 0 - The message is invalid - use a shift register to shift one byte further. The division may simply have been shifted.
744
+ response = response[1:] + serial_object.read(1) # shift 1 byte
745
+ return response # return the msg ist valid
746
+
747
+ def _get_echo_state(self, serial_object):
748
+ for _ in range(5):
749
+ self.write_command(b'%RS#1\r', serial_object)
750
+ # print(f'echo test - sent: {b'%RS#1\r'}')
751
+ chunk = self.read_command(1, serial_object)
752
+ if chunk == b'?': # if the first chunk is ? mean error
753
+ continue # continue
754
+ response = chunk + serial_object.read_until(b"\r") # read until '\r' plus the chunk
755
+ echo = b'$' not in response # b'$' not in response? - that mean we have an echo
756
+ serial_object.echo_state = echo # set the echo state
757
+ if echo:
758
+ # print(f'echo test - echo: {response}')
759
+ response = serial_object.read_until(b"\r")
760
+ break
761
+ # print(f'echo test - received: {response}')
762
+
763
+
764
+ class IOValidator:
765
+ def build_command(self, operation: str, operation_identifier: str, value: str | int | None) -> bytes:
766
+ """
767
+ This method is used to build the command.
768
+
769
+ Args:
770
+ operation (str): The operation to be performed example CM/RM....
771
+ operation_identifier (str): The operation identifier for the command
772
+ value (str | int | None): The value for the command if exist
773
+
774
+ Return:
775
+ command_as_byte (bytes): The built command
776
+ """
777
+ command_dict = self.build_message_structure(operation, operation_identifier, value, has_value_key="command_has_value") # call the method for build the message structure
778
+ command_as_byte = (''.join(str(value[0]) for value in command_dict.values() if value[0] is not None)).encode('utf-8') # convert to command as bytes - get all entries of dict
779
+ return command_as_byte # return the command as bytes
780
+
781
+ def build_message_structure(self, operation: str, operation_identifier: str, value: str | int | None, has_value_key: str) -> dict:
782
+ """
783
+ This method is used to build the structure of msg.
784
+
785
+ Args:
786
+ operation (str): The operation to be performed example CM/RM....
787
+ operation_identifier (str): The operation identifier for the command
788
+ value (str | int | None): The value for the command if exist
789
+ has_value_key (str): only command_has_value and response_has_value
790
+
791
+ Return:
792
+ command_as_dict (dict): The built command as dict
793
+ """
794
+ update_core = {"operation": operation, "operation_identifier": operation_identifier} # create the update dictionary for core structure
795
+ core = self._update_dict(copy.deepcopy(message_core_structure), update_core) # call the method for update the dict as core
796
+ terminator = copy.deepcopy(message_terminator) # get the edn token as deep copy dict
797
+ has_value = message_value_mapping[operation][has_value_key] # check if the message has a value - possible True/False and None that mean is variable
798
+ if has_value or has_value is None: # if (has_value is True) or (has_value is None and value is not None) or (has_value is None and has_value_key is True)
799
+ if has_value_key == "response_has_value" or value is not None: # if the out put dict ist for the response
800
+ update_value = {"value": has_value if has_value_key == "response_has_value" else value} # create update dict and if has_value_key is response use None else the value
801
+ value_optional = self._update_dict(copy.deepcopy(message_value_optional), update_value) # get the optional value as dict
802
+ return {**core, **value_optional, **terminator} # merge the dict
803
+ return {**core, **terminator} # merge the dict without value because no has value
804
+
805
+ def check_response(self, response: bytes, response_dict: dict) -> tuple[str | None, bool]:
806
+ """
807
+ This method is used to build the structure of msg.
808
+
809
+ Args:
810
+ response (bytes): The response of device as bytes (b'%RM#0$11\r')
811
+ response_dict (dict): The structure dict of response
812
+
813
+ Return:
814
+ value (str | None): The value of response
815
+ error (bool): If an error occurred return True
816
+ """
817
+ response_str = response.decode('utf-8', errors='ignore') # decode the response
818
+ start_token = response_dict["start_token"][0] # get the start token from the structure dict.
819
+ index = response_str.rfind(start_token) # get the position of start token in the msg - Note search the last start token in he msg
820
+ if index == -1: # if start token no exist
821
+ return response_str, True # return - true means msg no valid - start token not found
822
+ response_str = response_str[index + 1:] # get the response without start token and - delete "%"
823
+ for key in ["operation", "separator_token", "operation_identifier"]: # check if the token exist in the msg (RM and # and 0)
824
+ expected_value, length = response_dict[key] # get the token and the length
825
+ if str(response_str[:length]) != str(expected_value): # exist if exist
826
+ return response_str, True # return the response and True for msg no valid
827
+ response_str = response_str[length:] # delete the found and checked token
828
+ response_str = response_str.replace(response_dict["end_token"][0], "") # delete the end token - normally \r
829
+ if not response_str: # if response is empty
830
+ expected_state = response_dict.get("value")[0] # get expected state
831
+ if expected_state is None or expected_state is False: # if is none or false
832
+ return "", False # no error
833
+ else:
834
+ return response, True # error
835
+ value_prefix, length = response_dict["value_prefix"]
836
+ if response_str[:length] != value_prefix:
837
+ return response, True # error occurred
838
+ return response_str[length:], False # return value, no error
839
+
840
+ @staticmethod
841
+ def calculate_real_timestamp(response: bytes, last_raw_timestamp: int | None, timestamp_rollover_count: int) -> tuple[float, int, int]:
842
+ """
843
+ This method is used to calculate the real timestamp with rollover.
844
+
845
+ Args:
846
+ response (bytes): The stream response
847
+ last_raw_timestamp (int | None): The last raw timestamp
848
+ timestamp_rollover (int): The counter of rollover
849
+
850
+ Return:
851
+ real_timestamp (int): The real calculated timestamp
852
+ last_raw_timestamp (int): The last raw timestamp
853
+ timestamp_rollover (int): The counter of rollover
854
+ """
855
+ raw_timestamp = int.from_bytes(response[0:2], byteorder='big') # 2-byte timestamp max int is 65536 - The device then starts again at 0.
856
+ if last_raw_timestamp is not None and raw_timestamp < last_raw_timestamp: # check if the last timestamp is bigger then the new one
857
+ timestamp_rollover_count += 1 # Rollover detected
858
+ last_raw_timestamp = raw_timestamp # write the new timestamp to the old timestamp
859
+ full_ticks = timestamp_rollover_count * 65536 + raw_timestamp # biggest integer with 16 bit is (2**16 -1) = 65536 - calculate the full tick
860
+ timestamp = (full_ticks * 5) / 1000 # calculate the full time stamp - note 5 is every 5 ms I get a new value
861
+ return timestamp, last_raw_timestamp, timestamp_rollover_count # return
862
+
863
+ @staticmethod
864
+ def check_range(response: bytes, msg_length: int, ranges: list) -> bool | None:
865
+ """
866
+ This method is used to check the range of the values in the stream response.
867
+
868
+ Args:
869
+ response (bytes): The stream response
870
+ msg_length (int): The length of message
871
+ ranges (list): The ranges for all measured values (min & max)
872
+ Return:
873
+ error_occurred (True | None): If True return an error occurred else not
874
+ """
875
+ start = 2 # the first 2 byte are timestamp
876
+ end = msg_length - 1 # the last byte is the check sum
877
+ measured_values = [] # create empty list
878
+ for i in range(start, end, 2): # extract all measured values
879
+ value = int.from_bytes(response[i:i + 2], byteorder='big', signed=True) # get the value
880
+ measured_values.append(value) # append the measured value
881
+ for value, (min_val, max_val) in zip(measured_values, ranges): # get the measured value und the range (max min)
882
+ if not (min_val <= value <= max_val): # if not in range
883
+ return True # the value is not in range
884
+ return None
885
+
886
+ @staticmethod
887
+ def extract_measured_values(response: bytes, msg_length: int, conversion_factors: list[float]):
888
+ """
889
+ This method is used to extract the measured values.
890
+
891
+ Args:
892
+ response (bytes): The stream response
893
+ msg_length (int): The length of message
894
+ conversion_factors (list[float]): The list with all conversion factors
895
+
896
+ Return:
897
+ converted_values (tuple): All measured values as tuple
898
+ """
899
+ start = 2 # the first 2 byte are timestamp
900
+ end = msg_length - 1 # the last byte is the check sum
901
+ measured_values = [] # create empty list
902
+ for i in range(start, end, 2): # extract all measured values
903
+ value = int.from_bytes(response[i:i + 2], byteorder='big', signed=True) # get the value
904
+ measured_values.append(value) # append the measured value
905
+ converted_values = [
906
+ raw / factor for raw, factor in zip(measured_values, conversion_factors)
907
+ ] # calculate all measured values
908
+ return tuple(converted_values)
909
+
910
+ @staticmethod
911
+ def _update_dict(dict: dict, update_dict: dict) -> dict:
912
+ """
913
+ This static method is used to update a dict.
914
+
915
+ Args:
916
+ dict (dict): The dictionary that needs to be updated
917
+ update_dict (dict): The dictionary with the new entries
918
+
919
+ Return:
920
+ dict (dict): The updated dictionary
921
+ """
922
+ for key, value in update_dict.items(): # get all items
923
+ if key in dict: # if the key exist in the dict
924
+ dict[key][0] = value # the dict is updated
925
+ dict[key][1] = None if value is None or isinstance(value, bool) else len(str(value)) # update the length if is bool or none the length is None else the length
926
+ return dict # return dict
927
+
928
+ def check_values_argument(self, dictionary: dict, operation: str, operation_name: str, value: str | None) -> str | None: # value is None or str
929
+ """
930
+ This method is used to check the values of the command.
931
+
932
+ Args:
933
+ dictionary (dict): The dictionary for this operation (CM/RM etc....)
934
+ operation (str): The operation (CM/RM.....)
935
+ operation_name (str): The operation name (pressure_low)
936
+ value (str | None): The value that is set
937
+
938
+ Return:
939
+ value (str | None): The value of response
940
+ """
941
+ command_has_value = message_value_mapping.get(operation, {}).get("command_has_value") # get if the command_has_value
942
+ entry_for_value = dictionary.get(operation_name, {}).get("value") # get the entry for value
943
+ if command_has_value is False and value is None: # Case 1: No value required and none given
944
+ return None # return None
945
+ if command_has_value is True and value is None: # Case 2: Value required, but none given
946
+ self._raise_error(dictionary, operation_name, value) # call the method for raise the error
947
+ if command_has_value is False and value is not None: # Case 3: No value required, but one given
948
+ self._raise_error(dictionary, operation_name, value) # call the method for raise the error
949
+ if command_has_value is None and value is None: # Case 4: Unclear whether value is required, and none given
950
+ if entry_for_value is None: # if no value required
951
+ return None # return None
952
+ self._raise_error(dictionary, operation_name, value) # call the method for raise the error
953
+ if value is not None: # Cases 5 & 6: Value given, check whether checking the range or mapping for translation
954
+ if entry_for_value is True: # if entry_for_value is True
955
+ min_value, max_value = dictionary.get(operation_name, {}).get("range").get("min"), dictionary.get(operation_name, {}).get("range").get("max") # extract min_value, max_value
956
+ try: # try
957
+ if not (min_value <= int(value) <= max_value): # if value if not in range
958
+ self._raise_error(dictionary, operation_name, value) # raise an value error
959
+ except ValueError:
960
+ self._raise_error(dictionary, operation_name, value) # is impossible to convert in int
961
+ return value # return value
962
+ elif entry_for_value is not None: # if entry_for_value is not None
963
+ translation = entry_for_value.get(value) # get the translation
964
+ if translation is None: # if the translation is none
965
+ self._raise_error(dictionary, operation_name, value) # call the method for raise the error
966
+ return translation # return translation
967
+ else:
968
+ self._raise_error(dictionary, operation_name, value) # call the method for raise the error
969
+
970
+ def _raise_error(self, dictionary: dict, operation_name: str, value: str | None) -> None:
971
+ """
972
+ This method is used to generate the raise error with msg
973
+
974
+ Args:
975
+ dictionary (dict): The dictionary for this operation (CM/RM etc....)
976
+ operation_name (str): The operation name (pressure_low)
977
+ value (str | None): The value that is set
978
+ """
979
+ entry_for_value = dictionary.get(operation_name, {}).get("value") # get the entry for the value in the dict
980
+ conversion_factor = dictionary.get(operation_name, None).get("conversion_factor") # get the conversion factor
981
+ if entry_for_value is None: # if entry is none
982
+ valid_values = None # the valid values is none
983
+ elif entry_for_value is True: # if entry is True
984
+ min_value, max_value = dictionary.get(operation_name, {}).get("range").get("min") / conversion_factor, dictionary.get(operation_name, {}).get("range").get("max") / conversion_factor # get max and min value
985
+ valid_values = f"values from {min_value} to {max_value}" # set the valid range
986
+ else:
987
+ valid_values = list(dictionary.get(operation_name, {}).get("value")) # set the list with valid operation_names
988
+ if value is not None and conversion_factor is not None:
989
+ value = value / conversion_factor
990
+ raise ValueError(f"Value is not valid: {value}\nThe following values are valid: {valid_values}") # raise an value error
991
+
992
+ @staticmethod
993
+ def check_operation_name(dictionary: dict, operation_name: str) -> str:
994
+ """
995
+ This method is used to generate the raise error with msg
996
+
997
+ Args:
998
+ dictionary (dict): The dictionary for this operation (CM/RM etc....)
999
+ operation_name (str): The operation name (pressure_low)
1000
+
1001
+ Return:
1002
+ operation_identifier (str): The translated operation name
1003
+ """
1004
+ if (operation_identifier := dictionary.get(operation_name, {}).get("operation_identifier")) is None: # if operation identifier is not found
1005
+ raise ValueError(f"Operation name is not valid: {operation_name}\nThe following operation names are valid {list(dictionary.keys())}") # raise value error
1006
+ return operation_identifier # return the operation identifier
1007
+
1008
+ @staticmethod
1009
+ def validate_and_cast(value, target_types, allowed_types, min_value=None, max_value=None) -> tuple[str | int | float | bool | None]:
1010
+ """
1011
+ This method validates whether the input value can be converted to the expected data type and whether it falls within the allowed range.
1012
+
1013
+ Args:
1014
+ value (str | int | float | bool | None): The value to be checked
1015
+ target_types (str | int | float | bool | None | tuple): The target types
1016
+ allowed_types (tuple |str | int | float | bool | None): The allowed types
1017
+ min_value (int | float | None): The lower limit of the permissible range
1018
+ max_value (int | float | None): The upper limit of the permissible range
1019
+
1020
+ Return:
1021
+ value (str | int | float | bool | None): the converted and verified value
1022
+ """
1023
+ if not isinstance(allowed_types, tuple): # if the input in not a tuple
1024
+ allowed_types = (allowed_types,) # convert to tuple
1025
+ if type(value) not in allowed_types: # if the type of input value is not in the allowed types
1026
+ raise ValueError(f"The input '{value}' is invalid. Please provide data that is convertible to {target_types} in range of {min_value} to {max_value}") # raise an error
1027
+ if type(value) is type(None) or type(value) is bool: # if the type of value is bool or None return it directly
1028
+ return value
1029
+ try: # try
1030
+ if target_types == str: # if the target type is str
1031
+ return str(value) # convert to str and return
1032
+ elif target_types == int: # if the target type is int
1033
+ value_as_float = float(value) # change to float - Note for convert string to float (directly from string to int not possible)
1034
+ if value_as_float.is_integer(): # check if is integer if not pass for raise error (maybe now is 3.4)
1035
+ value_as_int = int(value_as_float) # convert the float to int
1036
+ if max_value is not None and min_value is not None: # if max and min are not none
1037
+ if not (min_value <= value_as_int <= max_value): # if the sample_count is not in range
1038
+ 1 / 0 # for trigger the exception
1039
+ return value_as_int # return value as int
1040
+ elif target_types == float: # convert in float
1041
+ value_as_float = float(value) # convert to float
1042
+ if max_value is not None and min_value is not None: # if are not None
1043
+ if not (min_value <= value_as_float <= max_value): # if the sample_count is not in range
1044
+ 1 / 0 # for trigger the exception
1045
+ return value_as_float # return
1046
+ elif target_types is type(None): # check if is None type
1047
+ return value # return it
1048
+ elif target_types is bool: # check if is bool
1049
+ return value # return the value
1050
+ except Exception: # Exception
1051
+ pass
1052
+ raise ValueError(f"The input '{value}' is invalid. Please provide data that is convertible to {target_types} in range of {min_value} to {max_value}") # raise error
1053
+
1054
+
1055
+ if __name__ == "__main__":
1056
+ pass