Coverage for apio/utils/serial_util.py: 82%

100 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 05:08 +0000

1# This file is part of the Apio project 

2# (C) 2016-present FPGAwars 

3# Project authors: 

4# * Jesús Arroyo (Jesus89) (2016-2019) 

5# * Juan Gonzalez (obijuan) (2019-2024) 

6# * The Apio Team (2024-present) 

7# License: GPLv2 

8 

9"""Serial devices related utilities.""" 

10 

11import re 

12from dataclasses import dataclass 

13from serial.tools.list_ports import comports 

14from serial.tools.list_ports_common import ListPortInfo 

15from apio.common.debug_util import is_debug 

16from apio.common.apio_console import cout 

17from apio.utils import usb_util 

18from apio.apio_context import ApioContext 

19 

20 

21@dataclass() 

22class SerialDevice: 

23 """A data class to hold the information of a single Serial device.""" 

24 

25 # pylint: disable=too-many-instance-attributes 

26 

27 port: str 

28 port_name: str 

29 vid: str 

30 pid: str 

31 manufacturer: str 

32 product: str 

33 serial_number: str 

34 device_type: str 

35 location: str 

36 

37 def __post_init__(self): 

38 """Check that vid, pid, has the format %04X.""" 

39 usb_util.check_usb_id_format(self.vid) 

40 usb_util.check_usb_id_format(self.pid) 

41 

42 def summary(self) -> str: 

43 """Returns a user friendly short description of this device.""" 

44 return ( 

45 f"[{self.port}] " 

46 f"[{self.vid}:{self.pid}] " 

47 f"[{self.manufacturer}] [{self.product}] " 

48 f"[{self.serial_number}]" 

49 ) 

50 

51 

52def scan_serial_devices(_: ApioContext) -> list[SerialDevice]: 

53 """Scan the connected serial devices and return their information.""" 

54 

55 # -- Initial empty device list 

56 devices = [] 

57 

58 # -- Use the serial.tools.list_ports module for reading the 

59 # -- serial ports. More info: 

60 # -- https://pyserial.readthedocs.io/en/latest/tools.html 

61 list_port_info: list[ListPortInfo] = list(comports()) 

62 assert isinstance(list_port_info, list) 

63 if list_port_info: 63 ↛ 67line 63 didn't jump to line 67 because the condition on line 63 was always true

64 assert isinstance(list_port_info[0], ListPortInfo) 

65 

66 # -- Collect the items that are USB serial ports. 

67 for port in list_port_info: 

68 

69 # -- Dump for debugging. 

70 if is_debug(1): 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 cout("Raw serial port:") 

72 cout(f" Device: {port.device}") 

73 cout(f" Hwid: {port.hwid}") 

74 cout(f" Interface: {port.interface}") 

75 

76 # # -- Skip if not a serial port. 

77 if not port.device: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 continue 

79 

80 # -- Skip if not a USB device. 

81 if not port.vid or not port.pid: 81 ↛ 89line 81 didn't jump to line 89 because the condition on line 81 was always true

82 continue 

83 

84 # -- If needed, append A or B to the serial numbers of a dual channel 

85 # -- FTDI devices such as FT2232H. 

86 # -- 

87 # -- Ideally these should be done in the comports() function of 

88 # -- the pyserial library. 

89 serial_number = port.serial_number or "" 

90 if serial_number and port.device.endswith(f"-{serial_number}0"): 

91 serial_number += "A" 

92 elif serial_number and port.device.endswith(f"-{serial_number}1"): 

93 serial_number += "B" 

94 

95 # -- Add device to list. 

96 devices.append( 

97 SerialDevice( 

98 port=port.device, 

99 port_name=port.name, 

100 vid=f"{port.vid:04X}", 

101 pid=f"{port.pid:04X}", 

102 manufacturer=port.manufacturer or "", 

103 product=port.product or "", 

104 serial_number=serial_number, 

105 device_type=usb_util.get_device_type(port.vid, port.pid), 

106 location=port.location or "", 

107 ) 

108 ) 

109 

110 # -- Sort by port name, case insensitive. 

111 devices = sorted(devices, key=lambda d: d.port.lower()) 

112 

113 if is_debug(1): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 cout(f"Found {len(devices)} serial device:") 

115 for device in devices: 

116 cout(str(device)) 

117 

118 # -- All done. 

119 return devices 

120 

121 

122@dataclass 

123class SerialDeviceFilter: 

124 """A class to filter a list of serial devices by attributes. We use the 

125 Fluent Interface design pattern so we can assert that the values that 

126 the caller passes as filters are not unintentionally None or empty 

127 unintentionally.""" 

128 

129 _vid: str | None = None 

130 _pid: str | None = None 

131 _product_regex: str | None = None 

132 _serial_port: str | None = None 

133 _serial_num: str | None = None 

134 

135 def summary(self) -> str: 

136 """User friendly representation of the filter""" 

137 terms = [] 

138 if self._vid: 

139 terms.append(f"VID={self._vid}") 

140 if self._pid: 

141 terms.append(f"PID={self._pid}") 

142 if self._product_regex: 

143 terms.append(f'REGEX="{self._product_regex}"') 

144 if self._serial_port: 

145 terms.append(f"PORT={self._serial_port}") 

146 if self._serial_num: 

147 terms.append(f'S/N="{self._serial_num}"') 

148 if terms: 

149 return "[" + ", ".join(terms) + "]" 

150 return "[all]" 

151 

152 def set_vid(self, vid: str) -> "SerialDeviceFilter": 

153 """Pass only devices with given vendor id.""" 

154 usb_util.check_usb_id_format(vid) 

155 self._vid = vid 

156 return self 

157 

158 def set_pid(self, pid: str) -> "SerialDeviceFilter": 

159 """Pass only devices given product id.""" 

160 usb_util.check_usb_id_format(pid) 

161 self._pid = pid 

162 return self 

163 

164 def set_product_regex(self, product_regex: str) -> "SerialDeviceFilter": 

165 """Pass only devices whose product string match given regex.""" 

166 assert product_regex 

167 self._product_regex = product_regex 

168 return self 

169 

170 def set_port(self, serial_port: str) -> "SerialDeviceFilter": 

171 """Pass only devices given product serial port..""" 

172 assert serial_port 

173 self._serial_port = serial_port 

174 return self 

175 

176 def set_serial_num(self, serial_num: str) -> "SerialDeviceFilter": 

177 """Pass only devices given product serial number..""" 

178 assert serial_num 

179 self._serial_num = serial_num 

180 return self 

181 

182 def _eval(self, device: SerialDevice) -> bool: 

183 """Test if the devices passes this field.""" 

184 if (self._vid is not None) and (self._vid != device.vid): 

185 return False 

186 

187 if (self._pid is not None) and (self._pid != device.pid): 

188 return False 

189 

190 if (self._product_regex is not None) and not re.search( 

191 self._product_regex, device.product 

192 ): 

193 return False 

194 

195 # -- We allow matching by full port string or by port name. 

196 if (self._serial_port is not None) and ( 

197 ( 

198 self._serial_port.lower() 

199 not in [device.port.lower(), device.port_name.lower()] 

200 ) 

201 ): 

202 return False 

203 

204 if (self._serial_num is not None) and ( 

205 self._serial_num.lower() != device.serial_number.lower() 

206 ): 

207 return False 

208 

209 return True 

210 

211 def filter(self, devices: list[SerialDevice]): 

212 """Return a copy of the list with items that are pass this filter. 

213 Items order is preserved.""" 

214 result = [d for d in devices if self._eval(d)] 

215 return result