Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\sst_luminox.py : 50%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (c) 2020 ETH Zurich, SIS ID and HVL D-ITET
2#
3"""
4Device class for a SST Luminox Oxygen sensor. This device can measure the oxygen
5concentration between 0 % and 25 %.
7Furthermore, it measures the barometric pressure and internal temperature.
8The device supports two operating modes: in streaming mode the device measures all
9parameters every second, in polling mode the device measures only after a query.
11Technical specification and documentation for the device can be found a the
12manufacturer's page:
13https://www.sstsensing.com/product/luminox-optical-oxygen-sensors-2/
14"""
16import logging
17import re
18from enum import Enum
19from time import sleep
20from typing import Union, Tuple, List, cast, Optional, Dict
22from .base import SingleCommDevice
23from ..comm import SerialCommunication, SerialCommunicationConfig
24from ..comm.serial import (
25 SerialCommunicationParity,
26 SerialCommunicationStopbits,
27 SerialCommunicationBytesize,
28)
29from ..configuration import configdataclass
30from ..utils.enum import ValueEnum
31from ..utils.typing import Number
34class LuminoxOutputModeError(Exception):
35 """
36 Wrong output mode for requested data
37 """
39 pass
42class LuminoxOutputMode(Enum):
43 """
44 output mode.
45 """
47 streaming = 0
48 polling = 1
51class LuminoxMeasurementTypeError(Exception):
52 """
53 Wrong measurement type for requested data
54 """
56 pass
59LuminoxMeasurementTypeValue = Union[float, int, str]
60"""A typing hint for all possible LuminoxMeasurementType values as read in either
61streaming mode or in a polling mode with `LuminoxMeasurementType.all_measurements`.
63Beware: has to be manually kept in sync with `LuminoxMeasurementType` instances
64`cast_type` attribute values.
65"""
68LuminoxMeasurementTypeDict = Dict[
69 Union[str, "LuminoxMeasurementType"], LuminoxMeasurementTypeValue
70]
71"""A typing hint for a dictionary holding LuminoxMeasurementType values. Keys are
72allowed as strings because `LuminoxMeasurementType` is of a `StrEnumBase` type.
73"""
76class LuminoxMeasurementType(ValueEnum):
77 """
78 Measurement types for `LuminoxOutputMode.polling`.
80 The `all_measurements` type will read values for the actual measurement types
81 as given in `LuminoxOutputMode.all_measurements_types()`; it parses multiple
82 single values using regexp's for other measurement types, therefore, no regexp is
83 defined for this measurement type.
84 """
86 _init_ = "value cast_type value_re"
87 partial_pressure_o2 = "O", float, r"[0-9]{4}.[0-9]"
88 percent_o2 = "%", float, r"[0-9]{3}.[0-9]{2}"
89 temperature_sensor = "T", float, r"[+-][0-9]{2}.[0-9]"
90 barometric_pressure = "P", int, r"[0-9]{4}"
91 sensor_status = "e", int, r"[0-9]{4}"
92 date_of_manufacture = "# 0", str, r"[0-9]{5} [0-9]{5}"
93 serial_number = "# 1", str, r"[0-9]{5} [0-9]{5}"
94 software_revision = "# 2", str, r"[0-9]{5}"
95 all_measurements = "A", str, None
97 @classmethod
98 def all_measurements_types(cls) -> Tuple["LuminoxMeasurementType", ...]:
99 """
100 A tuple of `LuminoxMeasurementType` enum instances which are actual
101 measurements, i.e. not date of manufacture or software revision.
102 """
103 return cast(
104 Tuple["LuminoxMeasurementType", ...],
105 (
106 cls.partial_pressure_o2,
107 cls.temperature_sensor,
108 cls.barometric_pressure,
109 cls.percent_o2,
110 cls.sensor_status,
111 ),
112 )
114 @property
115 def command(self) -> str:
116 return self.value.split(" ")[0]
118 def parse_read_measurement_value(
119 self, read_txt: str
120 ) -> Union[LuminoxMeasurementTypeDict, LuminoxMeasurementTypeValue]:
121 if self is LuminoxMeasurementType.all_measurements:
122 return {
123 measurement: measurement._parse_single_measurement_value(read_txt)
124 for measurement in LuminoxMeasurementType.all_measurements_types()
125 }
126 return self._parse_single_measurement_value(read_txt)
128 def _parse_single_measurement_value(
129 self, read_txt: str
130 ) -> LuminoxMeasurementTypeValue:
132 parsed_data: List[str] = re.findall(f"{self.command} {self.value_re}", read_txt)
133 if len(parsed_data) != 1:
134 self._parse_error(parsed_data)
136 parsed_measurement: str = parsed_data[0]
137 try:
138 parsed_value = self.cast_type(
139 # don't check for empty match - we know already that there is one
140 re.search(self.value_re, parsed_measurement).group() # type: ignore
141 )
142 except ValueError:
143 self._parse_error(parsed_data)
145 return parsed_value
147 def _parse_error(self, parsed_data: List[str]) -> None:
148 err_msg = (
149 f"Expected measurement value for {self.name.replace('_', ' ')} of type "
150 f'{self.cast_type}; instead tyring to parse: "{parsed_data}"'
151 )
152 logging.error(err_msg)
153 raise LuminoxMeasurementTypeError(err_msg)
156@configdataclass
157class LuminoxSerialCommunicationConfig(SerialCommunicationConfig):
158 #: Baudrate for SST Luminox is 9600 baud
159 baudrate: int = 9600
161 #: SST Luminox does not use parity
162 parity: Union[str, SerialCommunicationParity] = SerialCommunicationParity.NONE
164 #: SST Luminox does use one stop bit
165 stopbits: Union[int, SerialCommunicationStopbits] = SerialCommunicationStopbits.ONE
167 #: One byte is eight bits long
168 bytesize: Union[
169 int, SerialCommunicationBytesize
170 ] = SerialCommunicationBytesize.EIGHTBITS
172 #: The terminator is CR LF
173 terminator: bytes = b"\r\n"
175 #: use 3 seconds timeout as default
176 timeout: Number = 3
179class LuminoxSerialCommunication(SerialCommunication):
180 """
181 Specific communication protocol implementation for the SST Luminox oxygen sensor.
182 Already predefines device-specific protocol parameters in config.
183 """
185 @staticmethod
186 def config_cls():
187 return LuminoxSerialCommunicationConfig
190@configdataclass
191class LuminoxConfig:
192 """
193 Configuration for the SST Luminox oxygen sensor.
194 """
196 # wait between set and validation of output mode
197 wait_sec_post_activate: Number = 0.5
198 wait_sec_trials_activate: Number = 0.1
199 nr_trials_activate: int = 5
201 def clean_values(self):
202 if self.wait_sec_post_activate <= 0:
203 raise ValueError(
204 "Wait time (sec) post output mode activation must be a positive number."
205 )
206 if self.wait_sec_trials_activate <= 0:
207 raise ValueError(
208 "Re-try wait time (sec) for mode activation must be a positive number."
209 )
210 if self.nr_trials_activate <= 0:
211 raise ValueError(
212 "Trials for mode activation must be a positive integer >=1)."
213 )
216class Luminox(SingleCommDevice):
217 """
218 Luminox oxygen sensor device class.
219 """
221 def __init__(self, com, dev_config=None):
223 # Call superclass constructor
224 super().__init__(com, dev_config)
225 self.output: Optional[LuminoxOutputMode] = None
227 @staticmethod
228 def config_cls():
229 return LuminoxConfig
231 @staticmethod
232 def default_com_cls():
233 return LuminoxSerialCommunication
235 def start(self) -> None:
236 """
237 Start this device. Opens the communication protocol.
238 """
240 logging.info(f"Starting device {self}")
241 super().start()
243 def stop(self) -> None:
244 """
245 Stop the device. Closes also the communication protocol.
246 """
248 logging.info(f"Stopping device {self}")
249 super().stop()
251 def _write(self, value: str) -> None:
252 """
253 Write given `value` string to `self.com`.
255 :param value: String value to send.
256 :raises SerialCommunicationIOError: when communication port is not opened
257 """
259 self.com.write_text(value)
261 def _read(self) -> str:
262 """
263 Read a string value from `self.com`.
265 :return: Read text from the serial port, without the trailing terminator,
266 as defined in the communcation protocol configuration.
267 :raises SerialCommunicationIOError: when communication port is not opened
268 """
269 return self.com.read_text().rstrip(self.com.config.terminator_str())
271 def activate_output(self, mode: LuminoxOutputMode) -> None:
272 """
273 activate the selected output mode of the Luminox Sensor.
274 :param mode: polling or streaming
275 """
276 with self.com.access_lock:
277 self._write(f"M {mode.value}")
278 # needs a little bit of time ot activate
279 sleep(self.config.wait_sec_post_activate)
281 for trial in range(self.config.nr_trials_activate + 1):
282 msg = self._read()
283 if (
284 not msg == f"M 0{mode.value}"
285 and trial == self.config.nr_trials_activate
286 ):
287 err_msg = (
288 f"Stream mode activation was not possible "
289 f"after {self.config.nr_trials_activate} trials {self}"
290 )
291 logging.error(err_msg)
292 raise LuminoxOutputModeError(err_msg)
293 if msg == f"M 0{mode.value}":
294 msg = (
295 f"Stream mode activation possible "
296 f"in trial {trial} out of {self.config.nr_trials_activate}"
297 )
298 logging.info(msg)
299 break
300 sleep(self.config.wait_sec_trials_activate)
302 self.output = mode
303 logging.info(f"{mode.name} mode activated {self}")
305 def read_streaming(self) -> LuminoxMeasurementTypeDict:
306 """
307 Read values of Luminox in the streaming mode. Convert the single string
308 into separate values.
310 :return: dictionary with `LuminoxMeasurementType.all_measurements_types()` keys
311 and accordingly type-parsed values.
312 :raises LuminoxOutputModeError: when streaming mode is not activated
313 :raises LuminoxMeasurementTypeError: when any of expected measurement values is
314 not read
315 """
316 if not self.output == LuminoxOutputMode.streaming:
317 err_msg = f"Streaming mode not activated {self}"
318 logging.error(err_msg)
319 raise LuminoxOutputModeError(err_msg)
321 read_txt = self._read()
322 return cast(
323 LuminoxMeasurementTypeDict,
324 cast(
325 LuminoxMeasurementType, LuminoxMeasurementType.all_measurements
326 ).parse_read_measurement_value(read_txt),
327 )
329 def query_polling(
330 self,
331 measurement: Union[str, LuminoxMeasurementType],
332 ) -> Union[LuminoxMeasurementTypeDict, LuminoxMeasurementTypeValue]:
333 """
334 Query a value or values of Luminox measurements in the polling mode,
335 according to a given measurement type.
337 :param measurement: type of measurement
338 :return: value of requested measurement
339 :raises ValueError: when a wrong key for LuminoxMeasurementType is provided
340 :raises LuminoxOutputModeError: when polling mode is not activated
341 :raises LuminoxMeasurementTypeError: when expected measurement value is not read
342 """
343 if not isinstance(measurement, LuminoxMeasurementType):
344 try:
345 measurement = cast(
346 LuminoxMeasurementType,
347 LuminoxMeasurementType[measurement], # type: ignore
348 )
349 except KeyError:
350 measurement = cast(
351 LuminoxMeasurementType,
352 LuminoxMeasurementType(measurement),
353 )
355 if not self.output == LuminoxOutputMode.polling:
356 err_msg = f"Polling mode not activated {self}"
357 logging.error(err_msg)
358 raise LuminoxOutputModeError(err_msg)
360 with self.com.access_lock:
361 self._write(str(measurement))
362 read_txt = self._read()
363 read_value = measurement.parse_read_measurement_value(read_txt)
364 return read_value