Files
Zynq_Flasher/src/gui/main_window.py
T
Jeremy Shen 699ad82834 🐛 fix: sub-step default expanded, real-time checkbox, startup freeze, color sync
1. SubStepFrame defaults to expanded (▼), not collapsed
2. erase_all checkbox has command=_on_erase_toggle → immediate config update
3. _deferred_init runs _check_vitis/_refresh_ports in background thread
   Fixes UI freeze during startup (subprocess.run for tool versions)
4. Sub-step colors now match StepHeader:
   - pending: gray ACCENT_PENDING (#888888)
   - running: blue CIRCLE_RUNNING (#0078D4) with pulse animation
   - complete: green ACCENT_COMPLETE (#107C10)
   - error: red ACCENT_ERROR (#D13438)
   - Running sub-step pulses between light/dark blue like StepHeader
2026-06-10 14:11:59 +08:00

1274 lines
48 KiB
Python

"""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()