Coverage for apio/managers/drivers.py: 50%

169 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"""Manage board drivers""" 

10 

11import getpass 

12import os 

13import shlex 

14import shutil 

15import subprocess 

16from pathlib import Path 

17from apio.utils import util 

18from apio.common.apio_console import cout, cerror, cmarkdown, fatal_error 

19from apio.common.apio_styles import INFO, SUCCESS, EMPH1, EMPH3 

20from apio.apio_context import ApioContext 

21 

22# -- Style shortcuts 

23E1 = f"[{EMPH1}]" 

24E3 = f"[{EMPH3}]" 

25 

26# -- A message to print when trying to install/uninstall apio drivers 

27# -- on platforms that don't require it. 

28NO_DRIVERS_MSG = "No driver installation is required on this platform." 

29 

30# -- Text in the rich-text format of the python rich library. 

31FTDI_INSTALL_INSTRUCTIONS_WINDOWS = f""" 

32{E3}Please follow these steps:[/] 

33 

34 1. Make sure your {E1}FPGA board is connected[/] to the computer. 

35 

36 2. {E1}Accept the Zadig request[/] to make changes to your computer. 

37 

38 3. {E1}Find the Zadig window[/] on your screen. You may need to click 

39 on its icon in the task bar for it to appear. 

40 

41 4. {E1}Select your FPGA board[/] from the drop down list, For example 

42 'Alhambra II v1.0A - B09-335 (Interface 0)'. 

43 

44 {E3}VERY IMPORTANT - If your board appears multiple time, make sure 

45 to select its 'interface 0' entry.[/] 

46 

47 5. {E1}Select the 'WinUSB' driver[/] as the target driver. For example 

48 'WinUSB (v6.1.7600.16385)'. 

49 

50 6. {E1}Click 'Replace Driver'[/] and wait for a successful 

51 completion, this can take a minute or two. 

52 

53 7. {E1}Close the Zadig window.[/] 

54 

55 8. {E1}Disconnect and reconnect[/] your FPGA board for the new driver 

56 to take affect. 

57 

58 9. {E1}Run the command 'apio devices scan-usb'[/] and verify that 

59 your board is listed. 

60""" 

61 

62# -- Text in the rich-text format of the python rich library. 

63FTDI_UNINSTALL_INSTRUCTIONS_WINDOWS = f""" 

64{E3}Please follow these steps:[/] 

65 

66 1. Make sure your FPGA {E1}board is NOT connected[/] to the computer. 

67 

68 2. If asked, {E1}allow the Device Manager to make changes to your system.[/] 

69 

70 3. {E1}Find the Device Manager window.[/] 

71 

72 4. {E1}Connect the board[/] to your computer and a new entry will be added 

73 to the device list (though sometimes it may be collapsed and 

74 hidden). 

75 

76 5. {E1}Identify the entry of your board[/] (e.g. in the 'Universal Serial 

77 Bus Devices' section). 

78 

79 {E3}NOTE: Boards with FT2232 ICs have two channels, 'interface 0' 

80 and 'interface 1'. Here we care only about 'interface 0' and 

81 ignore 'interface 1' if it appears as a COM port.[/] 

82 

83 6. {E1}Right click[/] on your board entry and \ 

84{E1}select 'Uninstall device'.[/] 

85 

86 7. If available, check the box {E1}'Delete the driver software for this 

87 device'.[/] 

88 

89 8. Click the {E1}'Uninstall' button[/]. 

90 

91 9. {E1}Close[/] the Device Manager window. 

92""" 

93 

94# -- Text in the rich-text format of the python rich library. 

95SERIAL_INSTALL_INSTRUCTIONS_WINDOWS = f""" 

96{E3}Please follow these steps:[/] 

97 

98 1. Make sure your FPGA {E1}board is connected[/] to the computer. 

99 

100 2. {E1}Accept the Serial Installer request[/] to make changes to your \ 

101computer. 

102 

103 3. Find the Serial installer window and {E1}follow the instructions.[/] 

104 

105 4. To verify, {E1}disconnect and reconnect the board[/] and run the command 

106 {E1}'apio devices scan-serial'.[/] 

107""" 

108 

109# -- Text in the rich-text format of the python rich library. 

110SERIAL_UNINSTALL_INSTRUCTIONS_WINDOWS = f""" 

111{E3}Please follow these steps:[/] 

112 

113 1. Make sure your FPGA {E1}board is NOT connected[/] to the computer. 

114 

115 2. If asked, {E1}allow the Device Manager to make changes[/] to your system. 

116 

117 3. {E1}Find the Device Manager window.[/] 

118 

119 4. {E1}Connect the board[/] to your computer and a new entry will be added 

120 to the device list (though sometimes it may be collapsed). 

121 

122 5. {E1}Identify the entry of your board[/] (typically in the Ports section). 

123 

124 {E3} NOTE: If your board does not show up as a COM port, it may not 

125 have the 'apio drivers --serial-install' applied to it.[/] 

126 

127 6. {E1}Right click[/] on your board entry \ 

128and {E1}select 'Uninstall device'.[/] 

129 

130 7. If available, check the box \ 

131{E1}'Delete the driver software for this device'.[/] 

132 

133 8. Click the {E1}'Uninstall' button.[/] 

134 

135 9. {E1}Close the Device Manager window.[/] 

136""" 

137 

138 

139class Drivers: 

140 """Class for managing the board drivers""" 

141 

142 # -- The driver installation on linux consist of copying the rule files 

143 # -- to the /etc/udev/rules.d folder 

144 

145 # -- FTDI source rules file paths 

146 resources_dir = util.get_path_in_apio_package("resources") 

147 ftdi_rules_local_path = resources_dir / "80-fpga-ftdi.rules" 

148 

149 # -- Target rule file 

150 ftdi_rules_system_path = Path("/etc/udev/rules.d/80-fpga-ftdi.rules") 

151 

152 # Serial rules files paths 

153 serial_rules_local_path = resources_dir / "80-fpga-serial.rules" 

154 serial_rules_system_path = Path("/etc/udev/rules.d/80-fpga-serial.rules") 

155 

156 # Driver to restore: mac os 

157 driver_c = "" 

158 

159 def __init__(self, apio_ctx: ApioContext) -> None: 

160 

161 self.apio_ctx = apio_ctx 

162 

163 def ftdi_install(self): 

164 """Installs the FTDI driver. Function is platform dependent.""" 

165 

166 if self.apio_ctx.is_linux: 166 ↛ 168line 166 didn't jump to line 168 because the condition on line 166 was always true

167 self._ftdi_install_linux() 

168 elif self.apio_ctx.is_darwin: 

169 self._ftdi_install_darwin() 

170 elif self.apio_ctx.is_windows: 

171 self._ftdi_install_windows() 

172 else: 

173 fatal_error( 

174 f"Unexpected platform type '{self.apio_ctx.platform.id}'." 

175 ) 

176 

177 def ftdi_uninstall(self): 

178 """Uninstalls the FTDI driver. Function is platform dependent.""" 

179 if self.apio_ctx.is_linux: 179 ↛ 181line 179 didn't jump to line 181 because the condition on line 179 was always true

180 self._ftdi_uninstall_linux() 

181 elif self.apio_ctx.is_darwin: 

182 self._ftdi_uninstall_darwin() 

183 elif self.apio_ctx.is_windows: 

184 self._ftdi_uninstall_windows() 

185 else: 

186 fatal_error(f"Unexpected platform '{self.apio_ctx.platform.id}'.") 

187 

188 def serial_install(self): 

189 """Installs the serial driver. Function is platform dependent.""" 

190 

191 if self.apio_ctx.is_linux: 191 ↛ 193line 191 didn't jump to line 193 because the condition on line 191 was always true

192 self._serial_install_linux() 

193 elif self.apio_ctx.is_darwin: 

194 self._serial_install_darwin() 

195 elif self.apio_ctx.is_windows: 

196 self._serial_install_windows() 

197 else: 

198 fatal_error(f"Unexpected platform '{self.apio_ctx.platform.id}'.") 

199 

200 def serial_uninstall(self): 

201 """Uninstalls the serial driver. Function is platform dependent.""" 

202 

203 if self.apio_ctx.is_linux: 203 ↛ 205line 203 didn't jump to line 205 because the condition on line 203 was always true

204 self._serial_uninstall_linux() 

205 elif self.apio_ctx.is_darwin: 

206 self._serial_uninstall_darwin() 

207 

208 elif self.apio_ctx.is_windows: 

209 self._serial_uninstall_windows() 

210 else: 

211 fatal_error(f"Unknown platform '{self.apio_ctx.platform.id}'.") 

212 

213 def _ftdi_install_linux(self): 

214 """Drivers install on Linux. It copies the .rules file into 

215 the corresponding folder.""" 

216 

217 cout("Configure FTDI drivers for FPGA") 

218 

219 # -- Check if the target rules file already exists 

220 if self.ftdi_rules_system_path.exists(): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 cout("Already installed", style=INFO) 

222 return 

223 

224 # -- here when the driver is not already installed. 

225 # -- Copy the rules file and reload udev, all in ONE sudo invocation 

226 # -- (a single password prompt). 

227 steps = [ 

228 ( 

229 "cp " 

230 f"{shlex.quote(str(self.ftdi_rules_local_path))} " 

231 f"{shlex.quote(str(self.ftdi_rules_system_path))}", 

232 "install the FTDI udev rules file", 

233 ), 

234 ] + self._udev_reload_steps() 

235 

236 exit_code = self._sudo_steps_linux(steps) 

237 

238 if exit_code != 0: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 fatal_error("Failed to install FTDI drivers.") 

240 

241 # -- All done ok. 

242 cout("FTDI drivers installed", style=SUCCESS) 

243 cout("Unplug and reconnect your board", style=INFO) 

244 

245 def _ftdi_uninstall_linux(self): 

246 """Uninstall the FTDI drivers on linux. Returns process exist code.""" 

247 

248 # -- For disabling the FTDI driver the .rules files should be 

249 # -- removed from the /etc/udev/rules.d/ folder 

250 

251 # -- Remove the .rules file, if it exists 

252 if not self.ftdi_rules_system_path.exists(): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 cout("Already uninstalled", style=INFO) 

254 return 

255 

256 # -- Here when need to uninstall 

257 cout("Revert FTDI drivers configuration") 

258 

259 # -- Remove the rules file and reload udev in ONE sudo call. 

260 steps = [ 

261 ( 

262 f"rm {shlex.quote(str(self.ftdi_rules_system_path))}", 

263 "remove the FTDI udev rules file", 

264 ), 

265 ] + self._udev_reload_steps() 

266 

267 exit_code = self._sudo_steps_linux(steps) 

268 

269 if exit_code != 0: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 fatal_error("Failed to uninstall FTDI drivers") 

271 

272 cout("FTDI drivers uninstalled", style=SUCCESS) 

273 cout("Unplug and reconnect your board", style=INFO) 

274 

275 def _serial_install_linux(self): 

276 """Serial drivers install on Linux.""" 

277 

278 cout("Configure Serial drivers for FPGA") 

279 

280 # -- Check if the target rules file already exists 

281 if self.serial_rules_system_path.exists(): 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 cout("Already installed", style=INFO) 

283 return 

284 

285 # -- Here when need to install. 

286 steps = [] 

287 

288 # -- Add the user to the dialout group for having access to the 

289 # -- serial port, if not a member yet. 

290 group_added = self._needs_dialout_group_linux() 

291 if group_added: 291 ↛ 302line 291 didn't jump to line 302 because the condition on line 291 was always true

292 steps.append( 

293 ( 

294 "usermod -a -G dialout " 

295 f"{shlex.quote(getpass.getuser())}", 

296 "add the user to the dialout group", 

297 ) 

298 ) 

299 

300 # -- Copy the rules file and reload udev; everything runs in 

301 # -- ONE sudo invocation (a single password prompt). 

302 steps += [ 

303 ( 

304 "cp " 

305 f"{shlex.quote(str(self.serial_rules_local_path))} " 

306 f"{shlex.quote(str(self.serial_rules_system_path))}", 

307 "install the serial udev rules file", 

308 ), 

309 ] + self._udev_reload_steps() 

310 

311 exit_code = self._sudo_steps_linux(steps) 

312 

313 if exit_code != 0: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 fatal_error("Serial drivers installation failed.") 

315 

316 cout("Serial drivers installed", style=SUCCESS) 

317 cout("Unplug and reconnect your board", style=INFO) 

318 if group_added: 318 ↛ exitline 318 didn't return from function '_serial_install_linux' because the condition on line 318 was always true

319 cout( 

320 "Restart your machine to install the dialout group", 

321 style=INFO, 

322 ) 

323 

324 def _serial_uninstall_linux(self): 

325 """Uninstall the serial driver on Linux.""" 

326 

327 # -- Do noting if not installed. 

328 if not self.serial_rules_system_path.exists(): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 cout("Already uninstalled", style=INFO) 

330 

331 # -- For disabling the serial driver the corresponding .rules file 

332 # -- should be removed, it it exists 

333 cout("Revert Serial drivers configuration") 

334 

335 # -- Remove the rules file and reload udev in ONE sudo call. 

336 steps = [ 

337 ( 

338 f"rm {shlex.quote(str(self.serial_rules_system_path))}", 

339 "remove the serial udev rules file", 

340 ), 

341 ] + self._udev_reload_steps() 

342 

343 exit_code = self._sudo_steps_linux(steps) 

344 

345 if exit_code != 0: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 fatal_error("Serial drivers uninstallation failed") 

347 

348 cout("Serial drivers uninstalled", style=SUCCESS) 

349 cout("Unplug and reconnect your board", style=INFO) 

350 

351 # -- Exit code of the first step of a _sudo_steps_linux() script; the 

352 # -- following steps use consecutive codes. High enough to not collide 

353 # -- with sudo's own exit codes (1 = auth failure). 

354 _FIRST_STEP_EXIT_CODE = 10 

355 

356 def _sudo_steps_linux(self, steps) -> int: 

357 """Run the given root steps as a SINGLE sudo invocation, so the 

358 user is prompted for the password at most once. 'steps' is a list 

359 of (shell_command, action_description) tuples; each command gets a 

360 distinct exit code so a failure is reported precisely (their 

361 stderr also reaches the console). Returns the process exit code, 

362 0 on success.""" 

363 

364 cout( 

365 "This one-time setup needs administrator privileges " 

366 "(a single sudo prompt)", 

367 style=INFO, 

368 ) 

369 

370 # -- Build 'cmd1 || exit 10; cmd2 || exit 11; ...' 

371 script = "; ".join( 

372 f"{cmd} || exit {self._FIRST_STEP_EXIT_CODE + i}" 

373 for i, (cmd, _) in enumerate(steps) 

374 ) 

375 

376 # -- Honor a graphical askpass helper when the caller provides one 

377 # -- (SUDO_ASKPASS): GUI launchers like Icestudio spawn apio without 

378 # -- an interactive terminal, so sudo cannot prompt on a tty; with 

379 # -- -A it asks through the helper instead (issue #899). Terminal 

380 # -- users without SUDO_ASKPASS keep the classic tty prompt (-A 

381 # -- without a helper would fail instead of prompting). 

382 sudo_cmd = ["sudo"] 

383 if os.environ.get("SUDO_ASKPASS"): 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true

384 sudo_cmd.append("-A") 

385 

386 exit_code = subprocess.call(sudo_cmd + ["sh", "-c", script]) 

387 if exit_code == 0: 387 ↛ 391line 387 didn't jump to line 391 because the condition on line 387 was always true

388 return 0 

389 

390 # -- Map the exit code back to the step that failed. 

391 step = exit_code - self._FIRST_STEP_EXIT_CODE 

392 if 0 <= step < len(steps): 

393 cerror(f"Failed to {steps[step][1]}.") 

394 else: 

395 # -- sudo itself failed (wrong password, no sudo rights, ...) 

396 cerror( 

397 "Could not get administrator privileges " 

398 f"(sudo failed, exit_code={exit_code}).", 

399 ) 

400 return exit_code 

401 

402 def _udev_reload_steps(self): 

403 """The root steps for reloading the udev rules, for 

404 _sudo_steps_linux(). Restarting the udev daemon is NOT needed for 

405 rule changes and the legacy unit name it used ('udev') only exists 

406 on distros with the Debian/Ubuntu compat alias (issue #899 on 

407 other distros: "Failed to restart udev.service: Unit udev.service 

408 not found").""" 

409 

410 return [ 

411 ("udevadm control --reload-rules", "reload the udev rules"), 

412 ( 

413 "udevadm trigger", 

414 "apply the udev rules to the connected devices", 

415 ), 

416 ] 

417 

418 def _needs_dialout_group_linux(self): 

419 """True if the user must be added to the dialout group (needed for 

420 access to the serial port).""" 

421 

422 # -- Get the current groups of the user 

423 groups = subprocess.check_output("groups") 

424 

425 # -- True if it does not belong to the dialout group yet. 

426 return "dialout" not in groups.decode() 

427 

428 def _ftdi_install_darwin(self): 

429 """Installs FTDI driver on darwin. Returns process status code.""" 

430 # Check homebrew 

431 cout(NO_DRIVERS_MSG, style=SUCCESS) 

432 

433 def _ftdi_uninstall_darwin(self): 

434 """Uninstalls FTDI driver on darwin. Returns process status code.""" 

435 cout(NO_DRIVERS_MSG, style=SUCCESS) 

436 

437 def _serial_install_darwin(self): 

438 """Installs serial driver on darwin. Returns process status code.""" 

439 cout(NO_DRIVERS_MSG, style=SUCCESS) 

440 

441 def _serial_uninstall_darwin(self): 

442 """Uninstalls serial driver on darwin. Returns process status code.""" 

443 cout(NO_DRIVERS_MSG, style=SUCCESS) 

444 

445 def _ftdi_install_windows(self): 

446 

447 # -- Get the drivers apio package base folder 

448 drivers_base_dir = self.apio_ctx.get_package_dir("drivers") 

449 

450 # NOTE: Zadig documentation: 

451 # https://github.com/pbatard/libwdi/wiki/Zadig?utm_source=chatgpt.com 

452 

453 # -- Path to the config file zadig.ini. 

454 zadig_ini_src = drivers_base_dir / "share" / "zadig.ini" 

455 

456 # -- Execute in a tmp directory, this way we don't contaminate the 

457 # -- current with zadig.ini, in case the program crashes. 

458 # -- Using a fix tmp location prevents accumulation of leftover 

459 # -- zadig.ini in case the are not cleaned up properly. 

460 # -- We can't store zadig under _build since we don't necessarily 

461 # -- run in a context of a project.. 

462 with util.pushd(self.apio_ctx.get_tmp_dir()): 

463 # -- Bring a copy of zadig.ini 

464 shutil.copyfile(zadig_ini_src, "zadig.ini") 

465 

466 # -- Zadig exe file with full path: 

467 zadig_abs_path = drivers_base_dir / "bin" / "zadig.exe" 

468 assert zadig_abs_path.is_absolute(), zadig_abs_path 

469 

470 # -- Show messages for the user 

471 cout("", "Launching zadig.exe.") 

472 cmarkdown(FTDI_INSTALL_INSTRUCTIONS_WINDOWS) 

473 

474 # -- Execute Zadig. 

475 # -- We execute it using a shell, rather than by 

476 # -- util.exec_command() because zadig required permissions 

477 # -- elevation. 

478 exit_code = subprocess.call(str(zadig_abs_path), shell=True) 

479 

480 if exit_code != 0: 

481 fatal_error("Zadig failed") 

482 

483 def _ftdi_uninstall_windows(self) -> None: 

484 

485 cout("", "Launching the interactive Device Manager.") 

486 cmarkdown(FTDI_UNINSTALL_INSTRUCTIONS_WINDOWS) 

487 

488 # -- We launch the device manager using os.system() rather than with 

489 # -- util.exec_command() because util.exec_command() does not support 

490 # -- elevation. 

491 exit_code = subprocess.call("mmc devmgmt.msc", shell=True) 

492 

493 if exit_code != 0: 

494 fatal_error("Device Manager invocation failed.") 

495 

496 def _serial_install_windows(self): 

497 """Install serial drivers on windows.""" 

498 

499 drivers_base_dir = self.apio_ctx.get_package_dir("drivers") 

500 drivers_bin_dir = drivers_base_dir / "bin" 

501 

502 cout("", "Launching the interactive Serial Installer.") 

503 cmarkdown(SERIAL_INSTALL_INSTRUCTIONS_WINDOWS) 

504 

505 # -- We launch the device manager using shell rather than with 

506 # -- util.exec_command() because util.exec_command() does not support 

507 # -- elevation. 

508 

509 serial_installer_abs_path = drivers_bin_dir / "serial_install.exe" 

510 assert serial_installer_abs_path.is_absolute, serial_installer_abs_path 

511 

512 exit_code = subprocess.call(str(serial_installer_abs_path), shell=True) 

513 

514 if exit_code != 0: 

515 fatal_error("Interactive Serial Installer failed.") 

516 

517 def _serial_uninstall_windows(self): 

518 """Uninstall serial drivers on Windows""" 

519 

520 cout("", "Launching the interactive Device Manager.") 

521 cmarkdown(SERIAL_UNINSTALL_INSTRUCTIONS_WINDOWS) 

522 

523 # -- We launch the device manager using os.system() rather than with 

524 # -- util.exec_command() because util.exec_command() does not support 

525 # -- elevation. 

526 

527 exit_code = subprocess.call("mmc devmgmt.msc", shell=True) 

528 

529 if exit_code != 0: 

530 fatal_error("The interactive Device Manager failed.")