"""Main GUI window for Zynq XC7Z100 Flasher. Integrates all backend modules into a modern, step-by-step workflow GUI built with CustomTkinter. """ from __future__ import annotations import os import threading import time import tkinter as tk from pathlib import Path from typing import Callable import customtkinter as ctk from config_manager import Config from vitis_checker import check_vitis, get_tool_status_dict from zynq_checker import check_zynq_jtag, get_zynq_status_dict from flash_programmer import full_flash_program, FlashResult from bitstream_programmer import full_bitstream_program, BitstreamResult from ip_verifier import ping_ip from tftp_manager import tftp_upload_verify, TftpResult from reboot_manager import reboot_zynq, RebootResult from boot_verifier import verify_boot, verify_zynq_status, BootVerificationResult from serial_monitor import detect_serial_ports, parse_boot_output, check_uart_available, UartMonitor from gui.styles import ( WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, FONT_TITLE, FONT_HEADING, FONT_BODY, FONT_SMALL, FONT_MONO, PADDING, PADDING_LARGE, PADDING_SMALL, CORNER_RADIUS, PRIMARY_COLOR, SUCCESS_COLOR, WARNING_COLOR, DANGER_COLOR, INFO_COLOR, CONNECTOR_COLOR, get_theme, ) from gui.widgets import ( StepHeader, ProgressIndicator, StatusDisplay, FileSelector, LogDisplay, SubStepFrame, ) class MainWindow(ctk.CTk): """Main application window for Zynq Flasher GUI.""" def __init__(self) -> None: """Initialize the main window.""" super().__init__() self.title(WINDOW_TITLE) self.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") self.minsize(800, 600) ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") self._config: Config | None = None self._config_path = self._find_config() self._steps: list[StepHeader] = [] self._step_statuses: dict[int, str] = {} self._is_running = False self._worker_thread: threading.Thread | None = None self._uart_available: bool = False self._uart_message: str = "" self._zynq_jtag_present: bool = False self._zynq_jtag_info = None self._uart_monitor: UartMonitor | None = None self._flash_phase: str = "" # StringVar references for config sync self._ip_string_var: ctk.StringVar | None = None self._load_config() self._build_ui() self._update_step_dependencies() # Handle window close self.protocol("WM_DELETE_WINDOW", self._on_close) # Defer blocking operations until after the window is displayed self.after(100, self._deferred_init) def _deferred_init(self) -> None: """Run blocking operations in background thread after window shown.""" def _bg_init(): self._check_vitis() self._refresh_ports_initial() threading.Thread(target=_bg_init, daemon=True).start() # ── Config ───────────────────────────────────────────────── def _find_config(self) -> Path | None: """Find the user configuration file. Checks common locations: 1. config.yaml (project root, user's config) 2. config/default_config.yaml (template, not for saving) 3. User-specified path via env var Returns: Path to user config file, or None if not found. """ # Navigate from src/gui/main_window.py -> project root app_dir = Path(__file__).parent.parent.parent # 1. Check for user config at project root user_config = app_dir / "config.yaml" if user_config.exists(): return user_config # 2. Check for default config (template) config_dir = app_dir / "config" default_config = config_dir / "default_config.yaml" if default_config.exists(): return default_config # 3. Check env var env_path = os.environ.get("ZYNQ_CONFIG") if env_path: return Path(env_path) return None def _get_user_config_path(self) -> Path: """Get the path to the user config file (config.yaml). Creates config.yaml in project root if it doesn't exist. Returns: Path to user config file. """ # Navigate from src/gui/main_window.py -> project root app_dir = Path(__file__).parent.parent.parent config_path = app_dir / "config.yaml" config_path.parent.mkdir(parents=True, exist_ok=True) return config_path def _load_config(self) -> None: """Load configuration from file or create default.""" if self._config_path: try: self._config = Config.from_file(self._config_path) # If loaded from default_config.yaml, set save path to config.yaml if self._config_path.name == "default_config.yaml": user_config_path = self._get_user_config_path() self._config._config_path = user_config_path except Exception: self._config = Config.from_default() # Set save path to config.yaml for new configs user_config_path = self._get_user_config_path() self._config._config_path = user_config_path else: self._config = Config.from_default() # Set save path to config.yaml for new configs user_config_path = self._get_user_config_path() self._config._config_path = user_config_path def _sync_config_from_ui(self) -> None: """Read current UI values and update the Config object. This must be called before _save_config() to ensure the Config object has the latest UI values. """ if not self._config: return # Sync IP address if self._ip_string_var: self._config.zynq_ip = self._ip_string_var.get() # Sync serial port self._config.serial_port = self._port_var.get() # Sync file paths from FileSelectors files = self._get_selected_files() self._config.bootloader_bit_path = files["bootloader_bit_path"] self._config.bootloader_elf_path = files["bootloader_elf_path"] self._config.bootloader_bin_path = files["bootloader_bin_path"] self._config.fsbl_elf_path = files["fsbl_elf_path"] self._config.erase_all = self._erase_cb_var.get() self._config.firmware_bin_path = files["firmware_bin_path"] def _save_config(self) -> None: """Save current configuration to user config file (config.yaml). Always saves to config/config.yaml, not to default_config.yaml. """ self._sync_config_from_ui() # Sync UI values first if not self._config: self._log_message(" No config loaded") return # Save to user config file (config.yaml) user_config_path = self._get_user_config_path() self._config._config_path = user_config_path # Set save path try: self._config.save() self._log_message(f" Config saved to {user_config_path}") self._status.set_status("Config saved", "success") except Exception as e: self._log_message(f" ✗ Save failed: {e}") self._status.set_status(f"Save failed: {e}", "error") def _on_erase_toggle(self) -> None: """Update config.erase_all immediately when checkbox toggles.""" if self._config: self._config.erase_all = self._erase_cb_var.get() # ── UI Construction ──────────────────────────────────────── def _build_ui(self) -> None: """Build the complete UI layout.""" # Main container with padding main_frame = ctk.CTkFrame(self) main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE) main_frame.grid_rowconfigure(1, weight=1) main_frame.grid_columnconfigure(0, weight=1) # ── Header ── self._build_header(main_frame) # ── Left Panel: Workflow Steps ── left_frame = ctk.CTkFrame(main_frame) left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, PADDING)) left_frame.grid_rowconfigure(3, weight=1) left_frame.grid_columnconfigure(0, weight=1) self._build_workflow_panel(left_frame) self._build_log_panel(left_frame) # ── Right Panel: Configuration ── right_frame = ctk.CTkFrame(main_frame) right_frame.grid(row=1, column=1, sticky="nsew", padx=(PADDING, 0)) right_frame.grid_rowconfigure(4, weight=1) self._build_config_panel(right_frame) self._build_serial_panel(right_frame) self._build_status_panel(right_frame) def _build_header(self, parent: ctk.CTkFrame) -> None: """Build the application header.""" header = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) header.grid(row=0, column=0, columnspan=2, sticky="nsew", pady=(0, PADDING)) header.grid_columnconfigure(1, weight=1) title = ctk.CTkLabel( header, text="Zynq XC7Z100 Flasher", font=FONT_TITLE, ) title.grid(row=0, column=0, padx=PADDING, pady=PADDING) self._vitis_status = ctk.CTkLabel( header, text="Vitis: Checking...", font=FONT_BODY, ) self._vitis_status.grid(row=0, column=1, sticky="e", padx=PADDING) self._ip_status = ctk.CTkLabel( header, text=f"IP: {self._config.zynq_ip}", font=FONT_BODY, ) self._ip_status.grid(row=0, column=2, sticky="e", padx=PADDING) self._jtag_status = ctk.CTkLabel( header, text="JTAG: Checking...", font=FONT_BODY, ) self._jtag_status.grid(row=0, column=3, sticky="e", padx=PADDING) def _build_workflow_panel(self, parent: ctk.CTkFrame) -> None: """Build the step-by-step workflow panel.""" panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) panel.grid( row=0, column=0, sticky="nsew", padx=PADDING, pady=(PADDING, 0), ) panel.grid_rowconfigure(10, weight=1) panel.grid_columnconfigure(0, weight=1) # ── Title ── title = ctk.CTkLabel( panel, text="Workflow", font=FONT_HEADING, ) title.grid(row=0, column=0, padx=PADDING_SMALL, pady=(PADDING_SMALL, PADDING_SMALL)) # ── Steps ── step_defs = [ {"num": 1, "title": "Check Environment", "desc": "Verify tools & connectivity"}, {"num": 2, "title": "Program & Verify Flash", "desc": "Wipe, program, and verify flash"}, {"num": 3, "title": "Load Bootloader", "desc": "Program BIT + run ELF"}, {"num": 4, "title": "Load Main Program", "desc": "TFTP upload, reboot, verify boot"}, ] self._steps = [] self._step_statuses = {} for i, defn in enumerate(step_defs): step = StepHeader( panel, step_number=defn["num"], title=defn["title"], description=defn["desc"], callback=lambda idx=i: self._execute_step(idx), can_run=(i == 0), ) self._steps.append(step) self._step_statuses[i] = "pending" # Divider line between steps if i > 0: divider = ctk.CTkFrame( panel, height=1, fg_color=CONNECTOR_COLOR, ) divider.grid( row=i * 2, column=0, sticky="ew", padx=PADDING, pady=(2, 0), ) # Position step step.grid( row=i * 2 + 1, column=0, sticky="nsew", padx=PADDING_SMALL, pady=(0, 2), ) # Step 2: add collapsible sub-step frame if i == 1: self._sub_step_frame = SubStepFrame(panel) self._sub_step_frame.grid( row=i * 2 + 2, column=0, sticky="nsew", padx=PADDING_SMALL + 16, pady=(0, 2), ) # ── Progress indicator ── self._progress = ProgressIndicator(panel) self._progress.grid( row=8, column=0, sticky="nsew", padx=PADDING, pady=(PADDING, PADDING_SMALL), ) # ── Run All button ── self._run_btn = ctk.CTkButton( panel, text="▶ Run All Steps", font=FONT_HEADING, command=self._run_all_steps, height=40, corner_radius=CORNER_RADIUS, ) self._run_btn.grid( row=9, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL, ) def _build_log_panel(self, parent: ctk.CTkFrame) -> None: """Build the log display panel.""" panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) panel.grid( row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING, ) panel.grid_rowconfigure(1, weight=1) panel.grid_columnconfigure(0, weight=1) title = ctk.CTkLabel( panel, text="Log", font=FONT_HEADING, ) title.grid(row=0, column=0, padx=PADDING, pady=PADDING) self._log_display = LogDisplay(panel, height=250) self._log_display.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING) # Clear button clear_btn = ctk.CTkButton( panel, text="Clear Log", font=FONT_SMALL, command=self._log_display.clear, width=100, ) clear_btn.grid(row=0, column=1, padx=PADDING, pady=PADDING) def _build_config_panel(self, parent: ctk.CTkFrame) -> None: """Build the configuration panel.""" panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) panel.grid( row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING, ) panel.grid_rowconfigure(7, weight=1) title = ctk.CTkLabel( panel, text="Configuration", font=FONT_HEADING, ) title.grid(row=0, column=0, padx=PADDING, pady=PADDING) # IP address ip_frame = ctk.CTkFrame(panel) ip_frame.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) ip_frame.grid_columnconfigure(1, weight=1) ctk.CTkLabel(ip_frame, text="Zynq IP:", font=FONT_BODY).grid(row=0, column=0, sticky="w") self._ip_string_var = ctk.StringVar(value=self._config.zynq_ip) self._ip_entry = ctk.CTkEntry(ip_frame, textvariable=self._ip_string_var) self._ip_entry.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL) # BIT path (Bootloader) self._bit_selector = FileSelector( panel, label_text="Bootloader BIT (.bit):", file_types=[("BIT files", "*.bit"), ("All files", "*")], ) if self._config.bootloader_bit_path: resolved = self._config.resolve_path(self._config.bootloader_bit_path) self._bit_selector.set_path(str(resolved)) self._bit_selector.grid(row=2, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) # ELF path (Bootloader) self._elf_selector = FileSelector( panel, label_text="Bootloader ELF (.elf):", file_types=[("ELF files", "*.elf"), ("All files", "*")], ) if self._config.bootloader_elf_path: resolved = self._config.resolve_path(self._config.bootloader_elf_path) self._elf_selector.set_path(str(resolved)) self._elf_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) # Bootloader BIN path (Flash programming) self._bootloader_bin_selector = FileSelector( panel, label_text="Bootloader BIN (.bin):", file_types=[("BIN files", "*.bin"), ("All files", "*")], ) if self._config.bootloader_bin_path: resolved = self._config.resolve_path(self._config.bootloader_bin_path) self._bootloader_bin_selector.set_path(str(resolved)) self._bootloader_bin_selector.grid(row=4, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) # FSBL ELF path (required by program_flash for QSPI programming) self._fsbl_selector = FileSelector( panel, label_text="FSBL ELF (.elf):", file_types=[("ELF files", "*.elf"), ("All files", "*")], ) if self._config.fsbl_elf_path: resolved = self._config.resolve_path(self._config.fsbl_elf_path) self._fsbl_selector.set_path(str(resolved)) self._fsbl_selector.grid(row=5, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) # Flash options flash_frame = ctk.CTkFrame(panel) flash_frame.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) flash_frame.grid_columnconfigure(0, weight=1) self._erase_cb_var = ctk.BooleanVar(value=self._config.erase_all) self._erase_cb = ctk.CTkCheckBox( flash_frame, text="整片Flash擦除 (Erase entire flash)", variable=self._erase_cb_var, font=FONT_SMALL, command=self._on_erase_toggle, ) self._erase_cb.grid(row=0, column=0, sticky="w", padx=PADDING_SMALL) flash_model_label = ctk.CTkLabel( flash_frame, text=f"Flash: {self._config.flash_model} (32 MiB)", font=FONT_SMALL, ) flash_model_label.grid(row=1, column=0, sticky="w", padx=PADDING_SMALL, pady=(2, 0)) # Firmware BIN path (TFTP upload) self._firmware_bin_selector = FileSelector( panel, label_text="Firmware BIN (.bin):", file_types=[("BIN files", "*.bin"), ("All files", "*")], ) if self._config.firmware_bin_path: resolved = self._config.resolve_path(self._config.firmware_bin_path) self._firmware_bin_selector.set_path(str(resolved)) self._firmware_bin_selector.grid(row=7, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) # Save button save_btn = ctk.CTkButton( panel, text="Save Config", font=FONT_SMALL, command=self._save_config, ) save_btn.grid(row=8, column=0, padx=PADDING, pady=PADDING) def _build_serial_panel(self, parent: ctk.CTkFrame) -> None: """Build the serial monitor panel.""" panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) panel.grid( row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING, ) panel.grid_rowconfigure(3, weight=1) panel.grid_columnconfigure(0, weight=1) title = ctk.CTkLabel( panel, text="Serial Monitor", font=FONT_HEADING, ) title.grid(row=0, column=0, padx=PADDING, pady=PADDING) # Port selector port_frame = ctk.CTkFrame(panel) port_frame.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) port_frame.grid_columnconfigure(1, weight=1) ctk.CTkLabel(port_frame, text="Port:", font=FONT_BODY).grid(row=0, column=0, sticky="w") self._port_var = ctk.StringVar(value=self._config.serial_port) self._port_menu = ctk.CTkComboBox( port_frame, values=[""] + [p.device for p in detect_serial_ports()], variable=self._port_var, ) self._port_menu.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL) # Refresh button refresh_btn = ctk.CTkButton( port_frame, text="Refresh", font=FONT_SMALL, command=self._refresh_ports, width=80, ) refresh_btn.grid(row=0, column=2, padx=PADDING_SMALL) # Read button read_btn = ctk.CTkButton( panel, text="Read Serial", font=FONT_BODY, command=self._read_serial, ) read_btn.grid(row=2, column=0, padx=PADDING, pady=PADDING_SMALL) # Test version button self._test_version_btn = ctk.CTkButton( panel, text="Test Version", font=FONT_BODY, command=self._test_serial_version, ) self._test_version_btn.grid(row=3, column=0, padx=PADDING, pady=PADDING_SMALL) def _build_status_panel(self, parent: ctk.CTkFrame) -> None: """Build the status display panel.""" panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) panel.grid( row=2, column=0, sticky="nsew", padx=PADDING, pady=PADDING, ) self._status = StatusDisplay(panel) self._status.pack(fill="x", padx=PADDING, pady=PADDING) # UART warning label (persistent, not overwritten by other status updates) self._uart_warning_label = ctk.CTkLabel( panel, text="", font=FONT_BODY, anchor="w", text_color=WARNING_COLOR, ) self._uart_warning_label.pack( fill="x", padx=PADDING, pady=(0, PADDING_SMALL) ) self._uart_warning_label.pack_forget() # Hide by default # ── Workflow Steps ───────────────────────────────────────── def _log_message(self, message: str) -> None: """Append a message to the log display (thread-safe).""" self.after(0, lambda: self._log_display.append(message)) def _set_step_status(self, index: int, status: str) -> None: """Set the status of a workflow step. Args: index: Step index (0-based). status: Status string ('pending', 'running', 'complete', 'error'). """ if 0 <= index < len(self._steps): self._steps[index].set_status(status) def _get_selected_files(self) -> dict: """Get selected file paths from the UI. Returns: Dictionary with all file paths from UI selectors. """ return { "bootloader_bit_path": self._bit_selector.get_path(), "bootloader_elf_path": self._elf_selector.get_path(), "bootloader_bin_path": self._bootloader_bin_selector.get_path(), "fsbl_elf_path": self._fsbl_selector.get_path(), "firmware_bin_path": self._firmware_bin_selector.get_path(), } # ── Step Implementations ─────────────────────────────────── def _run_step_1_check_environment(self) -> bool: """Step 1: Check environment (Vitis/Vivado tools + Zynq IP + UART).""" self._log_message("Step 1: Checking environment...") if not self._config: self._log_message(" No config loaded") return False # Check Vitis/Vivado tools self._log_message(" Checking Vitis/Vivado tools...") result = check_vitis(self._config) status_dict = get_tool_status_dict(result) for tool_name, tool_info in status_dict["tools"].items(): if tool_info["found"]: alias_note = f" (via {tool_info['alias']})" if tool_info.get("alias") and tool_info["alias"] != tool_name else "" self._log_message(f" ✓ {tool_name}: {tool_info['path']}{alias_note}") else: self._log_message(f" ✗ {tool_name}: {tool_info['error']}") if result.is_ready: self._vitis_status.configure(text="Vitis: Ready", text_color=SUCCESS_COLOR) self._log_message(" Vitis/Vivado tools available") else: self._vitis_status.configure(text="Vitis: Partial", text_color=WARNING_COLOR) self._log_message(f" Issues: {'; '.join(result.errors)}") # Check Zynq JTAG presence self._log_message(" Checking Zynq JTAG presence...") jtag_result = check_zynq_jtag(self._config, self._jtag_callback) jtag_dict = get_zynq_status_dict(jtag_result) # Log JTAG chain information if jtag_result.jtag_chain: self._log_message(f" JTAG chain: {len(jtag_result.jtag_chain)} device(s)") for jtag_dev in jtag_result.jtag_chain: dev_type = "ARM DAP (PS)" if jtag_dev.is_arm_dap else "PL" if jtag_dev.is_pl_device else "Other" self._log_message(f" - {jtag_dev.name} ({dev_type})") if jtag_result.is_present: self._zynq_jtag_present = True self._zynq_jtag_info = jtag_result.devices[0] part = jtag_result.devices[0].part_number # Log PS and PL detection status ps_status = "✓" if jtag_result.ps_detected else "✗" pl_status = "✓" if jtag_result.pl_detected else "✗" self._log_message(f" PS (ARM DAP): {ps_status}") self._log_message(f" PL: {pl_status}") self._log_message(f" ✓ Zynq {part} detected on JTAG chain") self._jtag_status.configure(text=f"JTAG: {part} ✓", text_color=SUCCESS_COLOR) else: self._zynq_jtag_present = False self._zynq_jtag_info = None self._log_message(f" ✗ Zynq not detected on JTAG: {jtag_result.error}") self._jtag_status.configure(text="JTAG: Not detected", text_color=WARNING_COLOR) # Check UART availability self._log_message(" Checking UART serial port...") port = self._config.serial_port if port: available, reason = check_uart_available(port, self._config.serial_baudrate) self._uart_available = available self._uart_message = reason if available: self._log_message(f" ✓ UART available: {reason}") else: self._log_message(f" ✗ UART unavailable: {reason}") self._show_uart_notification(reason) else: self._uart_available = False self._uart_message = "No serial port configured" self._log_message(" ✗ No serial port configured") self._show_uart_notification(self._uart_message) return True def _show_uart_notification(self, reason: str) -> None: """Show a persistent UART unavailable notification. This notification stays visible and is not overwritten by other status updates. Args: reason: Why UART is unavailable. """ msg = f"⚠ UART 不可用 ({reason}),日志分析及检查将禁用" self.after(0, lambda: self._show_uart_warning(msg)) def _show_uart_warning(self, msg: str) -> None: """Display the persistent UART warning message. Args: msg: Warning message to display. """ self._uart_warning_label.configure(text=msg) self._uart_warning_label.pack(fill="x", padx=PADDING, pady=(0, PADDING_SMALL)) def _run_step_2_program_flash(self) -> bool: """Step 2: Program and verify Flash using program_flash.""" if not self._config: return False files = self._get_selected_files() if not files["bootloader_bin_path"]: self._log_message(" No Bootloader BIN file selected") return False if not files["fsbl_elf_path"]: self._log_message(" No FSBL ELF file selected (required by program_flash)") return False self._log_message(f"Step 2: Programming Flash with {files['bootloader_bin_path']}...") self._log_message(f" FSBL: {files['fsbl_elf_path']}") try: bin_path = Path(files["bootloader_bin_path"]) results = full_flash_program(self._config, bin_path, self._flash_callback) for result in results: status = "✓" if result.success else "✗" self._log_message(f" {status} {result.step}: {result.message}") return all(r.success for r in results) except Exception as e: self._log_message(f" ✗ Flash error: {e}") return False def _flash_callback(self, status: str, message: str) -> None: """Callback for flash programming — drives sub-step UI.""" if status == "phase": self._flash_phase = message phase_names = {"erase": "Erasing", "program": "Programming", "verify": "Verifying", "done": "Done"} label = phase_names.get(message, message) self._log_message(f" ▸ {label}") if hasattr(self, '_sub_step_frame'): self._sub_step_frame.set_phase(message) return if status == "progress": try: pct = int(message.rstrip('%')) except ValueError: pct = -1 phase_label = {"erase": "Erase", "program": "Program", "verify": "Verify"}.get( self._flash_phase, "Flash") self._log_message(f" [{phase_label}] {pct}%") if hasattr(self, '_sub_step_frame'): self._sub_step_frame.set_phase(self._flash_phase, pct) return self._log_message(f" [{status}] {message}") def _jtag_callback(self, status: str, message: str) -> None: """Callback for JTAG scan progress.""" self._log_message(f" [{status}] {message}") def _run_step_3_load_bootloader(self) -> bool: """Step 3: Load bootloader (BIT + ELF) and verify.""" if not self._config: return False files = self._get_selected_files() if not files["bootloader_bit_path"]: self._log_message(" No Bootloader BIT file selected") return False self._log_message(f"Step 3: Loading bootloader (BIT + ELF)...") self._log_message(f" BIT: {files['bootloader_bit_path']}") if files["bootloader_elf_path"]: self._log_message(f" ELF: {files['bootloader_elf_path']}") try: bit_path = Path(files["bootloader_bit_path"]) elf_path = Path(files["bootloader_elf_path"]) if files["bootloader_elf_path"] else bit_path.parent / "temp.elf" results = full_bitstream_program( self._config, bit_path, elf_path, self._bitstream_callback, ) for result in results: status = "✓" if result.success else "✗" self._log_message(f" {status} {result.step}: {result.message}") return all(r.success for r in results) except Exception as e: self._log_message(f" ✗ Bootloader error: {e}") return False def _bitstream_callback(self, status: str, message: str) -> None: """Callback for bitstream programming progress.""" self._log_message(f" [{status}] {message}") def _run_step_4_load_main_program(self) -> bool: """Step 4: Load main program (TFTP upload + reboot + boot verify).""" if not self._config: return False files = self._get_selected_files() if not files["firmware_bin_path"]: self._log_message(" No Firmware BIN file selected") return False all_results: list[bool] = [] # 4a: TFTP upload self._log_message("Step 4a: TFTP upload & verify...") try: bin_path = Path(files["firmware_bin_path"]) tftp_result = tftp_upload_verify( self._config, bin_path, callback=self._tftp_callback, ) status = "✓" if tftp_result.success else "✗" self._log_message(f" {status} {tftp_result.message}") all_results.append(tftp_result.success) except Exception as e: self._log_message(f" ✗ TFTP error: {e}") all_results.append(False) if not all_results[-1]: self._log_message(" Skipping reboot & boot verify (TFTP failed)") return False # 4a2: Post-download Zynq status check self._log_message("Step 4b: Checking Zynq status after download...") try: zynq_status = verify_zynq_status( self._config, uart_available=self._uart_available, callback=self._zynq_status_callback, ) status = "✓" if zynq_status.success else "✗" self._log_message(f" {status} {zynq_status.message}") except Exception as e: self._log_message(f" ✗ Zynq status check error: {e}") # 4c: Reboot self._log_message("Step 4c: Rebooting Zynq...") try: reboot_result = reboot_zynq( self._config, method="auto", callback=self._reboot_callback, ) status = "✓" if reboot_result.success else "✗" self._log_message(f" {status} {reboot_result.message}") all_results.append(reboot_result.success) except Exception as e: self._log_message(f" ✗ Reboot error: {e}") all_results.append(False) if not all_results[-1]: self._log_message(" Skipping boot verify (reboot failed)") return False # 4d: Verify boot self._log_message("Step 4d: Verifying boot...") try: boot_result = verify_boot( self._config, callback=self._boot_callback, ) status = "✓" if boot_result.success else "✗" self._log_message(f" {status} {boot_result.message}") if boot_result.ip_reachable: self._log_message(f" IP {self._config.zynq_ip} is reachable") if boot_result.serial_found: self._log_message(f" Boot info: {boot_result.boot_info}") all_results.append(boot_result.success) except Exception as e: self._log_message(f" ✗ Boot verify error: {e}") all_results.append(False) return all(all_results) def _tftp_callback(self, status: str, message: str) -> None: """Callback for TFTP progress.""" self._log_message(f" [{status}] {message}") def _reboot_callback(self, status: str, message: str) -> None: """Callback for reboot progress.""" self._log_message(f" [{status}] {message}") def _boot_callback(self, status: str, message: str) -> None: """Callback for boot verification progress.""" self._log_message(f" [{status}] {message}") def _zynq_status_callback(self, status: str, message: str) -> None: """Callback for post-download Zynq status check.""" self._log_message(f" [{status}] {message}") # ── Run All Steps ────────────────────────────────────────── def _update_step_dependencies(self) -> None: """Update step run-ability based on dependency status. All steps are runnable independently. """ for i, step in enumerate(self._steps): step.set_can_run(True) status = self._step_statuses.get(i, "pending") if status == "pending": step.set_status("pending") elif status == "complete": step.set_status("complete") elif status == "error": step.set_status("error") def _execute_step(self, index: int) -> None: """Execute a single step independently. Args: index: Step index (0-based). """ if self._is_running: return if index >= len(self._steps): return self._is_running = True self._run_btn.configure(state="disabled", text="Running...") self._progress.reset() # Reset status for this step only self._step_statuses[index] = "pending" self._steps[index].set_status("pending") self._progress.set_value(0.0) # Start background worker thread with single_step=True self._worker_thread = threading.Thread( target=self._run_steps_worker, daemon=True, args=(index, True), # single_step=True ) self._worker_thread.start() def _set_step_status(self, index: int, status: str) -> None: """Update the status of a step in the UI. Args: index: Step index (0-based). status: New status string. """ self._step_statuses[index] = status self._steps[index].set_status(status) def _run_all_steps(self) -> None: """Execute all workflow steps sequentially in a background thread.""" if self._is_running: return self._is_running = True self._run_btn.configure(state="disabled", text="Running...") self._progress.reset() # Reset all step statuses for i in range(len(self._steps)): self._step_statuses[i] = "pending" self._steps[i].set_status("pending") # Start background worker thread self._worker_thread = threading.Thread( target=self._run_steps_worker, daemon=True, args=(0, False), # single_step=False ) self._worker_thread.start() def _run_steps_worker(self, start_index: int = 0, single_step: bool = False) -> None: """Worker method that runs workflow steps in the background. Args: start_index: Index of the first step to run (0-based). single_step: If True, only run the step at start_index. """ step_funcs = [ self._run_step_1_check_environment, self._run_step_2_program_flash, self._run_step_3_load_bootloader, self._run_step_4_load_main_program, ] step_timeout_keys = [ ("check_env", 30), ("flash_program", 600), ("bitstream", 60), ("tftp_reboot", 120), ] # Start UART monitor if available self._start_uart_monitor() # Determine end index based on single_step flag end_index = start_index + 1 if single_step else len(step_funcs) total = end_index - start_index results: list[bool] = [] for i in range(start_index, end_index): step_idx = i - start_index self._set_step_status(i, "running") # Step 2: expand sub-step frame and reset if i == 1 and hasattr(self, '_sub_step_frame'): self._sub_step_frame.reset() self._sub_step_frame.set_expanded(True) self._flash_phase = "" # Log step timeout estimate timeout_key, default_timeout = step_timeout_keys[i] if i < len(step_timeout_keys) else ("", 30) step_timeout = self._config.step_timeouts.get(timeout_key, default_timeout) if self._config else default_timeout self._log_message(f" ⏱ Step {i+1} timeout: {step_timeout}s") self._progress.set_value(step_idx / total) t_step_start = time.time() try: success = step_funcs[i]() results.append(success) self._set_step_status(i, "complete" if success else "error") elapsed = time.time() - t_step_start self._log_message(f" ⏱ Step {i+1} completed in {elapsed:.0f}s") # Step 2: mark sub-steps based on result if i == 1 and hasattr(self, '_sub_step_frame'): if success: self._sub_step_frame.set_phase("done") else: self._sub_step_frame.set_phase("error") except Exception as e: results.append(False) self._set_step_status(i, "error") self._log_message(f" Exception in step {i+1}: {e}") self._progress.set_value((step_idx + 1) / total) # Stop on first failure when running all steps if not single_step and not success: break # Inter-step delay (skip after last step) if i < len(step_funcs) - 1: delay = self._config.inter_step_delay if self._config else 2 self._log_message(f" ⏳ Waiting {delay}s before next step...") time.sleep(delay) self._progress.set_value(1.0) self._is_running = False self._run_btn.configure(state="normal", text="▶ Run All Steps") # Update dependency statuses self._update_step_dependencies() # Set final status message if results: all_ok = all(results) any_fail = any(not r for r in results) if all_ok: self._status.set_status("Workflow complete — all steps passed", "success") elif any_fail: failed_count = results.count(False) self._status.set_status( f"Workflow complete — {failed_count}/{len(results)} step(s) failed", "warning", ) else: self._status.set_status("Workflow complete", "info") else: self._status.set_status("Workflow complete", "info") # Save config after running self._save_config() # Stop UART monitor self._stop_uart_monitor() # ── UART Monitor ────────────────────────────────────────── def _start_uart_monitor(self) -> None: """Start background serial monitoring if UART is available.""" if not self._uart_available or not self._config: return port = self._config.serial_port if not port: return self._uart_monitor = UartMonitor(port, self._config.serial_baudrate) self._uart_monitor.on_line = lambda line: self._log_message(f" [UART] {line}") self._uart_monitor.on_boot_info = lambda info: ( self._log_message(f" [UART] Boot detected: IP={info.ip_address}, Ver={info.version}") if info.ip_address or info.version else None ) self._uart_monitor.on_error = lambda e: self._log_message(f" [UART] Error: {e}") if self._uart_monitor.start(): self._log_message(" [UART] Serial monitor started") def _stop_uart_monitor(self) -> None: """Stop background serial monitoring.""" if self._uart_monitor: self._uart_monitor.stop() self._uart_monitor = None # ── Serial ───────────────────────────────────────────────── def _refresh_ports(self) -> None: """Refresh the serial port list.""" ports = detect_serial_ports() self._port_menu.configure(values=[""] + [p.device for p in ports]) self._log_message(f"Found {len(ports)} serial port(s)") def _refresh_ports_initial(self) -> None: """Refresh the serial port list on startup (silent, no log).""" ports = detect_serial_ports() self._port_menu.configure(values=[""] + [p.device for p in ports]) # Auto-select if only one port found and config has one if ports and self._config and self._config.serial_port: devices = [p.device for p in ports] if self._config.serial_port in devices: self._port_var.set(self._config.serial_port) def _on_close(self) -> None: """Handle window close event: save config and cleanup.""" self._stop_uart_monitor() if self._config: self._sync_config_from_ui() # Sync UI values first # Save to user config file (config.yaml) user_config_path = self._get_user_config_path() self._config._config_path = user_config_path try: self._config.save() except Exception: pass self.destroy() def _read_serial(self) -> None: """Read and display serial output.""" port = self._port_var.get() if not port: self._log_message("No serial port selected") return self._log_message(f"Reading from {port}...") try: import serial with serial.Serial(port, self._config.serial_baudrate if self._config else 115200, timeout=5) as ser: ser.reset_input_buffer() lines = [] while ser.in_waiting: line = ser.readline().decode("utf-8", errors="replace").strip() if line: lines.append(line) if lines: info = parse_boot_output(lines) self._log_message(f" Parsed: IP={info.ip_address}, Ver={info.version}") for line in lines[-10:]: self._log_message(f" > {line}") else: self._log_message(" No data received") except Exception as e: self._log_message(f" Error: {e}") def _test_serial_version(self) -> None: """Test serial port by sending 'ver()' command and parsing response.""" port = self._port_var.get() if not port: self._log_message("No serial port selected") return self._log_message(f"Testing version on {port}...") self._test_version_btn.configure(state="disabled", text="Testing...") # Run in background thread def test_version_thread(): try: from serial_monitor import test_serial_version success, message, version = test_serial_version( port, self._config.serial_baudrate if self._config else 115200, ) if success: if version: self._log_message(f" ✓ {message}") self._log_message(f" ✓ Version detected: {version}") self._status.set_status(f"Version: {version}", "success") else: self._log_message(f" ✓ {message}") self._status.set_status("Port responded", "info") else: self._log_message(f" ✗ {message}") self._status.set_status(message, "error") except Exception as e: self._log_message(f" ✗ Error: {e}") self._status.set_status(f"Error: {e}", "error") finally: # Re-enable button self.after(0, lambda: self._test_version_btn.configure(state="normal", text="Test Version")) threading.Thread(target=test_version_thread, daemon=True).start() # ── Vitis Check ──────────────────────────────────────────── def _check_vitis(self) -> None: """Check Vitis/Vivado on startup.""" if not self._config: return result = check_vitis(self._config) status_dict = get_tool_status_dict(result) if result.is_ready: self._vitis_status.configure(text="Vitis: Ready", text_color=SUCCESS_COLOR) self._log_message("Vitis/Vivado tools available") else: self._vitis_status.configure(text="Vitis: Partial", text_color=WARNING_COLOR) self._log_message(f"Vitis issues: {'; '.join(result.errors)}") # ── Entry Point ──────────────────────────────────────────────── def main() -> None: """Launch the Zynq Flasher GUI application.""" app = MainWindow() app.mainloop() if __name__ == "__main__": main()