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

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

## 121 statements

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"""

4 Communication protocol for serial ports. Makes use of the `pySerial

5<https://pythonhosted.org/pyserial/index.html>`_ library.

6"""

7

8 from time import sleep

9 from typing import Optional, Union, cast

10

11# Note: PyCharm does not recognize the dependency correctly, it is added as pyserial.

12 import serial

13

14 from .base import CommunicationProtocol

15 from ..configuration import configdataclass

16 from ..utils.enum import ValueEnum, unique

17 from ..utils.typing import Number

18

19

20 class SerialCommunicationIOError(IOError):

21 """Serial communication related I/O errors."""

22

23

24@unique

25 class SerialCommunicationParity(ValueEnum):

26 """

27 Serial communication parity.

28 """

29

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

36

37

38@unique

39 class SerialCommunicationStopbits(ValueEnum):

40 """

41 Serial communication stopbits.

42 """

43

44 ONE = serial.STOPBITS_ONE

45 ONE_POINT_FIVE = serial.STOPBITS_ONE_POINT_FIVE

46 TWO = serial.STOPBITS_TWO

47

48

49@unique

50 class SerialCommunicationBytesize(ValueEnum):

51 """

52 Serial communication bytesize.

53 """

54

55 FIVEBITS = serial.FIVEBITS

56 SIXBITS = serial.SIXBITS

57 SEVENBITS = serial.SEVENBITS

58 EIGHTBITS = serial.EIGHTBITS

59

60

61@configdataclass

62 class SerialCommunicationConfig:

63 """

64 Configuration dataclass for :class:`SerialCommunication`.

65 """

66

67 Parity = SerialCommunicationParity

68 Stopbits = SerialCommunicationStopbits

69 Bytesize = SerialCommunicationBytesize

70

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

75

76 #: Baudrate of the serial port

77 baudrate: int

78

79 #: Parity to be used for the connection.

80 parity: Union[str, SerialCommunicationParity]

81

82 #: Stopbits setting, can be 1, 1.5 or 2.

83 stopbits: Union[Number, SerialCommunicationStopbits]

84

85 #: Size of a byte, 5 to 8

86 bytesize: Union[int, SerialCommunicationBytesize]

87

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"

91

92 #: Timeout in seconds for the serial port

93 timeout: Number = 2

94

95 #: time to wait between attempts of reading a non-empty text

96 wait_sec_read_text_nonempty: Number = 0.5

97

98 #: default number of attempts to read a non-empty text

99 default_n_attempts_read_text_nonempty: int = 10

100

101 def clean_values(self):

102 if not isinstance(self.parity, SerialCommunicationParity):

103 self.force_value("parity", SerialCommunicationParity(self.parity))

104

105 if not isinstance(self.stopbits, SerialCommunicationStopbits):

106 self.force_value("stopbits", SerialCommunicationStopbits(self.stopbits))

107

108 if not isinstance(self.bytesize, SerialCommunicationBytesize):

109 self.force_value("bytesize", SerialCommunicationBytesize(self.bytesize))

110

111 if self.timeout < 0:

112 raise ValueError("Timeout has to be >= 0.")

113

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 )

119

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 )

125

126 def create_serial_port(self) -> serial.Serial:

127 """

128 Create a serial port instance according to specification in this configuration

129

130 :return: Closed serial port instance

131 """

132

133 ser = serial.serial_for_url(self.port, do_not_open=True)

134 assert not ser.is_open

135

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

141

142 return ser

143

144 def terminator_str(self) -> str:

145 return self.terminator.decode()

146

147

148 class SerialCommunication(CommunicationProtocol):

149 """

150 Implements the Communication Protocol for serial ports.

151 """

152

153 ENCODING = "utf-8"

154 UNICODE_HANDLING = "replace"

155

156 def __init__(self, configuration):

157 """

158 Constructor for SerialCommunication.

159 """

160

161 super().__init__(configuration)

162

163 self._serial_port = self.config.create_serial_port()

164

165 @staticmethod

166 def config_cls():

167 return SerialCommunicationConfig

168

169 def open(self):

170 """

171 Open the serial connection.

172

173 :raises SerialCommunicationIOError: when communication port cannot be opened.

174 """

175

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

184

185 def close(self):

186 """

187 Close the serial connection.

188 """

189

190 # close the port

191 with self.access_lock:

192 self._serial_port.close()

193

194 @property

195 def is_open(self) -> bool:

196 """

197 Flag indicating if the serial port is open.

198

199 :return: `True` if the serial port is open, otherwise `False`

200 """

201 return self._serial_port.is_open

202

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.

207

208 This method uses `self.access_lock` to ensure thread-safety.

209

210 :param text: Text to send to the port.

211 :raises SerialCommunicationIOError: when communication port is not opened

212 """

213

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

222

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.

227

228 This method uses `self.access_lock` to ensure thread-safety.

229

230 :return: String read from the serial port; `''` if there was nothing to read.

231 :raises SerialCommunicationIOError: when communication port is not opened

232 """

233

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

239

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.

244

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).

248

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

263

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.

268

269 This method uses `self.access_lock` to ensure thread-safety.

270

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 """

275

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

281

282 def write_bytes(self, data: bytes) -> int:

283 """

284 Write bytes to the serial port.

285

286 This method uses `self.access_lock` to ensure thread-safety.

287

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 """

292

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