Files
Zynq_Flasher/src/gui/main_window.py
T
yuysh 5ed195e2d6 fix: skip marking, fixed panel ratio, dialog hints, 30s boot, ping+UDP fallback
- TftpSubStepFrame.set_step_skipped(): ◌ Skip in WARNING color
- _reload_config(): suppress callbacks to avoid duplicate log/status wipe
- _log_all_file_info(): removed clear_info(), updates in-place per-key
- Removed duplicate _log_file_info method
- main_frame grid: uniform col group for strict 7:3 ratio
- FileSelector browse title shows expected file type hint
- boot_wait_delay: default 10→30s
- Step 4 IP fallback: ping first, then UDP scan, extra boot_delay retry
2026-06-12 16:10:28 +08:00

2153 lines
88 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, tftp_download, tftp_upload_verify, TftpResult, _compute_crc32
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 file_utils import compute_file_info, FileInfo
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,
TftpSubStepFrame,
UartMonitorWindow,
)
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._uart_window: UartMonitorWindow | None = None
self._flash_phase: str = ""
self._tftp_phase: str = ""
# StringVar references for config sync
self._ip_string_var: ctk.StringVar | None = None
self._load_config()
self._build_ui()
# Validate file paths — clear any that don't exist on this machine
if self._config and self._config._config_path:
try:
cleared = self._config.validate_paths()
if cleared:
joined = ", ".join(cleared)
self._log_message(f" ⚠ Cleared stale paths from config: {joined}")
# Also clear the UI selectors so stale paths don't
# get re-saved by subsequent _auto_save_config calls
_selector_map = {
"bootloader_bit_path": self._bit_selector,
"bootloader_elf_path": self._elf_selector,
"bootloader_bin_path": self._bootloader_bin_selector,
"fsbl_elf_path": self._fsbl_selector,
"firmware_bin_path": self._firmware_bin_selector,
}
for attr in cleared:
sel = _selector_map.get(attr)
if sel:
sel.set_path("")
self._config.save()
self._log_message(f" ✓ Config saved after clearing stale paths")
except Exception as e:
self._log_message(f" ✗ Config validation failed: {e}")
# Log file metadata for all configured paths
self._log_all_file_info()
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 a blank one on disk.
If config.yaml does not exist or cannot be read, a new one
is created with default values and written to disk immediately.
"""
user_config_path = self._get_user_config_path()
if self._config_path:
try:
self._config = Config.from_file(self._config_path)
if self._config_path.name == "default_config.yaml":
self._config._config_path = user_config_path
return
except Exception:
pass # Fall through and create default
# Config file missing or unreadable — create a blank one
self._config = Config.from_default()
self._config._config_path = user_config_path
try:
self._config.save()
except Exception:
pass # Will be saved later by _auto_save_config
def _reload_config(self) -> None:
"""Re-read config.yaml and update UI selectors with latest paths.
Reads the file on disk, applies values to the internal Config
object, then updates every UI selector so that changes made
outside the GUI (or between runs) take effect immediately.
"""
user_path = self._get_user_config_path()
if not user_path.exists():
return
try:
fresh = Config.from_file(user_path)
self._config = fresh
# Update UI selectors — suppress callbacks to avoid
# redundant file-info logging and status-panel rewrites.
_sels = [
self._bit_selector, self._elf_selector,
self._bootloader_bin_selector, self._fsbl_selector,
self._firmware_bin_selector,
]
for s in _sels:
s._suppress_callback = True
for attr, selector in [
("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector),
("bootloader_bin_path", self._bootloader_bin_selector),
("fsbl_elf_path", self._fsbl_selector),
("firmware_bin_path", self._firmware_bin_selector),
]:
val = getattr(fresh, attr, "")
if val:
resolved = fresh.resolve_path(val)
selector.set_relative_path(val)
selector.set_path(str(resolved))
for s in _sels:
s._suppress_callback = False
# Update IP
if self._ip_string_var:
self._ip_string_var.set(fresh.zynq_ip)
# Update xilinx path
if hasattr(fresh, 'xilinx_path') and self._xilinx_path_var:
self._xilinx_path_var.set(fresh.xilinx_path)
except Exception:
pass # Keep existing config on error
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.
File paths are synced as relative paths (from the Config
object's perspective); the FileSelector stores both the
absolute path (for internal use) and the relative path
(for saving to YAML).
All attribute accesses are defensive — widgets may not exist
yet during construction (e.g. _port_var created in _build_serial).
"""
if not self._config:
return
# Sync IP address (may not exist yet during _build_config_panel)
if self._ip_string_var:
self._config.zynq_ip = self._ip_string_var.get()
# Sync Xilinx Kit path
if hasattr(self, '_xilinx_path_var'):
self._config.xilinx_path = self._xilinx_path_var.get()
# Sync serial port (may not exist yet during _build_config_panel)
if hasattr(self, '_port_var'):
self._config.serial_port = self._port_var.get()
# Sync file paths from FileSelectors
# Always derive relative from absolute — the _relative_path
# stored in FileSelector can be stale after Browse.
for attr, selector in [
("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector),
("bootloader_bin_path", self._bootloader_bin_selector),
("fsbl_elf_path", self._fsbl_selector),
("firmware_bin_path", self._firmware_bin_selector),
]:
abs_path = selector.get_path()
if abs_path:
setattr(self._config, attr, self._config._relative_path(abs_path))
else:
setattr(self._config, attr, "")
# Sync erase checkbox (may not exist yet)
if hasattr(self, '_erase_cb_var'):
self._config.erase_all = self._erase_cb_var.get()
# Sync download verify checkbox
if hasattr(self, '_download_verify_var'):
self._config.download_verify = self._download_verify_var.get()
def _auto_save_config(self) -> None:
"""Auto-save config to disk after UI changes.
Ensures that changes made in the UI (file paths, IP, etc.)
are persisted immediately so that _reload_config() picks them
up on the next step execution.
"""
if not self._config:
return
self._sync_config_from_ui()
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path
try:
self._config.save()
except Exception:
pass # Silently fail — user can manually save
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()
def _on_download_verify_toggle(self) -> None:
"""Update config.download_verify immediately when checkbox toggles."""
if self._config:
self._config.download_verify = self._download_verify_var.get()
def _browse_xilinx_path(self) -> None:
"""Open directory dialog to select Xilinx root folder."""
from tkinter import filedialog
path = filedialog.askdirectory(title="Select Xilinx Root Directory")
if path:
self._xilinx_path_var.set(path)
self._on_xilinx_path_changed()
# Re-check tools with new path
self._check_vitis()
def _on_xilinx_path_changed(self) -> None:
"""Handle Xilinx path entry change — sync config immediately."""
if self._config:
self._config.xilinx_path = self._xilinx_path_var.get()
def _on_ip_changed(self) -> None:
"""Handle IP address entry change — sync config immediately."""
if self._config:
self._config.zynq_ip = self._ip_string_var.get()
def _on_file_selected(self, path: str) -> None:
"""Handle file selection — sync, auto-save, and log file info."""
from file_utils import compute_file_info
self._auto_save_config()
if path:
info = compute_file_info(path)
self._log_file_info(info)
# Update status info panel
self._log_all_file_info()
def _log_file_info(self, info: FileInfo) -> None:
"""Log file metadata in the log window.
Args:
info: FileInfo dataclass with computed metadata.
"""
filename = os.path.basename(info.path) if info.path else "(unknown)"
if not info.exists:
self._log_message(f" ⚠ {filename}: file not found")
return
self._log_message(
f" ✓ {filename}: "
f"{info.size_human} | CRC {info.crc32_hex} | {info.mtime_iso}"
)
def _log_all_file_info(self) -> None:
"""Update file metadata in the status info panel (preserves other info).
Each file line is updated in-place via ``set_info()`` — existing
Zynq info, tool versions, etc. are left untouched.
"""
if not self._config:
return
paths = [
("FSBL ELF", self._config.fsbl_elf_path),
("Bitstream", self._config.bootloader_bit_path),
("Bootloader ELF", self._config.bootloader_elf_path),
("Bootloader BIN", self._config.bootloader_bin_path),
("Firmware BIN", self._config.firmware_bin_path),
]
for label, path in paths:
if not path:
self._status.set_info(label, "(not set)")
continue
resolved = self._config.resolve_path(path)
info = compute_file_info(resolved)
if info.exists:
self._status.set_info(
label,
f"{info.size_human} CRC {info.crc32_hex} {info.mtime_iso}"
)
else:
self._status.set_info(label, "(not found)")
# ── UI Construction ────────────────────────────────────────
def _build_ui(self) -> None:
"""Build the complete UI layout."""
# Scrollable main container — scrollbar appears when content overflows
self.main_frame = ctk.CTkScrollableFrame(self)
self.main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE)
self.main_frame.grid_rowconfigure(1, weight=1)
self.main_frame.grid_columnconfigure(0, weight=7, uniform="main_col")
self.main_frame.grid_columnconfigure(1, weight=3, uniform="main_col")
# ── Header ──
self._build_header(self.main_frame)
# ── Left Panel: Workflow Steps ──
left_frame = ctk.CTkFrame(self.main_frame)
left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, PADDING))
left_frame.grid_rowconfigure(1, 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(self.main_frame)
right_frame.grid(row=1, column=1, sticky="nsew", padx=(PADDING, 0))
right_frame.grid_rowconfigure(3, weight=3) # status panel — main info area
right_frame.grid_columnconfigure(0, weight=1)
self._build_config_panel(right_frame)
self._build_serial_panel(right_frame)
self._build_utility_panel(right_frame)
self._build_status_panel(right_frame)
# ── Maximize / resize handling ──
self.bind("<Configure>", self._on_window_resize)
self._maximized = False
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(0, 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 (skip rows occupied by sub-steps)
if i > 0:
# Dividers go between main steps only, not between step and sub-step
# Row calculation: account for sub-steps from previous steps
prev_steps_with_substeps = sum(1 for j in range(i) if j in (1, 3))
divider_row = i * 2 + 1 + prev_steps_with_substeps
divider = ctk.CTkFrame(
panel,
height=1,
fg_color=CONNECTOR_COLOR,
)
divider.grid(
row=divider_row, column=0,
sticky="ew",
padx=PADDING,
pady=(2, 0),
)
# Position step (account for sub-steps from previous steps)
prev_steps_with_substeps = sum(1 for j in range(i) if j in (1, 3))
step_row = i * 2 + 1 + prev_steps_with_substeps
step.grid(
row=step_row, 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=step_row + 1, column=0,
sticky="nsew",
padx=PADDING_SMALL + 16,
pady=(0, 2),
)
# Step 4: add collapsible sub-step frame
if i == 3:
self._tftp_sub_step_frame = TftpSubStepFrame(panel)
self._tftp_sub_step_frame.grid(
row=step_row + 1, column=0,
sticky="nsew",
padx=PADDING_SMALL + 16,
pady=(0, 2),
)
# ── Progress indicator ──
self._progress = ProgressIndicator(panel)
self._progress.grid(
row=11, column=0,
sticky="ew",
padx=PADDING,
pady=(PADDING, PADDING_SMALL),
)
panel.grid_rowconfigure(11, minsize=40)
# ── 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=12, column=0,
sticky="nsew",
padx=PADDING,
pady=(0, 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,
)
self._log_panel = panel
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(9, weight=1)
panel.grid_columnconfigure(0, weight=1) # let content stretch
title = ctk.CTkLabel(
panel,
text="Configuration",
font=FONT_HEADING,
)
title.grid(row=0, column=0, padx=PADDING, pady=PADDING)
# ── Xilinx Kit path ──
xilinx_frame = ctk.CTkFrame(panel)
xilinx_frame.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
xilinx_frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(xilinx_frame, text="Xilinx Kit:", font=FONT_BODY).grid(row=0, column=0, sticky="w")
self._xilinx_path_var = ctk.StringVar(value=self._config.xilinx_path)
self._xilinx_entry = ctk.CTkEntry(xilinx_frame, textvariable=self._xilinx_path_var)
self._xilinx_entry.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL)
# Bind real-time sync so changes take effect immediately
self._xilinx_path_var.trace_add("write", lambda *args: self._on_xilinx_path_changed())
ctk.CTkButton(
xilinx_frame, text="Browse", font=FONT_SMALL, width=70,
command=self._browse_xilinx_path,
).grid(row=0, column=2, padx=(0, PADDING_SMALL))
# IP address
ip_frame = ctk.CTkFrame(panel)
ip_frame.grid(row=2, 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)
# Bind real-time sync
self._ip_string_var.trace_add("write", lambda *args: self._on_ip_changed())
# FSBL ELF path (First Stage Bootloader — initialises PS, forces JTAG boot)
self._fsbl_selector = FileSelector(
panel,
label_text="FSBL ELF (.elf):",
file_types=[("ELF files", "*.elf"), ("All files", "*")],
callback=self._on_file_selected,
)
self._fsbl_selector._suppress_callback = True
if self._config.fsbl_elf_path:
resolved = self._config.resolve_path(self._config.fsbl_elf_path)
self._fsbl_selector.set_relative_path(self._config.fsbl_elf_path)
self._fsbl_selector.set_path(str(resolved))
self._fsbl_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# BIT path (Bootloader)
self._bit_selector = FileSelector(
panel,
label_text="Bootloader BIT (.bit):",
file_types=[("BIT files", "*.bit"), ("All files", "*")],
callback=self._on_file_selected,
)
self._bit_selector._suppress_callback = True
if self._config.bootloader_bit_path:
resolved = self._config.resolve_path(self._config.bootloader_bit_path)
self._bit_selector.set_relative_path(self._config.bootloader_bit_path)
self._bit_selector.set_path(str(resolved))
self._bit_selector.grid(row=4, 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", "*")],
callback=self._on_file_selected,
)
self._elf_selector._suppress_callback = True
if self._config.bootloader_elf_path:
resolved = self._config.resolve_path(self._config.bootloader_elf_path)
self._elf_selector.set_relative_path(self._config.bootloader_elf_path)
self._elf_selector.set_path(str(resolved))
self._elf_selector.grid(row=5, 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", "*")],
callback=self._on_file_selected,
)
self._bootloader_bin_selector._suppress_callback = True
if self._config.bootloader_bin_path:
resolved = self._config.resolve_path(self._config.bootloader_bin_path)
self._bootloader_bin_selector.set_relative_path(self._config.bootloader_bin_path)
self._bootloader_bin_selector.set_path(str(resolved))
self._bootloader_bin_selector.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# Flash options
flash_frame = ctk.CTkFrame(panel)
flash_frame.grid(row=7, 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))
# Download verify checkbox (Step 4.2)
self._download_verify_var = ctk.BooleanVar(value=self._config.download_verify)
self._download_verify_cb = ctk.CTkCheckBox(
flash_frame,
text="下载校验 + CRC (Step 4.2)",
variable=self._download_verify_var,
font=FONT_SMALL,
command=self._on_download_verify_toggle,
)
self._download_verify_cb.grid(row=2, 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", "*")],
callback=self._on_file_selected,
)
self._firmware_bin_selector._suppress_callback = True
if self._config.firmware_bin_path:
resolved = self._config.resolve_path(self._config.firmware_bin_path)
self._firmware_bin_selector.set_relative_path(self._config.firmware_bin_path)
self._firmware_bin_selector.set_path(str(resolved))
self._firmware_bin_selector.grid(row=8, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# Enable callbacks now that all selectors are created
for sel in (self._fsbl_selector, self._bit_selector, self._elf_selector,
self._bootloader_bin_selector, self._firmware_bin_selector):
sel._suppress_callback = False
# Now sync and save config (all selectors exist)
self._auto_save_config()
# Save button
save_btn = ctk.CTkButton(
panel,
text="Save Config",
font=FONT_SMALL,
command=self._save_config,
)
save_btn.grid(row=9, 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_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,
command=self._on_port_changed,
)
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)
# Open UART button
uart_btn = ctk.CTkButton(
panel,
text="Open UART",
font=FONT_BODY,
command=self._read_serial,
)
uart_btn.grid(row=2, column=0, padx=PADDING, pady=PADDING_SMALL)
def _build_utility_panel(self, parent: ctk.CTkFrame) -> None:
"""Build the utility tools panel."""
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
panel.grid(
row=2, column=0, sticky="nsew",
padx=PADDING, pady=PADDING,
)
panel.grid_columnconfigure((0, 1), weight=1)
title = ctk.CTkLabel(
panel,
text="Utilities",
font=FONT_HEADING,
)
title.grid(row=0, column=0, columnspan=2, padx=PADDING, pady=PADDING)
# Row 1: Reboot buttons (both red)
self._jtag_reboot_btn = ctk.CTkButton(
panel,
text="Reboot (JTAG)",
font=FONT_BODY,
fg_color=DANGER_COLOR,
hover_color="#B92B2F",
command=self._jtag_reboot,
)
self._jtag_reboot_btn.grid(row=1, column=0, padx=PADDING_SMALL, pady=PADDING_SMALL, sticky="ew")
self._udp_reboot_btn = ctk.CTkButton(
panel,
text="Reboot (UDP)",
font=FONT_BODY,
fg_color=DANGER_COLOR,
hover_color="#B92B2F",
command=self._udp_reboot,
)
self._udp_reboot_btn.grid(row=1, column=1, padx=PADDING_SMALL, pady=PADDING_SMALL, sticky="ew")
# Row 2: Version buttons
self._udp_version_btn = ctk.CTkButton(
panel,
text="Version (UDP)",
font=FONT_BODY,
command=self._udp_version,
)
self._udp_version_btn.grid(row=2, column=0, padx=PADDING_SMALL, pady=PADDING_SMALL, sticky="ew")
self._test_version_btn = ctk.CTkButton(
panel,
text="Version (Serial)",
font=FONT_BODY,
command=self._test_serial_version,
)
self._test_version_btn.grid(row=2, column=1, padx=PADDING_SMALL, pady=PADDING_SMALL, sticky="ew")
def _build_status_panel(self, parent: ctk.CTkFrame) -> None:
"""Build the status display panel — uses full available space."""
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
panel.grid(
row=3, column=0, sticky="nsew",
padx=PADDING, pady=PADDING,
)
panel.grid_rowconfigure((0, 1), weight=1)
panel.grid_columnconfigure(0, weight=1)
self._status = StatusDisplay(panel)
self._status.grid(row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING)
# UART warning — multi-line textbox, wraps long messages instead
# of expanding the right panel horizontally
self._uart_warning = ctk.CTkTextbox(
panel,
font=FONT_BODY,
height=40,
corner_radius=4,
fg_color="transparent",
state="disabled",
wrap="word",
)
self._uart_warning.grid(
row=1, column=0, sticky="ew", padx=PADDING, pady=(0, PADDING_SMALL)
)
self._uart_warning.grid_remove() # 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 absolute paths for immediate backend use.
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...")
if self._config.xilinx_path:
self._log_message(f" Xilinx root: {self._config.xilinx_path}")
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"]:
ver_info = f" [v{tool_info['version']}]" if tool_info.get("version") else ""
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']}{ver_info}{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")
# Show tool versions in status display
for tool_name, tool_info in status_dict["tools"].items():
if tool_info["found"] and tool_info.get("version"):
self._status.set_info(tool_name, f"v{tool_info['version']}")
# Report all found versions for diagnostics
if self._config.xilinx_path:
try:
from vitis_checker import get_all_found_versions
for t in ("xsct", "program_flash", "bootgen"):
vers = get_all_found_versions(self._config.xilinx_path, t)
if len(vers) > 1:
ver_list = ", ".join(f"{v} ({p})" for v, p in vers)
self._log_message(f" {t} versions found: {ver_list}")
except Exception:
pass
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:
detected_part = jtag_result.devices[0].part_number
expected_part = self._config.zynq_part.upper()
# Validate part number matches expected
if detected_part != expected_part:
self._log_message(
f" ✗ Wrong Zynq part: detected {detected_part}, expected {expected_part}"
)
self._jtag_status.configure(
text=f"JTAG: {detected_part}{expected_part}", text_color=ERROR_COLOR
)
return False
self._zynq_jtag_present = True
self._zynq_jtag_info = jtag_result.devices[0]
part = detected_part
# 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)
self._status.set_info("Zynq", part)
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)
return False
# 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,
monitor=self._uart_monitor)
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 (multi-line).
Args:
msg: Warning message to display.
"""
self._uart_warning.configure(state="normal")
self._uart_warning.delete("1.0", "end")
self._uart_warning.insert("1.0", msg)
self._uart_warning.tag_config("warn", foreground=WARNING_COLOR)
self._uart_warning.tag_add("warn", "1.0", "end")
self._uart_warning.configure(state="disabled")
self._uart_warning.grid()
# Auto-size height based on line count
line_count = msg.count("\n") + 1
self._uart_warning.configure(height=max(2, line_count))
def _hide_uart_warning(self) -> None:
"""Hide the persistent UART warning."""
self._uart_warning.grid_remove()
def _on_window_resize(self, event) -> None:
"""Handle window resize / maximize events.
When the window is maximized, scale content to fit one screen
and hide scrollbars. When restored, use normal sizing with
scrollbars on overflow.
"""
try:
window_state = self.state()
is_maximized = window_state == "zoomed"
if is_maximized != self._maximized:
self._maximized = is_maximized
self._adjust_layout_for_maximize()
except Exception:
pass
def _adjust_layout_for_maximize(self) -> None:
"""Scale content and toggle scrollbars based on maximized state."""
if not hasattr(self, 'main_frame'):
return
sf = self.main_frame
has_scrollbars = hasattr(sf, '_scrollbar_y')
if self._maximized:
# Hide scrollbars when maximized
if has_scrollbars:
sf._scrollbar_y.grid_remove()
sf._scrollbar_x.grid_remove()
# Scale down to fit one screen
self._scale_ui(0.6)
# Hide the log panel when maximized to save space
if hasattr(self, '_log_panel'):
self._log_panel.grid_remove()
else:
# Show scrollbars when not maximized
if has_scrollbars:
sf._scrollbar_y.grid()
sf._scrollbar_x.grid()
# Restore normal scale
self._scale_ui(1.0)
# Show the log panel when not maximized
if hasattr(self, '_log_panel'):
self._log_panel.grid()
def _scale_ui(self, factor: float) -> None:
"""Scale UI elements by a factor to fit screen.
Args:
factor: Scaling factor (1.0 = normal, <1.0 = smaller).
"""
# Scale font sizes in key labels
scale_font = lambda orig: (orig[0], max(8, int(orig[1] * factor)), orig[2] if len(orig) > 2 else "")
# Header fonts
if hasattr(self, '_vitis_status'):
self._vitis_status.configure(font=scale_font(FONT_BODY))
if hasattr(self, '_ip_status'):
self._ip_status.configure(font=scale_font(FONT_BODY))
if hasattr(self, '_jtag_status'):
self._jtag_status.configure(font=scale_font(FONT_BODY))
# Workflow panel fonts
if hasattr(self, '_steps'):
for step in self._steps:
if hasattr(step, '_title_label'):
step._title_label.configure(font=scale_font((FONT_BODY[0], 13, "bold")))
if hasattr(step, '_desc_label'):
step._desc_label.configure(font=scale_font(FONT_SMALL))
if hasattr(step, '_action_btn'):
step._action_btn.configure(font=scale_font((FONT_BODY[0], 12)))
# Config panel fonts
if hasattr(self, '_xilinx_entry'):
self._xilinx_entry.configure(font=scale_font(FONT_BODY))
if hasattr(self, '_ip_entry'):
self._ip_entry.configure(font=scale_font(FONT_BODY))
# Log display font
if hasattr(self, '_log_display') and hasattr(self._log_display, '_text_widget'):
self._log_display._text_widget.configure(font=scale_font(FONT_MONO))
# Status display font
if hasattr(self, '_status') and hasattr(self._status, '_status_text'):
self._status._status_text.configure(font=scale_font(FONT_BODY))
# Scale padding/spacing on all frames
if hasattr(self, 'config_frame'):
self.config_frame.configure(padx=int(5 * factor), pady=int(5 * factor))
if hasattr(self, 'workflow_frame'):
self.workflow_frame.configure(padx=int(5 * factor), pady=int(5 * factor))
if hasattr(self, 'log_frame'):
self.log_frame.configure(padx=int(5 * factor), pady=int(5 * factor))
if hasattr(self, 'status_frame'):
self.status_frame.configure(padx=int(5 * factor), pady=int(5 * factor))
# Scale step frame spacing
if hasattr(self, '_steps'):
for step in self._steps:
if hasattr(step, '_inner_frame'):
step._inner_frame.configure(padx=int(5 * factor), pady=int(5 * factor))
def _on_port_changed(self, new_port: str) -> None:
"""Handle serial port selection change — validate immediately.
Args:
new_port: The newly selected serial port path.
"""
if not new_port:
self._uart_available = False
self._show_uart_notification("No port selected")
if self._uart_monitor:
self._stop_uart_monitor()
return
# Attempt to open port to validate
try:
port = new_port
baudrate = self._config.serial_baudrate if self._config else 115200
available, reason = check_uart_available(port, baudrate)
self._uart_available = available
if available:
self._uart_message = reason
self._hide_uart_warning()
self._log_message(f" ✓ UART {port}: {reason}")
# If UART monitor was running, restart on new port
if self._uart_monitor and self._uart_monitor.is_running():
self._stop_uart_monitor()
self._start_uart_monitor()
else:
self._uart_available = False
self._uart_message = reason
self._show_uart_notification(reason)
if self._uart_monitor:
self._stop_uart_monitor()
except Exception as e:
self._uart_available = False
self._show_uart_notification(str(e))
if self._uart_monitor:
self._stop_uart_monitor()
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,
)
dap_fail = False
for result in results:
status = "✓" if result.success else "✗"
self._log_message(f" {status} {result.step}: {result.message}")
if result.output:
# Log tool output for diagnostics (truncated)
output_lines = result.output.strip().splitlines()
for line in output_lines[-20:]: # last 20 lines
self._log_message(f" [xsct] {line}")
if "DAP" in result.message and not result.success:
dap_fail = True
if dap_fail:
self._log_message("")
self._log_message(" ╔══════════════════════════════════════════╗")
self._log_message(" ║ DAP ERROR — JTAG debug port hung ║")
self._log_message(" ║ This is caused by prior flash programming║")
self._log_message(" ║ → Power-cycle the board (unplug/replug) ║")
self._log_message(" ║ → Then re-run Step 3 ║")
self._log_message(" ╚══════════════════════════════════════════╝")
self._log_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 + download verify + CRC + 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
bin_path = Path(files["firmware_bin_path"])
remote_name = self._config.tftp_upload_name
all_results: list[bool] = []
# 4a: TFTP Upload
self._log_message("Step 4a: TFTP upload...")
self._tftp_phase = "Upload"
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_phase("Upload")
try:
tftp_result = tftp_upload(
self._config, bin_path, remote_name,
callback=self._tftp_sub_callback,
)
status = "✓" if tftp_result.success else "✗"
self._log_message(f" {status} {tftp_result.message}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_complete("Upload") if tftp_result.success else self._tftp_sub_step_frame.set_step_error("Upload")
all_results.append(tftp_result.success)
except Exception as e:
self._log_message(f" ✗ TFTP upload error: {e}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_error("Upload")
all_results.append(False)
if not all_results[-1]:
self._log_message(" Skipping download verify (TFTP upload failed)")
return False
# Delay to let Zynq TFTP server stabilize
self._log_message(" ⏳ Waiting 2s before download...")
time.sleep(2)
# 4b+4c: TFTP Download Verify + CRC Check
if not self._config.download_verify:
self._log_message("Step 4b+4c: Download verify & CRC — skipped (disabled in config)")
all_results.append(True) # 4b
all_results.append(True) # 4c
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_skipped("Download Verify")
self._tftp_sub_step_frame.set_step_skipped("CRC Check")
else:
# 4b: Download Verify
self._log_message("Step 4b: TFTP download verify...")
self._tftp_phase = "Download Verify"
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_phase("Download Verify")
try:
download_result = tftp_download(
self._config, remote_name,
callback=self._tftp_sub_callback,
expected_size=bin_path.stat().st_size,
)
status = "✓" if download_result.success else "✗"
self._log_message(f" {status} {download_result.message}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_complete("Download Verify") if download_result.success else self._tftp_sub_step_frame.set_step_error("Download Verify")
all_results.append(download_result.success)
except Exception as e:
self._log_message(f" ✗ TFTP download error: {e}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_error("Download Verify")
all_results.append(False)
if not all_results[-1]:
self._log_message(" Skipping CRC check (download failed)")
else:
# 4c: CRC Check
self._log_message("Step 4c: CRC check...")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_phase("CRC Check")
original_crc = _compute_crc32(bin_path)
downloaded_crc = download_result.crc_local
crc_match = original_crc == downloaded_crc
self._log_message(f" Original CRC: {original_crc:#010x}")
self._log_message(f" Downloaded CRC: {downloaded_crc:#010x}")
if crc_match:
self._log_message(" ✓ CRC match")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_complete("CRC Check")
all_results.append(True)
else:
self._log_message(" ✗ CRC mismatch")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_error("CRC Check")
all_results.append(False)
if not all_results[-1]:
self._log_message(" Skipping reboot (download/CRC failed)")
return False
# 4d: Reboot
self._log_message("Step 4d: Rebooting Zynq...")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_phase("Reboot")
try:
reboot_result = reboot_zynq(
self._config,
method="jtag",
callback=self._reboot_callback,
)
status = "✓" if reboot_result.success else "✗"
self._log_message(f" {status} {reboot_result.message}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_complete("Reboot") if reboot_result.success else self._tftp_sub_step_frame.set_step_error("Reboot")
all_results.append(reboot_result.success)
except Exception as e:
self._log_message(f" ✗ Reboot error: {e}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_error("Reboot")
all_results.append(False)
if not all_results[-1]:
self._log_message(" Skipping boot verify (reboot failed)")
return False
# Wait for board to boot — UART if available, else config delay
boot_delay = self._config.boot_wait_delay
# Check both main monitor and UART window monitor
uart_alive = False
_uart_src = None # source of latest_info
if self._uart_monitor and self._uart_monitor.is_running():
uart_alive = True
_uart_src = self._uart_monitor
elif (self._uart_window and self._uart_window.winfo_exists()
and self._uart_window.is_monitoring):
uart_alive = True
_uart_src = self._uart_window
discovered_ip: str = ""
if uart_alive and _uart_src:
self._log_message(f" ⏳ Waiting for UART boot signal (max {boot_delay}s)...")
elapsed = 0.0
boot_seen = False # Phase 1 guard — log once, keep waiting for IP
while elapsed < boot_delay:
info = (
_uart_src.latest_info
if hasattr(_uart_src, "latest_info")
else _uart_src.get_latest_info()
)
# Phase 2: IP address found — definitive boot confirmation
if info and info.ip_address:
self._log_message(
f" ✓ Boot detected after {elapsed:.1f}s"
f" (IP={info.ip_address}, Ver={info.version})"
)
discovered_ip = info.ip_address
break
# Phase 1: boot signal (version) seen but no IP yet — keep polling
if info and info.version and not boot_seen:
boot_seen = True
self._log_message(
f" ✓ Boot signal after {elapsed:.1f}s"
f" (Ver={info.version}), waiting for IP..."
)
time.sleep(0.5)
elapsed += 0.5
else:
self._log_message(f" ⏱ UART timeout — no IP in {boot_delay}s")
# Final check: IP may have arrived after the last poll
info = (
_uart_src.latest_info
if hasattr(_uart_src, "latest_info")
else _uart_src.get_latest_info()
)
if info and info.ip_address:
self._log_message(f" IP recovered from latest UART info: {info.ip_address}")
discovered_ip = info.ip_address
else:
self._log_message(f" ⏳ UART unavailable — sleeping {boot_delay}s for boot...")
time.sleep(boot_delay)
# Fallback: parse IP from accumulated UART buffer (lines may arrive
# after boot wait loop exited — e.g. "Board IP: 192.168.100.13")
if not discovered_ip and _uart_src:
all_lines = (
_uart_src.all_lines
if hasattr(_uart_src, "all_lines")
else []
)
if all_lines:
parsed = parse_boot_output(all_lines)
if parsed.ip_address:
discovered_ip = parsed.ip_address
self._log_message(f" IP from UART buffer: {discovered_ip}")
# Fallback: if no IP from UART, try ping → UDP scan, then extra delay
if not discovered_ip:
self._log_message(" ⏳ Network stack — waiting 3s...")
time.sleep(3)
# Try ping configured IP first (fast, reliable)
self._log_message(f" Pinging {self._config.zynq_ip}...")
ok, _ = ping_ip(self._config.zynq_ip, timeout=2)
if ok:
discovered_ip = self._config.zynq_ip
self._log_message(f" ✓ Zynq responds to ping at {discovered_ip}")
else:
# Fallback: UDP broadcast scan
self._log_message(" Scanning for Zynq IP (192.168.100.1130)...")
discovered_ip = self._discover_zynq_ip()
if discovered_ip:
self._log_message(f" ✓ Found Zynq at {discovered_ip}")
else:
self._log_message(" ⚠ Zynq not found — extra boot delay...")
self._log_message(f" ⏳ Waiting {boot_delay}s for late boot...")
time.sleep(boot_delay)
# Retry ping + scan after extra delay
ok2, _ = ping_ip(self._config.zynq_ip, timeout=2)
if ok2:
discovered_ip = self._config.zynq_ip
self._log_message(f" ✓ Zynq responds to ping at {discovered_ip}")
else:
discovered_ip = self._discover_zynq_ip()
if discovered_ip:
self._log_message(f" ✓ Found Zynq at {discovered_ip}")
else:
self._log_message(" ⚠ Zynq not found on any scanned IP")
if discovered_ip and discovered_ip != self._config.zynq_ip:
self._log_message(
f" IP changed: {self._config.zynq_ip}{discovered_ip}"
)
self._config.zynq_ip = discovered_ip
if self._ip_string_var:
self._ip_string_var.set(discovered_ip)
# 4e: Verify boot
self._log_message("Step 4e: Verifying boot...")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_phase("Boot Verify")
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}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_complete("Boot Verify") if boot_result.success else self._tftp_sub_step_frame.set_step_error("Boot Verify")
all_results.append(boot_result.success)
except Exception as e:
self._log_message(f" ✗ Boot verify error: {e}")
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_error("Boot Verify")
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 _tftp_sub_callback(self, status: str, message: str) -> None:
"""Callback for TFTP sub-step progress — drives TftpSubStepFrame."""
if status == "progress":
try:
pct = int(message.rstrip('%'))
except ValueError:
pct = -1
if hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_phase(self._tftp_phase, pct)
return
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
# Always re-read config.yaml before running
self._reload_config()
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
# Always re-read config.yaml before running
self._reload_config()
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 = ""
# Step 4: expand sub-step frame and reset
if i == 3 and hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.reset()
self._tftp_sub_step_frame.set_expanded(True)
# 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")
# Step 4: sub-steps already marked individually inside _run_step_4;
# avoid set_phase which would restart a pulse on Boot Verify.
if i == 3 and hasattr(self, '_tftp_sub_step_frame'):
if not success:
self._tftp_sub_step_frame.set_step_error("Boot Verify")
except Exception as e:
results.append(False)
self._set_step_status(i, "error")
self._log_message(f" Exception in step {i+1}: {e}")
# Mark sub-step frames as error too
if i == 1 and hasattr(self, '_sub_step_frame'):
self._sub_step_frame.set_phase("error")
if i == 3 and hasattr(self, '_tftp_sub_step_frame'):
self._tftp_sub_step_frame.set_step_error("Boot Verify")
self._progress.set_value((step_idx + 1) / total)
# Stop on first failure when running all steps
if not single_step and not success:
# Special case: Step 2 pass → Step 3 DAP fail = board needs power cycle
if i == 2 and results[0] and not success:
self._log_message("")
self._log_message(" ┌─────────────────────────────────────────┐")
self._log_message(" │ Step 3 failed after flash programming. │")
self._log_message(" │ This is normal — program_flash leaves │")
self._log_message(" │ the JTAG DAP in error state. │")
self._log_message(" │ → Power-cycle the board, then re-run │")
self._log_message(" │ Step 3 (Load Bootloader) alone. │")
self._log_message(" └─────────────────────────────────────────┘")
self._log_message("")
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 UART window is already monitoring, don't start a second instance
if (self._uart_window
and self._uart_window.winfo_exists()
and self._uart_window.is_monitoring):
self._log_message(" [UART] Already monitoring via UART window")
return
if 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={info.boot_mode}, IP={info.ip_address}, Ver={info.version}"
) if any([info.boot_mode, info.ip_address, 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")
else:
self._log_message(" [UART] Serial monitor failed to start (port may be busy)")
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._uart_window and self._uart_window.winfo_exists():
self._uart_window.destroy()
self._uart_window = None
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:
"""Open the UART Monitor window."""
port = self._port_var.get()
baudrate = self._config.serial_baudrate if self._config else 115200
self._uart_window = UartMonitorWindow(
self, port=port, baudrate=baudrate,
on_log=self._log_message,
)
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,
monitor=self._uart_monitor,
)
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="Version (Serial)"))
threading.Thread(target=test_version_thread, daemon=True).start()
def _jtag_reboot(self) -> None:
"""Reboot the Zynq board via JTAG system reset."""
if not self._config:
self._log_message("No config loaded — cannot reboot")
return
self._log_message(f"JTAG reboot: sending system reset to {self._config.zynq_ip}...")
self._jtag_reboot_btn.configure(state="disabled", text="Rebooting...")
def _reboot_thread():
try:
from reboot_manager import reboot_via_jtag
result = reboot_via_jtag(
self._config,
callback=lambda s, m: self._log_message(f" [{s}] {m}"),
)
if result.success:
self._log_message(f" ✓ JTAG reset: {result.message}")
self._status.set_status("JTAG reset complete", "success")
else:
self._log_message(f" ✗ JTAG reset: {result.message}")
self._status.set_status(f"JTAG reset failed: {result.message}", "error")
if result.output:
self._log_message(f" Output: {result.output[:500]}")
except Exception as e:
self._log_message(f" ✗ JTAG reboot error: {e}")
self._status.set_status(f"Error: {e}", "error")
finally:
self.after(0, lambda: self._jtag_reboot_btn.configure(
state="normal", text="Reboot (JTAG)",
))
threading.Thread(target=_reboot_thread, daemon=True).start()
def _udp_reboot(self) -> None:
"""Reboot the Zynq board via UDP command."""
if not self._config:
self._log_message("No config loaded — cannot reboot")
return
self._log_message(f"UDP reboot: sending reboot() to {self._config.zynq_ip}...")
self._udp_reboot_btn.configure(state="disabled", text="Rebooting...")
def _thread():
try:
from reboot_manager import reboot_via_udp
result = reboot_via_udp(
self._config,
callback=lambda s, m: self._log_message(f" [{s}] {m}"),
)
if result.success:
self._log_message(f" ✓ UDP reboot: {result.message}")
self._status.set_status("UDP reboot complete", "success")
else:
self._log_message(f" ✗ UDP reboot: {result.message}")
self._status.set_status(f"UDP reboot failed", "error")
except Exception as e:
self._log_message(f" ✗ UDP reboot error: {e}")
self._status.set_status(f"Error: {e}", "error")
finally:
self.after(0, lambda: self._udp_reboot_btn.configure(
state="normal", text="Reboot (UDP)",
))
threading.Thread(target=_thread, daemon=True).start()
def _udp_version(self) -> None:
"""Query Zynq firmware version via UDP."""
if not self._config:
self._log_message("No config loaded")
return
self._log_message(f"UDP version: sending ver() to {self._config.zynq_ip}...")
self._udp_version_btn.configure(state="disabled", text="Querying...")
def _thread():
try:
from reboot_manager import get_version_via_udp
result = get_version_via_udp(
self._config,
callback=lambda s, m: self._log_message(f" [{s}] {m}"),
)
if result.success:
self._log_message(f" ✓ Version: {result.version}")
self._status.set_status(f"Version: {result.version}", "success")
else:
self._log_message(f" ✗ {result.message}")
self._status.set_status(f"Query failed", "error")
except Exception as e:
self._log_message(f" ✗ UDP version error: {e}")
self._status.set_status(f"Error: {e}", "error")
finally:
self.after(0, lambda: self._udp_version_btn.configure(
state="normal", text="Version (UDP)",
))
threading.Thread(target=_thread, daemon=True).start()
def _discover_zynq_ip(self) -> str:
"""Scan IP range to find the Zynq after reboot.
Sends UDP ver() to each candidate IP (port = last_octet - 3)
and returns the first IP that responds.
Returns:
Discovered IP address, or empty string if not found.
"""
subnet = "192.168.100"
for last in range(11, 31):
ip = f"{subnet}.{last}"
try:
from reboot_manager import _send_udp_command
ok, _resp = _send_udp_command(ip, b"ver()", timeout=0.5)
if ok:
return ip
except Exception:
continue
return ""
# ── 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()