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

166 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 03:53 +0000

1# -*- coding: utf-8 -*- 

2# -- This file is part of the Apio project 

3# -- (C) 2016-2019 FPGAwars 

4# -- Author Jesús Arroyo 

5# -- License GPLv2 

6"""Manage board drivers""" 

7 

8import getpass 

9import os 

10import shlex 

11import shutil 

12import subprocess 

13from pathlib import Path 

14from apio.utils import util 

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

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

17from apio.apio_context import ApioContext 

18 

19# -- Style shortcuts 

20E1 = f"[{EMPH1}]" 

21E3 = f"[{EMPH3}]" 

22 

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

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

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

26 

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

28FTDI_INSTALL_INSTRUCTIONS_WINDOWS = f""" 

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

30 

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

32 

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

34 

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

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

37 

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

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

40 

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

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

43 

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

45 'WinUSB (v6.1.7600.16385)'. 

46 

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

48 completion, this can take a minute or two. 

49 

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

51 

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

53 to take affect. 

54 

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

56 your board is listed. 

57""" 

58 

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

60FTDI_UNINSTALL_INSTRUCTIONS_WINDOWS = f""" 

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

62 

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

64 

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

66 

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

68 

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

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

71 hidden). 

72 

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

74 Bus Devices' section). 

75 

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

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

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

79 

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

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

82 

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

84 device'.[/] 

85 

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

87 

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

89""" 

90 

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

92SERIAL_INSTALL_INSTRUCTIONS_WINDOWS = f""" 

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

94 

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

96 

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

98computer. 

99 

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

101 

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

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

104""" 

105 

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

107SERIAL_UNINSTALL_INSTRUCTIONS_WINDOWS = f""" 

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

109 

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

111 

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

113 

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

115 

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

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

118 

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

120 

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

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

123 

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

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

126 

127 7. If available, check the box \ 

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

129 

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

131 

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

133""" 

134 

135 

136class Drivers: 

137 """Class for managing the board drivers""" 

138 

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

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

141 

142 # -- FTDI source rules file paths 

143 resources_dir = util.get_path_in_apio_package("resources") 

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

145 

146 # -- Target rule file 

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

148 

149 # Serial rules files paths 

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

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

152 

153 # Driver to restore: mac os 

154 driver_c = "" 

155 

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

157 

158 self.apio_ctx = apio_ctx 

159 

160 def ftdi_install(self): 

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

162 

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

164 self._ftdi_install_linux() 

165 elif self.apio_ctx.is_darwin: 

166 self._ftdi_install_darwin() 

167 elif self.apio_ctx.is_windows: 

168 self._ftdi_install_windows() 

169 else: 

170 fatal_error( 

171 f"Unexpected platform type '{self.apio_ctx.platform_id}'." 

172 ) 

173 

174 def ftdi_uninstall(self): 

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

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

177 self._ftdi_uninstall_linux() 

178 elif self.apio_ctx.is_darwin: 

179 self._ftdi_uninstall_darwin() 

180 elif self.apio_ctx.is_windows: 

181 self._ftdi_uninstall_windows() 

182 else: 

183 fatal_error(f"Unexpected platform '{self.apio_ctx.platform_id}'.") 

184 

185 def serial_install(self): 

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

187 

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

189 self._serial_install_linux() 

190 elif self.apio_ctx.is_darwin: 

191 self._serial_install_darwin() 

192 elif self.apio_ctx.is_windows: 

193 self._serial_install_windows() 

194 else: 

195 fatal_error(f"Unexpected platform '{self.apio_ctx.platform_id}'.") 

196 

197 def serial_uninstall(self): 

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

199 

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

201 self._serial_uninstall_linux() 

202 elif self.apio_ctx.is_darwin: 

203 self._serial_uninstall_darwin() 

204 

205 elif self.apio_ctx.is_windows: 

206 self._serial_uninstall_windows() 

207 else: 

208 fatal_error(f"Unknown platform '{self.apio_ctx.platform_id}'.") 

209 

210 def _ftdi_install_linux(self): 

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

212 the corresponding folder.""" 

213 

214 cout("Configure FTDI drivers for FPGA") 

215 

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

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

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

219 return 

220 

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

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

223 # -- (a single password prompt). 

224 steps = [ 

225 ( 

226 "cp " 

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

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

229 "install the FTDI udev rules file", 

230 ), 

231 ] + self._udev_reload_steps() 

232 

233 exit_code = self._sudo_steps_linux(steps) 

234 

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

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

237 

238 # -- All done ok. 

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

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

241 

242 def _ftdi_uninstall_linux(self): 

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

244 

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

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

247 

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

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

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

251 return 

252 

253 # -- Here when need to uninstall 

254 cout("Revert FTDI drivers configuration") 

255 

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

257 steps = [ 

258 ( 

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

260 "remove the FTDI udev rules file", 

261 ), 

262 ] + self._udev_reload_steps() 

263 

264 exit_code = self._sudo_steps_linux(steps) 

265 

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

267 fatal_error("Failed to uninstall FTDI drivers") 

268 

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

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

271 

272 def _serial_install_linux(self): 

273 """Serial drivers install on Linux.""" 

274 

275 cout("Configure Serial drivers for FPGA") 

276 

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

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

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

280 return 

281 

282 # -- Here when need to install. 

283 steps = [] 

284 

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

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

287 group_added = self._needs_dialout_group_linux() 

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

289 steps.append( 

290 ( 

291 "usermod -a -G dialout " 

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

293 "add the user to the dialout group", 

294 ) 

295 ) 

296 

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

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

299 steps += [ 

300 ( 

301 "cp " 

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

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

304 "install the serial udev rules file", 

305 ), 

306 ] + self._udev_reload_steps() 

307 

308 exit_code = self._sudo_steps_linux(steps) 

309 

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

311 fatal_error("Serial drivers installation failed.") 

312 

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

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

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

316 cout( 

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

318 style=INFO, 

319 ) 

320 

321 def _serial_uninstall_linux(self): 

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

323 

324 # -- Do noting if not installed. 

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

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

327 

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

329 # -- should be removed, it it exists 

330 cout("Revert Serial drivers configuration") 

331 

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

333 steps = [ 

334 ( 

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

336 "remove the serial udev rules file", 

337 ), 

338 ] + self._udev_reload_steps() 

339 

340 exit_code = self._sudo_steps_linux(steps) 

341 

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

343 fatal_error("Serial drivers uninstallation failed") 

344 

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

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

347 

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

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

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

351 _FIRST_STEP_EXIT_CODE = 10 

352 

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

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

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

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

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

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

359 0 on success.""" 

360 

361 cout( 

362 "This one-time setup needs administrator privileges " 

363 "(a single sudo prompt)", 

364 style=INFO, 

365 ) 

366 

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

368 script = "; ".join( 

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

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

371 ) 

372 

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

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

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

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

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

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

379 sudo_cmd = ["sudo"] 

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

381 sudo_cmd.append("-A") 

382 

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

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

385 return 0 

386 

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

388 step = exit_code - self._FIRST_STEP_EXIT_CODE 

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

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

391 else: 

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

393 cerror( 

394 "Could not get administrator privileges " 

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

396 ) 

397 return exit_code 

398 

399 def _udev_reload_steps(self): 

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

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

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

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

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

405 not found").""" 

406 

407 return [ 

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

409 ( 

410 "udevadm trigger", 

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

412 ), 

413 ] 

414 

415 def _needs_dialout_group_linux(self): 

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

417 access to the serial port).""" 

418 

419 # -- Get the current groups of the user 

420 groups = subprocess.check_output("groups") 

421 

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

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

424 

425 def _ftdi_install_darwin(self): 

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

427 # Check homebrew 

428 cout(NO_DRIVERS_MSG, style=SUCCESS) 

429 

430 def _ftdi_uninstall_darwin(self): 

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

432 cout(NO_DRIVERS_MSG, style=SUCCESS) 

433 

434 def _serial_install_darwin(self): 

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

436 cout(NO_DRIVERS_MSG, style=SUCCESS) 

437 

438 def _serial_uninstall_darwin(self): 

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

440 cout(NO_DRIVERS_MSG, style=SUCCESS) 

441 

442 def _ftdi_install_windows(self): 

443 

444 # -- Get the drivers apio package base folder 

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

446 

447 # NOTE: Zadig documentation: 

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

449 

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

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

452 

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

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

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

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

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

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

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

460 # -- Bring a copy of zadig.ini 

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

462 

463 # -- Zadig exe file with full path: 

464 zadig_exe = drivers_base_dir / "bin" / "zadig.exe" 

465 

466 # -- Show messages for the user 

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

468 cmarkdown(FTDI_INSTALL_INSTRUCTIONS_WINDOWS) 

469 

470 # -- Execute Zadig. 

471 # -- We execute it using os.system() rather than by 

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

473 # -- elevation. 

474 exit_code = os.system(str(zadig_exe)) 

475 

476 if exit_code != 0: 

477 fatal_error("Zadig failed") 

478 

479 def _ftdi_uninstall_windows(self) -> None: 

480 

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

482 cmarkdown(FTDI_UNINSTALL_INSTRUCTIONS_WINDOWS) 

483 

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

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

486 # -- elevation. 

487 exit_code = os.system("mmc devmgmt.msc") 

488 

489 if exit_code != 0: 

490 fatal_error("Device Manager invocation failed.") 

491 

492 def _serial_install_windows(self): 

493 """Install serial drivers on windows.""" 

494 

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

496 drivers_bin_dir = drivers_base_dir / "bin" 

497 

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

499 cmarkdown(SERIAL_INSTALL_INSTRUCTIONS_WINDOWS) 

500 

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

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

503 # -- elevation. 

504 exit_code = os.system( 

505 str(Path(drivers_bin_dir) / "serial_install.exe") 

506 ) 

507 

508 if exit_code != 0: 

509 fatal_error("Interactive Serial Installer failed.") 

510 

511 def _serial_uninstall_windows(self): 

512 """Uninstall serial drivers on Windows""" 

513 

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

515 cmarkdown(SERIAL_UNINSTALL_INSTRUCTIONS_WINDOWS) 

516 

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

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

519 # -- elevation. 

520 exit_code = os.system("mmc devmgmt.msc") 

521 if exit_code != 0: 

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