Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\comm\serial.py : 81%

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) 2019-2020 ETH Zurich, SIS ID and HVL D-ITET
2#
3"""
4Communication protocol for serial ports. Makes use of the `pySerial
5<https://pythonhosted.org/pyserial/index.html>`_ library.
6"""
8from time import sleep
9from typing import Optional, Union, cast
11# Note: PyCharm does not recognize the dependency correctly, it is added as pyserial.
12import serial
14from .base import CommunicationProtocol
15from ..configuration import configdataclass
16from ..utils.enum import ValueEnum, unique
17from ..utils.typing import Number
20class SerialCommunicationIOError(IOError):
21 """Serial communication related I/O errors."""
24@unique
25class SerialCommunicationParity(ValueEnum):
26 """
27 Serial communication parity.
28 """
30 EVEN = serial.PARITY_EVEN
31 MARK = serial.PARITY_MARK
32 NAMES = serial.PARITY_NAMES
33 NONE = serial.PARITY_NONE
34 ODD = serial.PARITY_ODD
35 SPACE = serial.PARITY_SPACE
38@unique
39class SerialCommunicationStopbits(ValueEnum):
40 """
41 Serial communication stopbits.
42 """
44 ONE = serial.STOPBITS_ONE
45 ONE_POINT_FIVE = serial.STOPBITS_ONE_POINT_FIVE
46 TWO = serial.STOPBITS_TWO
49@unique
50class SerialCommunicationBytesize(ValueEnum):
51 """
52 Serial communication bytesize.
53 """
55 FIVEBITS = serial.FIVEBITS
56 SIXBITS = serial.SIXBITS
57 SEVENBITS = serial.SEVENBITS
58 EIGHTBITS = serial.EIGHTBITS
61@configdataclass
62class SerialCommunicationConfig:
63 """
64 Configuration dataclass for :class:`SerialCommunication`.
65 """
67 Parity = SerialCommunicationParity
68 Stopbits = SerialCommunicationStopbits
69 Bytesize = SerialCommunicationBytesize
71 #: Port is a string referring to a COM-port (e.g. ``'COM3'``) or a URL.
72 #: The full list of capabilities is found `on the pyserial documentation
73 #: <https://pythonhosted.org/pyserial/url_handlers.html>`_.
74 port: str
76 #: Baudrate of the serial port
77 baudrate: int
79 #: Parity to be used for the connection.
80 parity: Union[str, SerialCommunicationParity]
82 #: Stopbits setting, can be 1, 1.5 or 2.
83 stopbits: Union[Number, SerialCommunicationStopbits]
85 #: Size of a byte, 5 to 8
86 bytesize: Union[int, SerialCommunicationBytesize]
88 #: The terminator character. Typically this is ``b'\r\n'`` or ``b'\n'``, but can
89 #: also be ``b'\r'`` or other combinations.
90 terminator: bytes = b"\r\n"
92 #: Timeout in seconds for the serial port
93 timeout: Number = 2
95 #: time to wait between attempts of reading a non-empty text
96 wait_sec_read_text_nonempty: Number = 0.5
98 #: default number of attempts to read a non-empty text
99 default_n_attempts_read_text_nonempty: int = 10
101 def clean_values(self):
102 if not isinstance(self.parity, SerialCommunicationParity):
103 self.force_value("parity", SerialCommunicationParity(self.parity))
105 if not isinstance(self.stopbits, SerialCommunicationStopbits):
106 self.force_value("stopbits", SerialCommunicationStopbits(self.stopbits))
108 if not isinstance(self.bytesize, SerialCommunicationBytesize):
109 self.force_value("bytesize", SerialCommunicationBytesize(self.bytesize))
111 if self.timeout < 0:
112 raise ValueError("Timeout has to be >= 0.")
114 if self.wait_sec_read_text_nonempty <= 0:
115 raise ValueError(
116 "Wait time between attempts to read a non-empty text must be be a "
117 "positive value (in seconds)."
118 )
120 if self.default_n_attempts_read_text_nonempty <= 0:
121 raise ValueError(
122 "Default number of attempts of reaading a non-empty text must be a "
123 "positive integer."
124 )
126 def create_serial_port(self) -> serial.Serial:
127 """
128 Create a serial port instance according to specification in this configuration
130 :return: Closed serial port instance
131 """
133 ser = serial.serial_for_url(self.port, do_not_open=True)
134 assert not ser.is_open
136 ser.baudrate = self.baudrate
137 ser.parity = cast(SerialCommunicationParity, self.parity).value
138 ser.stopbits = cast(SerialCommunicationStopbits, self.stopbits).value
139 ser.bytesize = cast(SerialCommunicationBytesize, self.bytesize).value
140 ser.timeout = self.timeout
142 return ser
144 def terminator_str(self) -> str:
145 return self.terminator.decode()
148class SerialCommunication(CommunicationProtocol):
149 """
150 Implements the Communication Protocol for serial ports.
151 """
153 ENCODING = "utf-8"
154 UNICODE_HANDLING = "replace"
156 def __init__(self, configuration):
157 """
158 Constructor for SerialCommunication.
159 """
161 super().__init__(configuration)
163 self._serial_port = self.config.create_serial_port()
165 @staticmethod
166 def config_cls():
167 return SerialCommunicationConfig
169 def open(self):
170 """
171 Open the serial connection.
173 :raises SerialCommunicationIOError: when communication port cannot be opened.
174 """
176 # open the port
177 with self.access_lock:
178 try:
179 self._serial_port.open()
180 except serial.SerialException as exc:
181 # ignore when port is already open
182 if str(exc) != "Port is already open.":
183 raise SerialCommunicationIOError from exc
185 def close(self):
186 """
187 Close the serial connection.
188 """
190 # close the port
191 with self.access_lock:
192 self._serial_port.close()
194 @property
195 def is_open(self) -> bool:
196 """
197 Flag indicating if the serial port is open.
199 :return: `True` if the serial port is open, otherwise `False`
200 """
201 return self._serial_port.is_open
203 def write_text(self, text: str):
204 """
205 Write text to the serial port. The text is encoded and terminated by
206 the configured terminator.
208 This method uses `self.access_lock` to ensure thread-safety.
210 :param text: Text to send to the port.
211 :raises SerialCommunicationIOError: when communication port is not opened
212 """
214 with self.access_lock:
215 try:
216 self._serial_port.write(
217 text.encode(self.ENCODING, self.UNICODE_HANDLING)
218 + self.config.terminator
219 )
220 except serial.SerialException as exc:
221 raise SerialCommunicationIOError from exc
223 def read_text(self) -> str:
224 """
225 Read one line of text from the serial port. The input buffer may
226 hold additional data afterwards, since only one line is read.
228 This method uses `self.access_lock` to ensure thread-safety.
230 :return: String read from the serial port; `''` if there was nothing to read.
231 :raises SerialCommunicationIOError: when communication port is not opened
232 """
234 with self.access_lock:
235 try:
236 return self._serial_port.readline().decode(self.ENCODING)
237 except serial.SerialException as exc:
238 raise SerialCommunicationIOError from exc
240 def read_text_nonempty(self, n_attempts_max: Optional[int] = None) -> str:
241 """
242 Reads from the serial port, until a non-empty line is found, or the number of
243 attempts is exceeded.
245 Attention: in contrast to `read_text`, the returned answer will be stripped of
246 a whitespace newline terminator at the end, if such terminator is set in
247 the initial configuration (default).
249 :param n_attempts_max: maximum number of read attempts
250 :return: String read from the serial port; `''` if number of attempts is
251 exceeded or serial port is not opened.
252 """
253 answer = self.read_text().strip()
254 n_attempts_left = (
255 n_attempts_max or self.config.default_n_attempts_read_text_nonempty
256 ) if self.is_open else 0
257 attempt_interval_sec = self.config.wait_sec_read_text_nonempty
258 while len(answer) == 0 and n_attempts_left > 0:
259 sleep(attempt_interval_sec)
260 answer = self.read_text().strip()
261 n_attempts_left -= 1
262 return answer
264 def read_bytes(self, size: int = 1) -> bytes:
265 """
266 Read the specified number of bytes from the serial port.
267 The input buffer may hold additional data afterwards.
269 This method uses `self.access_lock` to ensure thread-safety.
271 :param size: number of bytes to read
272 :return: Bytes read from the serial port; `b''` if there was nothing to read.
273 :raises SerialCommunicationIOError: when communication port is not opened
274 """
276 with self.access_lock:
277 try:
278 return self._serial_port.read(size)
279 except serial.SerialException as exc:
280 raise SerialCommunicationIOError from exc
282 def write_bytes(self, data: bytes) -> int:
283 """
284 Write bytes to the serial port.
286 This method uses `self.access_lock` to ensure thread-safety.
288 :param data: data to write to the serial port
289 :return: number of bytes written
290 :raises SerialCommunicationIOError: when communication port is not opened
291 """
293 with self.access_lock:
294 try:
295 return self._serial_port.write(data)
296 except serial.SerialException as exc:
297 raise SerialCommunicationIOError from exc