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