Hide keyboard shortcuts

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

7 

8from time import sleep 

9from typing import Optional, Union, cast 

10 

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

12import serial 

13 

14from .base import CommunicationProtocol 

15from ..configuration import configdataclass 

16from ..utils.enum import ValueEnum, unique 

17from ..utils.typing import Number 

18 

19 

20class SerialCommunicationIOError(IOError): 

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

22 

23 

24@unique 

25class 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 

39class 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 

50class 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 

62class 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 

148class 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