Coverage for apio/managers/drivers.py: 20%
171 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 01:55 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 01:55 +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"""
9import getpass
10import os
11import shlex
12import shutil
13import subprocess
14from pathlib import Path
15from apio.utils import util
16from apio.common.apio_console import cout, cerror, cmarkdown
17from apio.common.apio_styles import INFO, SUCCESS, EMPH1, EMPH3
18from apio.apio_context import ApioContext
21# -- Style shortcuts
22E1 = f"[{EMPH1}]"
23E3 = f"[{EMPH3}]"
25# -- A message to print when trying to install/uninstall apio drivers
26# -- on platforms that don't require it.
27NO_DRIVERS_MSG = "No driver installation is required on this platform."
29# -- Text in the rich-text format of the python rich library.
30FTDI_INSTALL_INSTRUCTIONS_WINDOWS = f"""
31{E3}Please follow these steps:[/]
33 1. Make sure your {E1}FPGA board is connected[/] to the computer.
35 2. {E1}Accept the Zadig request[/] to make changes to your computer.
37 3. {E1}Find the Zadig window[/] on your screen. You may need to click
38 on its icon in the task bar for it to appear.
40 4. {E1}Select your FPGA board[/] from the drop down list, For example
41 'Alhambra II v1.0A - B09-335 (Interface 0)'.
43 {E3}VERY IMPORTANT - If your board appears multiple time, make sure
44 to select its 'interface 0' entry.[/]
46 5. {E1}Select the 'WinUSB' driver[/] as the target driver. For example
47 'WinUSB (v6.1.7600.16385)'.
49 6. {E1}Click 'Replace Driver'[/] and wait for a successful
50 completion, this can take a minute or two.
52 7. {E1}Close the Zadig window.[/]
54 8. {E1}Disconnect and reconnect[/] your FPGA board for the new driver
55 to take affect.
57 9. {E1}Run the command 'apio devices scan-usb'[/] and verify that
58 your board is listed.
59"""
61# -- Text in the rich-text format of the python rich library.
62FTDI_UNINSTALL_INSTRUCTIONS_WINDOWS = f"""
63{E3}Please follow these steps:[/]
65 1. Make sure your FPGA {E1}board is NOT connected[/] to the computer.
67 2. If asked, {E1}allow the Device Manager to make changes to your system.[/]
69 3. {E1}Find the Device Manager window.[/]
71 4. {E1}Connect the board[/] to your computer and a new entry will be added
72 to the device list (though sometimes it may be collapsed and
73 hidden).
75 5. {E1}Identify the entry of your board[/] (e.g. in the 'Universal Serial
76 Bus Devices' section).
78 {E3}NOTE: Boards with FT2232 ICs have two channels, 'interface 0'
79 and 'interface 1'. Here we care only about 'interface 0' and
80 ignore 'interface 1' if it appears as a COM port.[/]
82 6. {E1}Right click[/] on your board entry and \
83{E1}select 'Uninstall device'.[/]
85 7. If available, check the box {E1}'Delete the driver software for this
86 device'.[/]
88 8. Click the {E1}'Uninstall' button[/].
90 9. {E1}Close[/] the Device Manager window.
91"""
93# -- Text in the rich-text format of the python rich library.
94SERIAL_INSTALL_INSTRUCTIONS_WINDOWS = f"""
95{E3}Please follow these steps:[/]
97 1. Make sure your FPGA {E1}board is connected[/] to the computer.
99 2. {E1}Accept the Serial Installer request[/] to make changes to your \
100computer.
102 3. Find the Serial installer window and {E1}follow the instructions.[/]
104 4. To verify, {E1}disconnect and reconnect the board[/] and run the command
105 {E1}'apio devices scan-serial'.[/]
106"""
108# -- Text in the rich-text format of the python rich library.
109SERIAL_UNINSTALL_INSTRUCTIONS_WINDOWS = f"""
110{E3}Please follow these steps:[/]
112 1. Make sure your FPGA {E1}board is NOT connected[/] to the computer.
114 2. If asked, {E1}allow the Device Manager to make changes[/] to your system.
116 3. {E1}Find the Device Manager window.[/]
118 4. {E1}Connect the board[/] to your computer and a new entry will be added
119 to the device list (though sometimes it may be collapsed).
121 5. {E1}Identify the entry of your board[/] (typically in the Ports section).
123 {E3} NOTE: If your board does not show up as a COM port, it may not
124 have the 'apio drivers --serial-install' applied to it.[/]
126 6. {E1}Right click[/] on your board entry \
127and {E1}select 'Uninstall device'.[/]
129 7. If available, check the box \
130{E1}'Delete the driver software for this device'.[/]
132 8. Click the {E1}'Uninstall' button.[/]
134 9. {E1}Close the Device Manager window.[/]
135"""
138class Drivers:
139 """Class for managing the board drivers"""
141 # -- The driver installation on linux consist of copying the rule files
142 # -- to the /etc/udev/rules.d folder
144 # -- FTDI source rules file paths
145 resources_dir = util.get_path_in_apio_package("resources")
146 ftdi_rules_local_path = resources_dir / "80-fpga-ftdi.rules"
148 # -- Target rule file
149 ftdi_rules_system_path = Path("/etc/udev/rules.d/80-fpga-ftdi.rules")
151 # Serial rules files paths
152 serial_rules_local_path = resources_dir / "80-fpga-serial.rules"
153 serial_rules_system_path = Path("/etc/udev/rules.d/80-fpga-serial.rules")
155 # Driver to restore: mac os
156 driver_c = ""
158 def __init__(self, apio_ctx: ApioContext) -> None:
160 self.apio_ctx = apio_ctx
162 def ftdi_install(self) -> int:
163 """Installs the FTDI driver. Function is platform dependent.
164 Returns a process exit code.
165 """
167 if self.apio_ctx.is_linux:
168 return self._ftdi_install_linux()
170 if self.apio_ctx.is_darwin:
171 return self._ftdi_install_darwin()
173 if self.apio_ctx.is_windows:
174 return self._ftdi_install_windows()
176 cerror(f"Unknown platform type '{self.apio_ctx.platform_id}'.")
177 return 1
179 def ftdi_uninstall(self) -> int:
180 """Uninstalls the FTDI driver. Function is platform dependent.
181 Returns a process exit code.
182 """
183 if self.apio_ctx.is_linux:
184 return self._ftdi_uninstall_linux()
186 if self.apio_ctx.is_darwin:
187 return self._ftdi_uninstall_darwin()
189 if self.apio_ctx.is_windows:
190 return self._ftdi_uninstall_windows()
192 cerror(f"Unknown platform '{self.apio_ctx.platform_id}'.")
193 return 1
195 def serial_install(self) -> int:
196 """Installs the serial driver. Function is platform dependent.
197 Returns a process exit code.
198 """
200 if self.apio_ctx.is_linux:
201 return self._serial_install_linux()
203 if self.apio_ctx.is_darwin:
204 return self._serial_install_darwin()
206 if self.apio_ctx.is_windows:
207 return self._serial_install_windows()
209 cerror(f"Unknown platform '{self.apio_ctx.platform_id}'.")
210 return 1
212 def serial_uninstall(self) -> int:
213 """Uninstalls the serial driver. Function is platform dependent.
214 Returns a process exit code.
215 """
216 if self.apio_ctx.is_linux:
217 return self._serial_uninstall_linux()
219 if self.apio_ctx.is_darwin:
220 return self._serial_uninstall_darwin()
222 if self.apio_ctx.is_windows:
223 return self._serial_uninstall_windows()
225 cerror(f"Unknown platform '{self.apio_ctx.platform_id}'.")
226 return 1
228 def _ftdi_install_linux(self) -> int:
229 """Drivers install on Linux. It copies the .rules file into
230 the corresponding folder. Return process exit code."""
232 cout("Configure FTDI drivers for FPGA")
234 # -- Check if the target rules file already exists
235 if not self.ftdi_rules_system_path.exists():
237 # -- Copy the rules file and reload udev, all in ONE sudo
238 # -- invocation (a single password prompt).
239 steps = [
240 (
241 "cp "
242 f"{shlex.quote(str(self.ftdi_rules_local_path))} "
243 f"{shlex.quote(str(self.ftdi_rules_system_path))}",
244 "install the FTDI udev rules file",
245 ),
246 ] + self._udev_reload_steps()
247 exit_code = self._sudo_steps_linux(steps)
248 if exit_code != 0:
249 return exit_code
251 cout("FTDI drivers installed", style=SUCCESS)
252 cout("Unplug and reconnect your board", style=INFO)
253 else:
254 cout("Already installed", style=INFO)
256 return 0
258 def _ftdi_uninstall_linux(self):
259 """Uninstall the FTDI drivers on linux. Returns process exist code."""
261 # -- For disabling the FTDI driver the .rules files should be
262 # -- removed from the /etc/udev/rules.d/ folder
264 # -- Remove the .rules file, if it exists
265 if self.ftdi_rules_system_path.exists():
266 cout("Revert FTDI drivers configuration")
268 # -- Remove the rules file and reload udev in ONE sudo call.
269 steps = [
270 (
271 f"rm {shlex.quote(str(self.ftdi_rules_system_path))}",
272 "remove the FTDI udev rules file",
273 ),
274 ] + self._udev_reload_steps()
275 exit_code = self._sudo_steps_linux(steps)
276 if exit_code != 0:
277 return exit_code
279 cout("FTDI drivers uninstalled", style=SUCCESS)
280 cout("Unplug and reconnect your board", style=INFO)
281 else:
282 cout("Already uninstalled", style=INFO)
284 return 0
286 def _serial_install_linux(self):
287 """Serial drivers install on Linux. Returns process exit code."""
289 cout("Configure Serial drivers for FPGA")
291 # -- Check if the target rules file already exists
292 if not self.serial_rules_system_path.exists():
293 steps = []
295 # -- Add the user to the dialout group for having access to the
296 # -- serial port, if not a member yet.
297 group_added = self._needs_dialout_group_linux()
298 if group_added:
299 steps.append(
300 (
301 "usermod -a -G dialout "
302 f"{shlex.quote(getpass.getuser())}",
303 "add the user to the dialout group",
304 )
305 )
307 # -- Copy the rules file and reload udev; everything runs in
308 # -- ONE sudo invocation (a single password prompt).
309 steps += [
310 (
311 "cp "
312 f"{shlex.quote(str(self.serial_rules_local_path))} "
313 f"{shlex.quote(str(self.serial_rules_system_path))}",
314 "install the serial udev rules file",
315 ),
316 ] + self._udev_reload_steps()
317 exit_code = self._sudo_steps_linux(steps)
318 if exit_code != 0:
319 return exit_code
321 cout("Serial drivers installed", style=SUCCESS)
322 cout("Unplug and reconnect your board", style=INFO)
323 if group_added:
324 cout(
325 "Restart your machine to install the dialout group",
326 style=INFO,
327 )
328 else:
329 cout("Already installed", style=INFO)
331 return 0
333 def _serial_uninstall_linux(self) -> int:
334 """Uninstall the serial driver on Linux. Return process exit code."""
336 # -- For disabling the serial driver the corresponding .rules file
337 # -- should be removed, it it exists
338 if self.serial_rules_system_path.exists():
339 cout("Revert Serial drivers configuration")
341 # -- Remove the rules file and reload udev in ONE sudo call.
342 steps = [
343 (
344 f"rm {shlex.quote(str(self.serial_rules_system_path))}",
345 "remove the serial udev rules file",
346 ),
347 ] + self._udev_reload_steps()
348 exit_code = self._sudo_steps_linux(steps)
349 if exit_code != 0:
350 return exit_code
352 cout("Serial drivers uninstalled", style=SUCCESS)
353 cout("Unplug and reconnect your board", style=INFO)
354 else:
355 cout("Already uninstalled", style=INFO)
357 return 0
359 # -- Exit code of the first step of a _sudo_steps_linux() script; the
360 # -- following steps use consecutive codes. High enough to not collide
361 # -- with sudo's own exit codes (1 = auth failure).
362 _FIRST_STEP_EXIT_CODE = 10
364 def _sudo_steps_linux(self, steps) -> int:
365 """Run the given root steps as a SINGLE sudo invocation, so the
366 user is prompted for the password at most once. 'steps' is a list
367 of (shell_command, action_description) tuples; each command gets a
368 distinct exit code so a failure is reported precisely (their
369 stderr also reaches the console). Returns the process exit code,
370 0 on success."""
372 cout(
373 "This one-time setup needs administrator privileges "
374 "(a single sudo prompt)",
375 style=INFO,
376 )
378 # -- Build 'cmd1 || exit 10; cmd2 || exit 11; ...'
379 script = "; ".join(
380 f"{cmd} || exit {self._FIRST_STEP_EXIT_CODE + i}"
381 for i, (cmd, _) in enumerate(steps)
382 )
384 # -- Honor a graphical askpass helper when the caller provides one
385 # -- (SUDO_ASKPASS): GUI launchers like Icestudio spawn apio without
386 # -- an interactive terminal, so sudo cannot prompt on a tty; with
387 # -- -A it asks through the helper instead (issue #899). Terminal
388 # -- users without SUDO_ASKPASS keep the classic tty prompt (-A
389 # -- without a helper would fail instead of prompting).
390 sudo_cmd = ["sudo"]
391 if os.environ.get("SUDO_ASKPASS"):
392 sudo_cmd.append("-A")
394 exit_code = subprocess.call(sudo_cmd + ["sh", "-c", script])
395 if exit_code == 0:
396 return 0
398 # -- Map the exit code back to the step that failed.
399 step = exit_code - self._FIRST_STEP_EXIT_CODE
400 if 0 <= step < len(steps):
401 cerror(f"Failed to {steps[step][1]}.")
402 else:
403 # -- sudo itself failed (wrong password, no sudo rights, ...)
404 cerror("Could not get administrator privileges (sudo failed).")
405 return exit_code
407 def _udev_reload_steps(self):
408 """The root steps for reloading the udev rules, for
409 _sudo_steps_linux(). Restarting the udev daemon is NOT needed for
410 rule changes and the legacy unit name it used ('udev') only exists
411 on distros with the Debian/Ubuntu compat alias (issue #899 on
412 other distros: "Failed to restart udev.service: Unit udev.service
413 not found")."""
415 return [
416 ("udevadm control --reload-rules", "reload the udev rules"),
417 (
418 "udevadm trigger",
419 "apply the udev rules to the connected devices",
420 ),
421 ]
423 def _needs_dialout_group_linux(self):
424 """True if the user must be added to the dialout group (needed for
425 access to the serial port)."""
427 # -- Get the current groups of the user
428 groups = subprocess.check_output("groups")
430 # -- True if it does not belong to the dialout group yet.
431 return "dialout" not in groups.decode()
433 def _ftdi_install_darwin(self) -> int:
434 """Installs FTDI driver on darwin. Returns process status code."""
435 # Check homebrew
436 cout(NO_DRIVERS_MSG, style=SUCCESS)
437 return 0
439 def _ftdi_uninstall_darwin(self):
440 """Uninstalls FTDI driver on darwin. Returns process status code."""
441 cout(NO_DRIVERS_MSG, style=SUCCESS)
442 return 0
444 def _serial_install_darwin(self):
445 """Installs serial driver on darwin. Returns process status code."""
446 cout(NO_DRIVERS_MSG, style=SUCCESS)
447 return 0
449 def _serial_uninstall_darwin(self):
450 """Uninstalls serial driver on darwin. Returns process status code."""
451 cout(NO_DRIVERS_MSG, style=SUCCESS)
452 return 0
454 def _ftdi_install_windows(self) -> int:
456 # -- Get the drivers apio package base folder
457 drivers_base_dir = self.apio_ctx.get_package_dir("drivers")
459 # NOTE: Zadig documentation:
460 # https://github.com/pbatard/libwdi/wiki/Zadig?utm_source=chatgpt.com
462 # -- Path to the config file zadig.ini.
463 zadig_ini_src = drivers_base_dir / "share" / "zadig.ini"
465 # -- Execute in a tmp directory, this way we don't contaminate the
466 # -- current with zadig.ini, in case the program crashes.
467 # -- Using a fix tmp location prevents accumulation of leftover
468 # -- zadig.ini in case the are not cleaned up properly.
469 # -- We can't store zadig under _build since we don't necessarily
470 # -- run in a context of a project..
471 with util.pushd(self.apio_ctx.get_tmp_dir()):
472 # -- Bring a copy of zadig.ini
473 shutil.copyfile(zadig_ini_src, "zadig.ini")
475 # -- Zadig exe file with full path:
476 zadig_exe = drivers_base_dir / "bin" / "zadig.exe"
478 # -- Show messages for the user
479 cout("", "Launching zadig.exe.")
480 cmarkdown(FTDI_INSTALL_INSTRUCTIONS_WINDOWS)
482 # -- Execute zadig!
483 # -- We execute it using os.system() rather than by
484 # -- util.exec_command() because zadig required permissions
485 # -- elevation.
486 exit_code = os.system(str(zadig_exe))
488 # -- All done.
489 return exit_code
491 def _ftdi_uninstall_windows(self) -> int:
492 # -- Check that the required packages exist.
493 # packages.install_missing_packages_on_the_fly(
494 # self.apio_ctx.packages_context
495 # )
497 cout("", "Launching the interactive Device Manager.")
498 cmarkdown(FTDI_UNINSTALL_INSTRUCTIONS_WINDOWS)
500 # -- We launch the device manager using os.system() rather than with
501 # -- util.exec_command() because util.exec_command() does not support
502 # -- elevation.
503 exit_code = os.system("mmc devmgmt.msc")
504 return exit_code
506 def _serial_install_windows(self) -> int:
508 drivers_base_dir = self.apio_ctx.get_package_dir("drivers")
509 drivers_bin_dir = drivers_base_dir / "bin"
511 cout("", "Launching the interactive Serial Installer.")
512 cmarkdown(SERIAL_INSTALL_INSTRUCTIONS_WINDOWS)
514 # -- We launch the device manager using os.system() rather than with
515 # -- util.exec_command() because util.exec_command() does not support
516 # -- elevation.
517 exit_code = os.system(
518 str(Path(drivers_bin_dir) / "serial_install.exe")
519 )
521 return exit_code
523 def _serial_uninstall_windows(self) -> int:
525 cout("", "Launching the interactive Device Manager.")
526 cmarkdown(SERIAL_UNINSTALL_INSTRUCTIONS_WINDOWS)
528 # -- We launch the device manager using os.system() rather than with
529 # -- util.exec_command() because util.exec_command() does not support
530 # -- elevation.
531 exit_code = os.system("mmc devmgmt.msc")
532 return exit_code