"""Vitis/Vivado tool detection for Zynq Flasher GUI. Checks for required Xilinx tools: - xsct (Xilinx Software Command Line Tool) — JTAG operations - program_flash — QSPI flash programming - bootgen — BOOT.BIN generation (image creation only, not hardware) Version selection strategy: - Scans all Vitis/Vivado version directories under xilinx_root - Picks the NEWEST version (by semantic version comparison) - Logs all found versions so the user can verify the choice - If a specific version is needed, set xilinx_path directly Provides shared helpers used by flash_programmer and bitstream_programmer. """ from __future__ import annotations import os import platform import re import shutil import subprocess from dataclasses import dataclass from pathlib import Path from typing import Any from config_manager import Config @dataclass class ToolStatus: """Status of a single Vitis/Vivado tool.""" name: str found: bool path: str = "" version: str = "" error: str = "" alias: str = "" @dataclass class VitisCheckResult: """Result of a complete Vitis/Vivado environment check.""" system: str vitis_path: str tools: dict[str, ToolStatus] is_ready: bool errors: list[str] # ── Tool aliases: canonical name → [possible executable names] ────── TOOL_ALIASES: dict[str, list[str]] = { "xsct": ["xsct"], "program_flash": ["program_flash"], "bootgen": ["bootgen"], "hw_server": ["hw_server"], } # Product subdirectories under xilinx_root to scan (in preference order) _PRODUCT_DIRS = ("Vitis", "Vivado") # ── Version helpers ────────────────────────────────────────────────── def _parse_version_tuple(dirname: str) -> tuple[int, ...]: """Parse a semantic-like version from a directory name. E.g., '2023.2' → (2023, 2), '2018.3' → (2018, 3). Returns (0,) for unparseable names. Args: dirname: Directory name (e.g., '2023.2', '2018.3.1'). Returns: Tuple of integers for version comparison. """ m = re.match(r"(\d{4})\.(\d+)(?:\.(\d+))?", dirname) if m: return tuple(int(g) for g in m.groups() if g is not None) return (0,) def _version_from_path(tool_path: str) -> str: """Extract version string from a tool's install path. E.g., '/data/xilinx/Vitis/2023.2/bin/xsct' → '2023.2'. Args: tool_path: Absolute path to the tool executable. Returns: Version string or empty string. """ parts = Path(tool_path).parts for i, part in enumerate(parts): if re.match(r"\d{4}\.\d+", part) and i > 0: return part return "" # ── Executable discovery ───────────────────────────────────────────── def _scan_xilinx_root( xilinx_root: str, exec_name: str, exec_suffix: str, ) -> list[tuple[tuple[int, ...], Path, str]]: """Scan xilinx_root for all installed versions of a tool. Searches under Vitis/*/bin/ and Vivado/*/bin/ for the executable. On Windows also checks .bat wrappers in addition to .exe. Args: xilinx_root: Xilinx root directory (e.g., /data/xilinx, C:/Xilinx). exec_name: Executable name without extension. exec_suffix: Platform suffix ('' or '.exe'). Returns: List of (version_tuple, full_path, product_name) sorted newest first. """ results: list[tuple[tuple[int, ...], Path, str]] = [] xr = Path(xilinx_root) if not xr.is_dir(): return results # On Windows, also try .bat extension (Xilinx tools use .bat wrappers) suffixes = [exec_suffix] if exec_suffix == ".exe": suffixes.append(".bat") for product in _PRODUCT_DIRS: pdir = xr / product if not pdir.is_dir(): continue try: entries = list(pdir.iterdir()) except OSError: continue for ver_dir in entries: if not ver_dir.is_dir(): continue for sfx in suffixes: candidate = ver_dir / "bin" / f"{exec_name}{sfx}" if candidate.is_file(): v = _parse_version_tuple(ver_dir.name) if v != (0,): results.append((v, candidate, product)) break # Found for this version dir # Sort newest first (descending version tuple) results.sort(key=lambda x: x[0], reverse=True) return results def _find_executable( exec_name: str, vitis_path: str, system: str, xilinx_root: str = "", ) -> str | None: """Find an executable by name using PATH, Vitis, and Xilinx root. Search order: 1. System PATH (shutil.which) 2. vitis_path/bin (legacy config) 3. xilinx_root/Vitis/*/bin and Vivado/*/bin — pick NEWEST version 4. Common install paths (cross-platform) Args: exec_name: Executable name (without extension). vitis_path: Legacy Vitis installation directory. system: Platform identifier (linux, windows, darwin). xilinx_root: Xilinx root directory (e.g., /data/xilinx, C:/Xilinx). Returns: Absolute path to the best executable, or None. """ exec_suffix = ".exe" if system == "windows" else "" # 1. System PATH found = shutil.which(f"{exec_name}{exec_suffix}") if found: return found # 2. Legacy vitis_path/bin if vitis_path: candidate = Path(vitis_path) / "bin" / f"{exec_name}{exec_suffix}" if candidate.is_file(): return str(candidate) # 3. Scan xilinx_root — pick newest version across Vitis + Vivado if xilinx_root: candidates = _scan_xilinx_root(xilinx_root, exec_name, exec_suffix) if candidates: # Preference: Vitis over Vivado if same version # candidates are already sorted by version desc; # within same version, Vitis appears first (scanned first) return str(candidates[0][1]) # 4. Common install paths (cross-platform fallback) if system == "windows": common: list[str] = [] for sfx in (".bat", ".exe"): for ver in ("2023.2", "2022.2"): for prod in ("Vitis", "Vivado"): common.append(f"C:/Xilinx/{prod}/{ver}/bin/{exec_name}{sfx}") else: common = [ f"/data/xilinx/Vitis/2023.2/bin/{exec_name}", f"/data/xilinx/Vivado/2023.2/bin/{exec_name}", f"/opt/xilinx/bin/{exec_name}", os.path.expanduser(f"~/Xilinx/Vitis/2023.2/bin/{exec_name}"), os.path.expanduser(f"~/Xilinx/Vivado/2023.2/bin/{exec_name}"), ] for p in common: if os.path.isfile(p): return p return None def get_all_found_versions( xilinx_root: str, exec_name: str, ) -> list[tuple[str, str]]: """Return all found versions of a tool under xilinx_root. Useful for logging which versions are available. Args: xilinx_root: Xilinx root directory. exec_name: Executable name without extension. Returns: List of (version_string, product_name) sorted newest first. """ system = platform.system().lower() suffix = ".exe" if system == "windows" else "" candidates = _scan_xilinx_root(xilinx_root, exec_name, suffix) return [ (Path(str(p)).parent.parent.name, product) for _, p, product in candidates ] # ── Environment setup ──────────────────────────────────────────────── def _find_settings_script(tool_path: str) -> str | None: """Find the settings64 script for a Xilinx tool's version directory. Walks up from the tool's bin/ directory looking for settings64.sh (Linux) or settings64.bat (Windows). Args: tool_path: Absolute path to a Xilinx tool executable. Returns: Path to settings64 script, or None if not found. """ p = Path(tool_path).resolve() for parent in p.parents: for name in ("settings64.sh", "settings64.bat"): candidate = parent / name if candidate.is_file(): return str(candidate) return None def build_tool_command(tool_path: str, *args: str) -> list[str]: """Build a subprocess-safe command with Xilinx environment setup. On Linux: sources settings64.sh before running the tool. On Windows: .bat wrappers handle environment internally. Args: tool_path: Path to the Xilinx tool executable. *args: Arguments to pass to the tool. Returns: Command list suitable for subprocess.run(). """ import shlex system = platform.system().lower() # Windows: .bat wrappers set up their own environment if system == "windows": return [tool_path, *args] # Linux: find settings64.sh and source it before running settings = _find_settings_script(tool_path) if not settings: return [tool_path, *args] quoted = " ".join(shlex.quote(a) for a in [tool_path, *args]) return ["bash", "-c", f"source {shlex.quote(settings)} && {quoted}"] # ── Public path getters ────────────────────────────────────────────── def get_xsct_path(vitis_path: str = "", xilinx_root: str = "") -> str | None: """Find the xsct (Xilinx Software Command Line Tool) executable.""" return _find_executable("xsct", vitis_path, platform.system().lower(), xilinx_root) def get_program_flash_path(vitis_path: str = "", xilinx_root: str = "") -> str | None: """Find the program_flash executable.""" return _find_executable("program_flash", vitis_path, platform.system().lower(), xilinx_root) def get_bootgen_path(vitis_path: str = "", xilinx_root: str = "") -> str | None: """Find the bootgen executable.""" return _find_executable("bootgen", vitis_path, platform.system().lower(), xilinx_root) def get_vivado_path(vitis_path: str = "", xilinx_root: str = "") -> str | None: """Find the Vivado executable (JTAG operations via hw_server). Args: vitis_path: Legacy Vitis installation directory. xilinx_root: Xilinx root directory. Returns: Path to vivado executable, or None. """ return _find_executable("vivado", vitis_path, platform.system().lower(), xilinx_root) def get_hw_server_path(vitis_path: str = "", xilinx_root: str = "") -> str | None: """Find the hw_server executable (Xilinx hardware server). hw_server must be running before Vivado/xsct can connect to the JTAG chain. This finder searches the same Vitis/Vivado bin dirs. Args: vitis_path: Legacy Vitis installation directory. xilinx_root: Xilinx root directory. Returns: Path to hw_server executable, or None. """ return _find_executable( "hw_server", vitis_path, platform.system().lower(), xilinx_root ) # ── Tool version detection ─────────────────────────────────────────── def _get_tool_version(tool_path: str) -> str: """Get the version string from a tool executable. Tries (in order): 1. tool -version → parse version from output 2. Extract from path (e.g., .../Vitis/2023.2/bin/xsct → 2023.2) Args: tool_path: Absolute path to the tool executable. Returns: Version string, or empty string if undetectable. """ # Try -version flag try: result = subprocess.run( [tool_path, "-version"], capture_output=True, text=True, timeout=10, ) output = (result.stdout + result.stderr).strip() if output: # Scan all lines for a version pattern (skip batch echo noise) for raw_line in output.splitlines(): line = raw_line.strip() if not line: continue # Skip Windows batch echo noise if _is_batch_noise(line): continue m = re.search(r"(\d{4}\.\d+(?:\.\d+)?)", line) if m: return m.group(1) except (subprocess.TimeoutExpired, OSError): pass # Fallback: extract from path return _version_from_path(tool_path) def _is_batch_noise(line: str) -> bool: """Check if a line is Windows batch script noise. Args: line: A line of output text. Returns: True if the line looks like batch echo/status noise. """ noise_patterns = [ r"^ECHO\s+(is\s+(on|off)|处于)", # ECHO is on/off (en+zh) r"^[A-Z]:\\[^>]*>", # Prompt line like C:\path> ] return any(re.search(p, line, re.IGNORECASE) for p in noise_patterns) # ── Main check function ────────────────────────────────────────────── def check_vitis(config: Config) -> VitisCheckResult: """Check availability of all required Xilinx tools. Scans all versions under xilinx_root and picks the newest. Args: config: Application configuration. Returns: VitisCheckResult with per-tool status and version info. """ system = platform.system().lower() vitis_path = config.vitis_path if hasattr(config, 'vitis_path') else "" xilinx_root = config.xilinx_path if hasattr(config, 'xilinx_path') else "" tools: dict[str, ToolStatus] = {} errors: list[str] = [] for tool_name, aliases in TOOL_ALIASES.items(): status = _check_tool(tool_name, aliases, vitis_path, system, xilinx_root) tools[tool_name] = status if not status.found: errors.append(f"{tool_name}: {status.error or 'not found'}") return VitisCheckResult( system=system, vitis_path=xilinx_root or vitis_path, tools=tools, is_ready=len(errors) == 0, errors=errors, ) def _check_tool( tool_name: str, aliases: list[str], vitis_path: str, system: str, xilinx_root: str = "", ) -> ToolStatus: """Check if a single tool is available, picking the newest version.""" best_path: str | None = None best_alias: str = "" for alias in aliases: found = _find_executable(alias, vitis_path, system, xilinx_root) if found: best_path = found best_alias = alias if alias != tool_name else "" break if best_path: version = _get_tool_version(best_path) return ToolStatus( name=tool_name, found=True, path=best_path, version=version, alias=best_alias, ) return ToolStatus( name=tool_name, found=False, error=( f"Could not locate {', '.join(aliases)} in PATH" + (f" or {xilinx_root}" if xilinx_root else "") + (f" or {vitis_path}" if vitis_path else "") ), ) # ── Status serialization ───────────────────────────────────────────── def get_tool_status_dict(result: VitisCheckResult) -> dict[str, Any]: """Convert a VitisCheckResult to a serializable dictionary. Args: result: VitisCheckResult from check_vitis(). Returns: Dictionary suitable for JSON serialization / GUI display. """ return { "system": result.system, "vitis_path": result.vitis_path, "is_ready": result.is_ready, "tools": { name: { "found": status.found, "path": status.path, "version": status.version, "error": status.error, "alias": status.alias, } for name, status in result.tools.items() }, "errors": result.errors, }