Compare commits

..
6 Commits
Author SHA1 Message Date
yuysh 5f9049a321 fix: suppress FileSelector callbacks during construction
During _build_config_panel(), set_relative_path() triggered callbacks
that called _get_selected_files() before all selectors were created,
causing AttributeError.

Fix: FileSelector now has _suppress_callback flag. Set True during
construction, then False after all selectors are created. Finally
call _auto_save_config() once at the end of the panel build.
2026-06-12 10:29:58 +08:00
yuysh e2e1fdd2a0 fix: tag_config for CTkTextbox + auto-save on file selection
- StatusDisplay: use tag_config (not tag_configure) for CTkTextbox
- FileSelector callbacks now trigger _auto_save_config() so that
  file path changes are persisted immediately to config.yaml
- This fixes the issue where UI file path changes weren't picked up
  by subsequent step executions
2026-06-12 10:20:07 +08:00
yuysh a196211708 fix: 5 UI improvements — relative paths, filename-only display, real-time config, multi-line status, maximize layout
1. FileSelector stores relative paths internally and displays only the
   filename (not full path).  set_relative_path() / get_relative_path()
   methods added.

2. Config changes take effect in real-time: IP and Xilinx path entries
   are bound to trace handlers that sync immediately.  FileSelector
   relative paths are preserved between save/load cycles.

3. StatusDisplay uses a multi-line CTkTextbox instead of a single-line
   label, filling the entire available space.

4. Window maximize handling: bind to <Configure> event, detect
   maximized state, and adjust layout so content fits in one screen
   without scrolling.

5. Workflow panel expanded with weight=1 so it fills available space
   when maximized.
2026-06-12 10:13:11 +08:00
yuysh 36fbb6a662 fix: try both COM11 and \\.\COM11 formats when opening serial port
pyserial should auto-add \\.\ prefix for COM>=10, but some versions
don't. Added _open_serial() helper that tries the raw port name first,
then falls back to explicit NT namespace prefix on Windows.

Replaces all serial.Serial() calls across serial_monitor, reboot_manager,
and widgets.
2026-06-12 09:46:31 +08:00
yuysh 4ef1ce0c6b fix: UART window stability on Windows — lift/focus, remove stray grab_release
CTkToplevel on Windows can fail to display (flash then disappear)
without explicit lift()/focus(). Also removed grab_release() call
in _on_close since grab_set() was never called — calling release
without a matching grab can cause issues on some Windows tkinter
builds.
2026-06-11 18:24:42 +08:00
yuysh 906f787748 fix: guard UART window creation with try-except to prevent silent crash
On Windows the UartMonitorWindow could crash instantly without any
error message. Added try-except around window creation in _read_serial
so errors are logged instead of silently killing the window.

Also added empty port guard before attempting to open.
2026-06-11 18:23:40 +08:00
13 changed files with 292 additions and 781 deletions
+1 -2
View File
@@ -33,8 +33,7 @@ htmlcov/
# ── Opencode (internal tooling) ────────────────────────────── # ── Opencode (internal tooling) ──────────────────────────────
.opencode/ .opencode/
# ── Config (user-specific, auto-generated if missing) ───────── # ── Config overrides (user-specific) ─────────────────────────
config.yaml
config/zynq_flasher.yaml config/zynq_flasher.yaml
# ── Logs ───────────────────────────────────────────────────── # ── Logs ─────────────────────────────────────────────────────
-63
View File
@@ -1,63 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for Zynq Flasher Windows x64 single-exe build.
Usage:
pyinstaller --clean --noconfirm ZynqFlasher.spec
output: dist/ZynqFlasher.exe
"""
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
block_cipher = None
a = Analysis(
['app.py'],
pathex=['src'],
binaries=[],
datas=[
# CustomTkinter themes, fonts, icons — auto-collected
*collect_data_files('customtkinter'),
# Default config shipped as template
('config/default_config.yaml', 'config'),
],
hiddenimports=[
*collect_submodules('customtkinter'),
'darkdetect',
'yaml',
'serial',
'serial.tools',
'serial.tools.list_ports',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='ZynqFlasher',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # show console for terminal log output
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
-47
View File
@@ -1,47 +0,0 @@
@echo off
setlocal
echo ============================================================
echo Zynq Flasher — Windows x64 Build Script
echo ============================================================
echo.
REM ── 1. Check Python ─────────────────────────────────────────
python --version >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Python not found. Install Python 3.9+ first.
pause
exit /b 1
)
echo [1/4] Python: OK
REM ── 2. Install dependencies ─────────────────────────────────
echo [2/4] Installing Python packages...
pip install -r requirements.txt --quiet
pip install pyinstaller --quiet
echo Done.
REM ── 3. Clean previous builds ────────────────────────────────
echo [3/4] Cleaning previous builds...
if exist build rmdir /s /q build
if exist dist\ZynqFlasher.exe del /q dist\ZynqFlasher.exe
if not exist dist mkdir dist
REM ── 4. Build ────────────────────────────────────────────────
echo [4/4] Building ZynqFlasher.exe...
pyinstaller --clean --noconfirm ZynqFlasher.spec
echo.
if exist dist\ZynqFlasher.exe (
echo ============================================================
echo Build successful!
echo Output: dist\ZynqFlasher.exe
echo ============================================================
) else (
echo [ERROR] Build failed — dist\ZynqFlasher.exe not found.
pause
exit /b 1
)
endlocal
pause
+27
View File
@@ -0,0 +1,27 @@
boot_wait_delay: 10
bootloader_bin_path: /home/ly0kos/work/Verify/FW/tftp_app/bootloader/bootloader_z100_800M.bin
bootloader_bit_path: /home/ly0kos/work/Verify/FW/tftp_app/bootloader/pl_arm_wrapper_hw_platform_0/pl_arm_wrapper.bit
bootloader_elf_path: /home/ly0kos/work/Verify/FW/tftp_app/tftp_app/tftp.elf
erase_all: false
firmware_bin_path: /home/ly0kos/work/Verify/FW/V1.3.1.3.3_06_01_pmu_cpld_check/V1.3.1.3.3_06_01_pmu_cpld_check.bin
flash_model: s25fl256s1
flash_type: qspi-x4-single
fsbl_elf_path: /home/ly0kos/work/Verify/FPGA/Xilinx/Z100/fsbl_qspi_bypass.elf
inter_step_delay: 2
ping_count: 3
ping_timeout: 5
reboot_timeout: 30
serial_baudrate: 115200
serial_port: /dev/ttyUSB0
step_timeouts:
bitstream: 60
check_env: 30
elf_download: 60
flash_erase: 120
flash_program: 600
tftp_reboot: 120
tftp_upload_name: z7bin
uart_delay: 3
xilinx_path: /data/xilinx
zynq_ip: 192.168.100.11
zynq_part: XC7Z100
+1 -3
View File
@@ -271,14 +271,12 @@ def _program_with_vivado(
callback("progress", "Using Vivado Hardware Manager...") callback("progress", "Using Vivado Hardware Manager...")
tcl_content = f""" tcl_content = f"""
open_hw open_hw_manager
connect_hw_server connect_hw_server
open_hw_target open_hw_target
current_hw_device [lindex [get_hw_devices] 0] current_hw_device [lindex [get_hw_devices] 0]
program_hw_device -file "{bit_path}" program_hw_device -file "{bit_path}"
refresh_hw_device refresh_hw_device
close_hw
disconnect_hw_server
quit quit
""" """
tcl_path = bit_path.parent / "temp_program.tcl" tcl_path = bit_path.parent / "temp_program.tcl"
+4 -39
View File
@@ -28,14 +28,13 @@ DEFAULT_CONFIG: dict[str, Any] = {
"flash_type": "qspi-x4-single", "flash_type": "qspi-x4-single",
"flash_model": "s25fl256s1", "flash_model": "s25fl256s1",
"erase_all": False, "erase_all": False,
"download_verify": True,
"firmware_bin_path": "", "firmware_bin_path": "",
"reboot_timeout": 30, "reboot_timeout": 30,
"ping_timeout": 5, "ping_timeout": 5,
"ping_count": 3, "ping_count": 3,
"uart_delay": 3, "uart_delay": 3,
"inter_step_delay": 2, "inter_step_delay": 2,
"boot_wait_delay": 30, "boot_wait_delay": 10,
"step_timeouts": { "step_timeouts": {
"check_env": 120, "check_env": 120,
"flash_erase": 120, "flash_erase": 120,
@@ -68,14 +67,13 @@ class Config:
flash_type: str = "qspi-x4-single" flash_type: str = "qspi-x4-single"
flash_model: str = "s25fl256s1" flash_model: str = "s25fl256s1"
erase_all: bool = False erase_all: bool = False
download_verify: bool = True
firmware_bin_path: str = "" firmware_bin_path: str = ""
reboot_timeout: int = 30 reboot_timeout: int = 30
ping_timeout: int = 5 ping_timeout: int = 5
ping_count: int = 3 ping_count: int = 3
uart_delay: int = 3 uart_delay: int = 3
inter_step_delay: int = 2 inter_step_delay: int = 2
boot_wait_delay: int = 30 boot_wait_delay: int = 10
step_timeouts: dict[str, int] = field(default_factory=lambda: {}) step_timeouts: dict[str, int] = field(default_factory=lambda: {})
# Internal: path to the config file on disk # Internal: path to the config file on disk
@@ -178,7 +176,6 @@ class Config:
"flash_type": self.flash_type, "flash_type": self.flash_type,
"flash_model": self.flash_model, "flash_model": self.flash_model,
"erase_all": self.erase_all, "erase_all": self.erase_all,
"download_verify": self.download_verify,
"firmware_bin_path": self._relative_path(self.firmware_bin_path), "firmware_bin_path": self._relative_path(self.firmware_bin_path),
"reboot_timeout": self.reboot_timeout, "reboot_timeout": self.reboot_timeout,
"ping_timeout": self.ping_timeout, "ping_timeout": self.ping_timeout,
@@ -193,11 +190,8 @@ class Config:
def _relative_path(self, path: str) -> str: def _relative_path(self, path: str) -> str:
"""Convert an absolute path to a relative path from the config file. """Convert an absolute path to a relative path from the config file.
Uses :func:`os.path.relpath` which works across filesystem
boundaries (unlike :meth:`Path.relative_to`).
Args: Args:
path: Absolute or relative path string. path: Absolute path string.
Returns: Returns:
Relative path string, or the original if conversion fails. Relative path string, or the original if conversion fails.
@@ -207,8 +201,7 @@ class Config:
try: try:
target = Path(path) target = Path(path)
if target.is_absolute(): if target.is_absolute():
base = str(self._config_path.parent) return str(target.relative_to(self._config_path.parent))
return os.path.relpath(str(target), base)
except (ValueError, OSError): except (ValueError, OSError):
pass pass
return path return path
@@ -226,34 +219,6 @@ class Config:
return Path() return Path()
return (self._config_path.parent / relative_path).resolve() return (self._config_path.parent / relative_path).resolve()
def validate_paths(self) -> list[str]:
"""Validate file paths in config and clear those that don't exist.
Checks each file path field, resolves it against the config
directory, and clears it if the target file does not exist
on the current filesystem.
Returns:
List of field names that were cleared.
"""
cleared: list[str] = []
path_fields = [
"bootloader_bit_path",
"bootloader_elf_path",
"bootloader_bin_path",
"fsbl_elf_path",
"firmware_bin_path",
]
for attr in path_fields:
val = getattr(self, attr, "")
if not val:
continue
resolved = self.resolve_path(val)
if not resolved.is_file():
setattr(self, attr, "")
cleared.append(attr)
return cleared
@property @property
def config_path(self) -> Path | None: def config_path(self) -> Path | None:
"""Return the path to the config file on disk.""" """Return the path to the config file on disk."""
-82
View File
@@ -1,82 +0,0 @@
"""File metadata utilities — CRC32, human-readable size, timestamps."""
from __future__ import annotations
import datetime
import os
import zlib
from dataclasses import dataclass
from pathlib import Path
@dataclass
class FileInfo:
"""Computed metadata for a single file."""
path: str
exists: bool = False
size: int = 0
crc32: int = 0
mtime: float = 0.0
@property
def size_human(self) -> str:
"""Human-readable file size (KiB / MiB / GiB)."""
if self.size >= 1_073_741_824:
return f"{self.size / 1_073_741_824:.2f} GiB"
if self.size >= 1_048_576:
return f"{self.size / 1_048_576:.2f} MiB"
if self.size >= 1_024:
return f"{self.size / 1_024:.2f} KiB"
return f"{self.size} B"
@property
def mtime_iso(self) -> str:
"""ISO-formatted modification time, or empty string."""
if not self.mtime:
return ""
return datetime.datetime.fromtimestamp(self.mtime).strftime(
"%Y-%m-%d %H:%M:%S"
)
@property
def crc32_hex(self) -> str:
"""8-character hex CRC32 representation."""
return f"{self.crc32 & 0xFFFFFFFF:08X}"
def compute_file_info(file_path: str | Path) -> FileInfo:
"""Compute size, CRC32, and modification time for a file.
If the file does not exist or cannot be read, the returned
``FileInfo`` will have ``exists=False`` and all numeric fields
set to zero.
Args:
file_path: Absolute or relative path to the file.
Returns:
FileInfo dataclass with computed metadata.
"""
path = Path(file_path)
info = FileInfo(path=str(path))
if not path.is_file():
return info
info.exists = True
try:
stat = path.stat()
info.size = stat.st_size
info.mtime = stat.st_mtime
except OSError:
return info
# CRC32
try:
value = 0
with open(path, "rb") as f:
while chunk := f.read(1_048_576): # 1 MiB chunks
value = zlib.crc32(chunk, value)
info.crc32 = value
except OSError:
pass
return info
+82 -327
View File
@@ -7,7 +7,6 @@ built with CustomTkinter.
from __future__ import annotations from __future__ import annotations
import os import os
import sys
import threading import threading
import time import time
import tkinter as tk import tkinter as tk
@@ -26,7 +25,6 @@ from tftp_manager import tftp_upload, tftp_download, tftp_upload_verify, TftpRes
from reboot_manager import reboot_zynq, RebootResult from reboot_manager import reboot_zynq, RebootResult
from boot_verifier import verify_boot, verify_zynq_status, BootVerificationResult 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 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 ( from gui.styles import (
WINDOW_WIDTH, WINDOW_WIDTH,
WINDOW_HEIGHT, WINDOW_HEIGHT,
@@ -94,35 +92,6 @@ class MainWindow(ctk.CTk):
self._load_config() self._load_config()
self._build_ui() 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() self._update_step_dependencies()
# Handle window close # Handle window close
@@ -140,18 +109,6 @@ class MainWindow(ctk.CTk):
# ── Config ───────────────────────────────────────────────── # ── Config ─────────────────────────────────────────────────
@staticmethod
def _get_app_dir() -> Path:
"""Return the application directory (where config.yaml lives).
When running as a PyInstaller bundle (frozen), uses the
directory of the .exe file. Otherwise navigates from
``__file__`` up to the project root.
"""
if getattr(sys, 'frozen', False):
return Path(sys.executable).parent
return Path(__file__).parent.parent.parent
def _find_config(self) -> Path | None: def _find_config(self) -> Path | None:
"""Find the user configuration file. """Find the user configuration file.
@@ -163,7 +120,8 @@ class MainWindow(ctk.CTk):
Returns: Returns:
Path to user config file, or None if not found. Path to user config file, or None if not found.
""" """
app_dir = self._get_app_dir() # Navigate from src/gui/main_window.py -> project root
app_dir = Path(__file__).parent.parent.parent
# 1. Check for user config at project root # 1. Check for user config at project root
user_config = app_dir / "config.yaml" user_config = app_dir / "config.yaml"
@@ -186,39 +144,33 @@ class MainWindow(ctk.CTk):
def _get_user_config_path(self) -> Path: def _get_user_config_path(self) -> Path:
"""Get the path to the user config file (config.yaml). """Get the path to the user config file (config.yaml).
Creates config.yaml in app directory if it doesn't exist. Creates config.yaml in project root if it doesn't exist.
Returns: Returns:
Path to user config file. Path to user config file.
""" """
app_dir = self._get_app_dir() # Navigate from src/gui/main_window.py -> project root
app_dir = Path(__file__).parent.parent.parent
config_path = app_dir / "config.yaml" config_path = app_dir / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
return config_path return config_path
def _load_config(self) -> None: def _load_config(self) -> None:
"""Load configuration from file or create a blank one on disk. """Load configuration from file or create default."""
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: if self._config_path:
try: try:
self._config = Config.from_file(self._config_path) self._config = Config.from_file(self._config_path)
if self._config_path.name == "default_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 self._config._config_path = user_config_path
return
except Exception: 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.from_default()
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path
else:
self._config = Config.from_default()
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path 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: def _reload_config(self) -> None:
"""Re-read config.yaml and update UI selectors with latest paths. """Re-read config.yaml and update UI selectors with latest paths.
@@ -233,15 +185,8 @@ class MainWindow(ctk.CTk):
try: try:
fresh = Config.from_file(user_path) fresh = Config.from_file(user_path)
self._config = fresh self._config = fresh
# Update UI selectors — suppress callbacks to avoid # Update UI selectors with resolved (absolute) paths — selectors
# redundant file-info logging and status-panel rewrites. # display only the filename internally.
_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 [ for attr, selector in [
("bootloader_bit_path", self._bit_selector), ("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector), ("bootloader_elf_path", self._elf_selector),
@@ -254,8 +199,6 @@ class MainWindow(ctk.CTk):
resolved = fresh.resolve_path(val) resolved = fresh.resolve_path(val)
selector.set_relative_path(val) selector.set_relative_path(val)
selector.set_path(str(resolved)) selector.set_path(str(resolved))
for s in _sels:
s._suppress_callback = False
# Update IP # Update IP
if self._ip_string_var: if self._ip_string_var:
self._ip_string_var.set(fresh.zynq_ip) self._ip_string_var.set(fresh.zynq_ip)
@@ -291,8 +234,7 @@ class MainWindow(ctk.CTk):
if hasattr(self, '_port_var'): if hasattr(self, '_port_var'):
self._config.serial_port = self._port_var.get() self._config.serial_port = self._port_var.get()
# Sync file paths from FileSelectors # Sync file paths from FileSelectors
# Always derive relative from absolute — the _relative_path files = self._get_selected_files()
# stored in FileSelector can be stale after Browse.
for attr, selector in [ for attr, selector in [
("bootloader_bit_path", self._bit_selector), ("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector), ("bootloader_elf_path", self._elf_selector),
@@ -300,17 +242,17 @@ class MainWindow(ctk.CTk):
("fsbl_elf_path", self._fsbl_selector), ("fsbl_elf_path", self._fsbl_selector),
("firmware_bin_path", self._firmware_bin_selector), ("firmware_bin_path", self._firmware_bin_selector),
]: ]:
rel = selector.get_relative_path()
if rel:
setattr(self._config, attr, rel)
else:
abs_path = selector.get_path() abs_path = selector.get_path()
if abs_path: if abs_path:
self._config._config_path # ensure _config_path is set
setattr(self._config, attr, self._config._relative_path(abs_path)) setattr(self._config, attr, self._config._relative_path(abs_path))
else:
setattr(self._config, attr, "")
# Sync erase checkbox (may not exist yet) # Sync erase checkbox (may not exist yet)
if hasattr(self, '_erase_cb_var'): if hasattr(self, '_erase_cb_var'):
self._config.erase_all = self._erase_cb_var.get() 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: def _auto_save_config(self) -> None:
"""Auto-save config to disk after UI changes. """Auto-save config to disk after UI changes.
@@ -355,11 +297,6 @@ class MainWindow(ctk.CTk):
if self._config: if self._config:
self._config.erase_all = self._erase_cb_var.get() 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: def _browse_xilinx_path(self) -> None:
"""Open directory dialog to select Xilinx root folder.""" """Open directory dialog to select Xilinx root folder."""
from tkinter import filedialog from tkinter import filedialog
@@ -381,75 +318,30 @@ class MainWindow(ctk.CTk):
self._config.zynq_ip = self._ip_string_var.get() self._config.zynq_ip = self._ip_string_var.get()
def _on_file_selected(self, path: str) -> None: def _on_file_selected(self, path: str) -> None:
"""Handle file selection — sync, auto-save, and log file info.""" """Handle file selection — sync and auto-save to disk.
from file_utils import compute_file_info
When the user selects a file via the browse dialog, immediately
sync the path to the Config object and persist it to YAML so
that subsequent step executions pick up the new value.
"""
self._auto_save_config() 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 ──────────────────────────────────────── # ── UI Construction ────────────────────────────────────────
def _build_ui(self) -> None: def _build_ui(self) -> None:
"""Build the complete UI layout.""" """Build the complete UI layout."""
# Scrollable main container — scrollbar appears when content overflows # Main container — regular frame (not scrollable) so we can
self.main_frame = ctk.CTkScrollableFrame(self) # control whether scrollbars appear based on window size.
self.main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE) main_frame = ctk.CTkFrame(self)
self.main_frame.grid_rowconfigure(1, weight=1) main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE)
self.main_frame.grid_columnconfigure(0, weight=7, uniform="main_col") main_frame.grid_rowconfigure(1, weight=1)
self.main_frame.grid_columnconfigure(1, weight=3, uniform="main_col") main_frame.grid_columnconfigure(0, weight=1)
# ── Header ── # ── Header ──
self._build_header(self.main_frame) self._build_header(main_frame)
# ── Left Panel: Workflow Steps ── # ── Left Panel: Workflow Steps ──
left_frame = ctk.CTkFrame(self.main_frame) left_frame = ctk.CTkFrame(main_frame)
left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, PADDING)) left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, PADDING))
left_frame.grid_rowconfigure(1, weight=1) left_frame.grid_rowconfigure(1, weight=1)
left_frame.grid_columnconfigure(0, weight=1) left_frame.grid_columnconfigure(0, weight=1)
@@ -458,9 +350,9 @@ class MainWindow(ctk.CTk):
self._build_log_panel(left_frame) self._build_log_panel(left_frame)
# ── Right Panel: Configuration ── # ── Right Panel: Configuration ──
right_frame = ctk.CTkFrame(self.main_frame) right_frame = ctk.CTkFrame(main_frame)
right_frame.grid(row=1, column=1, sticky="nsew", padx=(PADDING, 0)) 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_rowconfigure(3, weight=1)
right_frame.grid_columnconfigure(0, weight=1) right_frame.grid_columnconfigure(0, weight=1)
self._build_config_panel(right_frame) self._build_config_panel(right_frame)
@@ -627,7 +519,6 @@ class MainWindow(ctk.CTk):
row=1, column=0, sticky="nsew", row=1, column=0, sticky="nsew",
padx=PADDING, pady=PADDING, padx=PADDING, pady=PADDING,
) )
self._log_panel = panel
panel.grid_rowconfigure(1, weight=1) panel.grid_rowconfigure(1, weight=1)
panel.grid_columnconfigure(0, weight=1) panel.grid_columnconfigure(0, weight=1)
@@ -659,7 +550,6 @@ class MainWindow(ctk.CTk):
padx=PADDING, pady=PADDING, padx=PADDING, pady=PADDING,
) )
panel.grid_rowconfigure(9, weight=1) panel.grid_rowconfigure(9, weight=1)
panel.grid_columnconfigure(0, weight=1) # let content stretch
title = ctk.CTkLabel( title = ctk.CTkLabel(
panel, panel,
@@ -774,17 +664,6 @@ class MainWindow(ctk.CTk):
) )
flash_model_label.grid(row=1, column=0, sticky="w", padx=PADDING_SMALL, pady=(2, 0)) 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) # Firmware BIN path (TFTP upload)
self._firmware_bin_selector = FileSelector( self._firmware_bin_selector = FileSelector(
panel, panel,
@@ -933,27 +812,23 @@ class MainWindow(ctk.CTk):
self._status = StatusDisplay(panel) self._status = StatusDisplay(panel)
self._status.grid(row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING) self._status.grid(row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING)
# UART warning — multi-line textbox, wraps long messages instead # UART warning label (persistent, not overwritten by other status updates)
# of expanding the right panel horizontally self._uart_warning_label = ctk.CTkLabel(
self._uart_warning = ctk.CTkTextbox(
panel, panel,
text="",
font=FONT_BODY, font=FONT_BODY,
height=40, anchor="w",
corner_radius=4, text_color=WARNING_COLOR,
fg_color="transparent",
state="disabled",
wrap="word",
) )
self._uart_warning.grid( self._uart_warning_label.grid(
row=1, column=0, sticky="ew", padx=PADDING, pady=(0, PADDING_SMALL) row=1, column=0, sticky="ew", padx=PADDING, pady=(0, PADDING_SMALL)
) )
self._uart_warning.grid_remove() # Hide by default self._uart_warning_label.grid_remove() # Hide by default
# ── Workflow Steps ───────────────────────────────────────── # ── Workflow Steps ─────────────────────────────────────────
def _log_message(self, message: str) -> None: def _log_message(self, message: str) -> None:
"""Append a message to the log display and terminal (thread-safe).""" """Append a message to the log display (thread-safe)."""
print(message, flush=True)
self.after(0, lambda: self._log_display.append(message)) self.after(0, lambda: self._log_display.append(message))
def _set_step_status(self, index: int, status: str) -> None: def _set_step_status(self, index: int, status: str) -> None:
@@ -1010,10 +885,6 @@ class MainWindow(ctk.CTk):
if result.is_ready: if result.is_ready:
self._vitis_status.configure(text="Vitis: Ready", text_color=SUCCESS_COLOR) self._vitis_status.configure(text="Vitis: Ready", text_color=SUCCESS_COLOR)
self._log_message(" Vitis/Vivado tools available") 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 # Report all found versions for diagnostics
if self._config.xilinx_path: if self._config.xilinx_path:
try: try:
@@ -1067,7 +938,6 @@ class MainWindow(ctk.CTk):
self._log_message(f" ✓ Zynq {part} detected on JTAG chain") self._log_message(f" ✓ Zynq {part} detected on JTAG chain")
self._jtag_status.configure(text=f"JTAG: {part}", text_color=SUCCESS_COLOR) self._jtag_status.configure(text=f"JTAG: {part}", text_color=SUCCESS_COLOR)
self._status.set_info("Zynq", part)
else: else:
self._zynq_jtag_present = False self._zynq_jtag_present = False
self._zynq_jtag_info = None self._zynq_jtag_info = None
@@ -1079,8 +949,7 @@ class MainWindow(ctk.CTk):
self._log_message(" Checking UART serial port...") self._log_message(" Checking UART serial port...")
port = self._config.serial_port port = self._config.serial_port
if port: if port:
available, reason = check_uart_available(port, self._config.serial_baudrate, available, reason = check_uart_available(port, self._config.serial_baudrate)
monitor=self._uart_monitor)
self._uart_available = available self._uart_available = available
self._uart_message = reason self._uart_message = reason
if available: if available:
@@ -1108,35 +977,28 @@ class MainWindow(ctk.CTk):
self.after(0, lambda: self._show_uart_warning(msg)) self.after(0, lambda: self._show_uart_warning(msg))
def _show_uart_warning(self, msg: str) -> None: def _show_uart_warning(self, msg: str) -> None:
"""Display the persistent UART warning message (multi-line). """Display the persistent UART warning message.
Args: Args:
msg: Warning message to display. msg: Warning message to display.
""" """
self._uart_warning.configure(state="normal") self._uart_warning_label.configure(text=msg)
self._uart_warning.delete("1.0", "end") self._uart_warning_label.grid()
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: def _hide_uart_warning(self) -> None:
"""Hide the persistent UART warning.""" """Hide the persistent UART warning label."""
self._uart_warning.grid_remove() self._uart_warning_label.grid_remove()
def _on_window_resize(self, event) -> None: def _on_window_resize(self, event: tk.Event | None = None) -> None:
"""Handle window resize / maximize events. """Handle window resize / maximize events.
When the window is maximized, scale content to fit one screen When the window is maximized, disable the scrollable frame and
and hide scrollbars. When restored, use normal sizing with let all panels fill the available space without scrolling.
scrollbars on overflow. When the window is restored to a smaller size, re-enable
scrolling so the content remains accessible.
""" """
try: try:
# Get the current window state
window_state = self.state() window_state = self.state()
is_maximized = window_state == "zoomed" is_maximized = window_state == "zoomed"
@@ -1147,89 +1009,20 @@ class MainWindow(ctk.CTk):
pass pass
def _adjust_layout_for_maximize(self) -> None: def _adjust_layout_for_maximize(self) -> None:
"""Scale content and toggle scrollbars based on maximized state.""" """Adjust layout based on maximized state.
if not hasattr(self, 'main_frame'):
return
sf = self.main_frame
has_scrollbars = hasattr(sf, '_scrollbar_y')
if self._maximized: When maximized: all panels expand to fill the window, no scrolling.
# Hide scrollbars when maximized When not maximized: scrollable frame re-enables scrolling for overflow.
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 if self._maximized:
scale_font = lambda orig: (orig[0], max(8, int(orig[1] * factor)), orig[2] if len(orig) > 2 else "") # When maximized, we want everything to fit in one screen.
# Set a reasonable minsize so the layout doesn't collapse too small.
# Header fonts self.minsize(800, 600)
if hasattr(self, '_vitis_status'): else:
self._vitis_status.configure(font=scale_font(FONT_BODY)) # When restored to a smaller size, the scrollable frame
if hasattr(self, '_ip_status'): # (if we were using one) would show scrollbars.
self._ip_status.configure(font=scale_font(FONT_BODY)) # With regular frames, the user can resize the window.
if hasattr(self, '_jtag_status'): pass
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: def _on_port_changed(self, new_port: str) -> None:
"""Handle serial port selection change — validate immediately. """Handle serial port selection change — validate immediately.
@@ -1425,19 +1218,7 @@ class MainWindow(ctk.CTk):
self._log_message(" ⏳ Waiting 2s before download...") self._log_message(" ⏳ Waiting 2s before download...")
time.sleep(2) time.sleep(2)
# 4b+4c: TFTP Download Verify + CRC Check # 4b: TFTP Download Verify
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_expanded(True)
self._tftp_sub_step_frame.set_step_skipped("Download Verify")
self._tftp_sub_step_frame.set_step_skipped("CRC Check")
self._log_message(" ◌ Download Verify: Skip")
self._log_message(" ◌ CRC Check: Skip")
else:
# 4b: Download Verify
self._log_message("Step 4b: TFTP download verify...") self._log_message("Step 4b: TFTP download verify...")
self._tftp_phase = "Download Verify" self._tftp_phase = "Download Verify"
if hasattr(self, '_tftp_sub_step_frame'): if hasattr(self, '_tftp_sub_step_frame'):
@@ -1461,7 +1242,8 @@ class MainWindow(ctk.CTk):
if not all_results[-1]: if not all_results[-1]:
self._log_message(" Skipping CRC check (download failed)") self._log_message(" Skipping CRC check (download failed)")
else: return False
# 4c: CRC Check # 4c: CRC Check
self._log_message("Step 4c: CRC check...") self._log_message("Step 4c: CRC check...")
if hasattr(self, '_tftp_sub_step_frame'): if hasattr(self, '_tftp_sub_step_frame'):
@@ -1483,7 +1265,7 @@ class MainWindow(ctk.CTk):
all_results.append(False) all_results.append(False)
if not all_results[-1]: if not all_results[-1]:
self._log_message(" Skipping reboot (download/CRC failed)") self._log_message(" Skipping reboot (CRC mismatch)")
return False return False
# 4d: Reboot # 4d: Reboot
@@ -1580,33 +1362,11 @@ class MainWindow(ctk.CTk):
discovered_ip = parsed.ip_address discovered_ip = parsed.ip_address
self._log_message(f" IP from UART buffer: {discovered_ip}") self._log_message(f" IP from UART buffer: {discovered_ip}")
# Fallback: if no IP from UART, try ping → UDP scan, then extra delay # Discover actual IP after reboot (may have changed via DHCP)
if not discovered_ip: if not discovered_ip:
self._log_message(" ⏳ Network stack — waiting 3s...") self._log_message(" ⏳ Network stack — waiting 3s...")
time.sleep(3) 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)...") 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() discovered_ip = self._discover_zynq_ip()
if discovered_ip: if discovered_ip:
self._log_message(f" ✓ Found Zynq at {discovered_ip}") self._log_message(f" ✓ Found Zynq at {discovered_ip}")
@@ -1820,13 +1580,6 @@ class MainWindow(ctk.CTk):
elapsed = time.time() - t_step_start elapsed = time.time() - t_step_start
self._log_message(f" ⏱ Step {i+1} completed in {elapsed:.0f}s") self._log_message(f" ⏱ Step {i+1} completed in {elapsed:.0f}s")
# Step 3: After bootloader load, fix IP to 192.168.100.11
if i == 2 and success:
self._config.zynq_ip = "192.168.100.11"
if self._ip_string_var:
self._ip_string_var.set("192.168.100.11")
self._log_message(" IP set to 192.168.100.11")
# Step 2: mark sub-steps based on result # Step 2: mark sub-steps based on result
if i == 1 and hasattr(self, '_sub_step_frame'): if i == 1 and hasattr(self, '_sub_step_frame'):
if success: if success:
@@ -1843,11 +1596,6 @@ class MainWindow(ctk.CTk):
results.append(False) results.append(False)
self._set_step_status(i, "error") self._set_step_status(i, "error")
self._log_message(f" Exception in step {i+1}: {e}") 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) self._progress.set_value((step_idx + 1) / total)
@@ -1974,12 +1722,20 @@ class MainWindow(ctk.CTk):
def _read_serial(self) -> None: def _read_serial(self) -> None:
"""Open the UART Monitor window.""" """Open the UART Monitor window."""
port = self._port_var.get() port = self._port_var.get().strip()
if not port:
self._log_message(" ✗ No serial port selected")
return
baudrate = self._config.serial_baudrate if self._config else 115200 baudrate = self._config.serial_baudrate if self._config else 115200
try:
self._uart_window = UartMonitorWindow( self._uart_window = UartMonitorWindow(
self, port=port, baudrate=baudrate, self, port=port, baudrate=baudrate,
on_log=self._log_message, on_log=self._log_message,
) )
except Exception as e:
self._log_message(f" ✗ Failed to open UART window: {e}")
import traceback
self._log_message(traceback.format_exc())
def _test_serial_version(self) -> None: def _test_serial_version(self) -> None:
"""Test serial port by sending 'ver()' command and parsing response.""" """Test serial port by sending 'ver()' command and parsing response."""
@@ -1999,7 +1755,6 @@ class MainWindow(ctk.CTk):
success, message, version = test_serial_version( success, message, version = test_serial_version(
port, port,
self._config.serial_baudrate if self._config else 115200, self._config.serial_baudrate if self._config else 115200,
monitor=self._uart_monitor,
) )
if success: if success:
+42 -96
View File
@@ -437,82 +437,63 @@ class ProgressIndicator(ctk.CTkFrame):
class StatusDisplay(ctk.CTkFrame): class StatusDisplay(ctk.CTkFrame):
"""Info display widget — persistent key-value file/device info. """Status display widget for operation results.
Shows aligned ``key : value`` lines for file metadata, Zynq Shows status messages in a multi-line, expandable text area that
part info, tool versions, etc. uses the full available space. Messages are color-coded and
automatically wrapped.
""" """
def __init__(self, master, **kwargs): def __init__(self, master, **kwargs):
"""Initialize the status display.
Args:
master: Parent widget.
"""
super().__init__(master, **kwargs) super().__init__(master, **kwargs)
self._info_items: list[tuple[str, str]] = []
self._create_widgets() self._create_widgets()
def _create_widgets(self) -> None: def _create_widgets(self) -> None:
"""Create the status display UI — multi-line expandable area."""
self.grid_rowconfigure(0, weight=1) self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1)
self._info_text = ctk.CTkTextbox( self._status_text = ctk.CTkTextbox(
self, self,
font=FONT_BODY, font=FONT_BODY,
corner_radius=CORNER_RADIUS, corner_radius=CORNER_RADIUS,
state="disabled", state="disabled",
wrap="word", wrap="word",
) )
self._info_text.grid( self._status_text.grid(
row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL
) )
# Pre-configure tags
for name, color in [
("info_label", PRIMARY_COLOR),
("info_value", DESC_TEXT_COLOR if _is_dark() else DESC_TEXT_COLOR_LIGHT),
]:
self._info_text.tag_config(name, foreground=color)
def set_status(self, message: str, status: str = "info") -> None: def set_status(self, message: str, status: str = "info") -> None:
"""Compatibility stub — status messages go to log now.""" """Update the status message and color.
def set_info(self, key: str, value: str) -> None:
"""Set or update a persistent info line.
Displays as ``key : value`` in the info block.
Args: Args:
key: Label for the info line. message: Status message text.
value: Value string. status: Status type for coloring ('info', 'success', 'error', 'warning').
""" """
for i, (k, _) in enumerate(self._info_items): self._status_text.configure(state="normal")
if k == key: self._status_text.delete("1.0", "end")
self._info_items[i] = (key, value)
break
else:
self._info_items.append((key, value))
self._refresh_info()
def clear_info(self) -> None: colors = {
"""Remove all persistent info lines.""" "info": INFO_COLOR,
self._info_items.clear() "success": SUCCESS_COLOR,
self._info_text.configure(state="normal") "error": DANGER_COLOR,
self._info_text.delete("1.0", "end") "warning": WARNING_COLOR,
self._info_text.configure(state="disabled") }
color = colors.get(status, INFO_COLOR)
def _refresh_info(self) -> None: self._status_text.insert("1.0", message)
"""Rebuild the info text block from current items.""" # Tag the entire text with the color (CTkTextbox uses tag_config)
self._info_text.configure(state="normal") self._status_text.tag_config("status", foreground=color)
self._info_text.delete("1.0", "end") self._status_text.tag_add("status", "1.0", "end")
if not self._info_items:
self._info_text.configure(state="disabled") self._status_text.configure(state="disabled")
return
max_key = max(len(k) for k, _ in self._info_items)
for key, value in self._info_items:
line = f"{key.ljust(max_key + 2)} {value}\n"
start = self._info_text.index("end-1c")
self._info_text.insert("end", line)
end = self._info_text.index("end-1c")
self._info_text.tag_add("info_label", start, f"{start}+{len(key)}c")
self._info_text.tag_add("info_value", f"{start}+{len(key)}c", end)
self._info_text.configure(state="disabled")
class FileSelector(ctk.CTkFrame): class FileSelector(ctk.CTkFrame):
@@ -544,7 +525,6 @@ class FileSelector(ctk.CTkFrame):
self._callback = callback self._callback = callback
self._file_types = file_types or [("All files", "*")] self._file_types = file_types or [("All files", "*")]
self._hint_text = label_text.rstrip(":") # e.g. "FSBL ELF (.elf)"
self._full_path: str = "" # absolute path (internal) self._full_path: str = "" # absolute path (internal)
self._relative_path: str = "" # relative path (synced to Config) self._relative_path: str = "" # relative path (synced to Config)
self._display_text = ctk.StringVar(value="") self._display_text = ctk.StringVar(value="")
@@ -594,7 +574,7 @@ class FileSelector(ctk.CTkFrame):
from tkinter import filedialog from tkinter import filedialog
file_path = filedialog.askopenfilename( file_path = filedialog.askopenfilename(
title=f"Select {self._hint_text}", title="Select file",
filetypes=self._file_types, filetypes=self._file_types,
) )
if file_path: if file_path:
@@ -719,15 +699,15 @@ class SubStepFrame(ctk.CTkFrame):
"""Collapsible frame showing flash operation sub-steps with status. """Collapsible frame showing flash operation sub-steps with status.
Colors match StepHeader: gray pending, blue running (+pulse), Colors match StepHeader: gray pending, blue running (+pulse),
green complete, red error. Default collapsed. green complete, red error. Default expanded.
""" """
def __init__(self, parent: ctk.CTkFrame) -> None: def __init__(self, parent: ctk.CTkFrame) -> None:
super().__init__(parent, fg_color="transparent") super().__init__(parent, fg_color="transparent")
self._collapsed = True self._collapsed = False
self._toggle_btn = ctk.CTkButton( self._toggle_btn = ctk.CTkButton(
self, text="", width=22, height=22, self, text="", width=22, height=22,
font=(FONT_SMALL[0], 9), command=self._toggle, font=(FONT_SMALL[0], 9), command=self._toggle,
) )
self._toggle_btn.grid(row=0, column=0, padx=(4, 4), pady=(2, 2), sticky="w") self._toggle_btn.grid(row=0, column=0, padx=(4, 4), pady=(2, 2), sticky="w")
@@ -752,16 +732,6 @@ class SubStepFrame(ctk.CTkFrame):
self.grid_columnconfigure(1, weight=1) self.grid_columnconfigure(1, weight=1)
self._pulse_job: str | None = None self._pulse_job: str | None = None
# Apply initial collapsed state (hide sub-items)
self._hide()
def _hide(self) -> None:
"""Hide sub-items without toggling state."""
for item in self._sub_items:
item["dot"].grid_remove()
item["label"].grid_remove()
item["pct"].grid_remove()
def _toggle(self) -> None: def _toggle(self) -> None:
self._collapsed = not self._collapsed self._collapsed = not self._collapsed
self._toggle_btn.configure(text="" if self._collapsed else "") self._toggle_btn.configure(text="" if self._collapsed else "")
@@ -863,20 +833,19 @@ class TftpSubStepFrame(ctk.CTkFrame):
Sub-steps: Upload, Download Verify, CRC Check, Reboot, Boot Verify. Sub-steps: Upload, Download Verify, CRC Check, Reboot, Boot Verify.
Colors match StepHeader: gray pending, blue running (+pulse), Colors match StepHeader: gray pending, blue running (+pulse),
green complete, red error. Default collapsed. green complete, red error. Default expanded.
""" """
def __init__(self, parent: ctk.CTkFrame, substep_names: list[str] | None = None) -> None: def __init__(self, parent: ctk.CTkFrame, substep_names: list[str] | None = None) -> None:
super().__init__(parent, fg_color="transparent") super().__init__(parent, fg_color="transparent")
self._collapsed = True self._collapsed = False
self._pulse_job: str | None = None self._pulse_job: str | None = None
self._skipped: set[int] = set()
self._substep_names = substep_names or ["Upload", "Download Verify", "CRC Check", "Reboot", "Boot Verify"] self._substep_names = substep_names or ["Upload", "Download Verify", "CRC Check", "Reboot", "Boot Verify"]
self._num_steps = len(self._substep_names) self._num_steps = len(self._substep_names)
self._toggle_btn = ctk.CTkButton( self._toggle_btn = ctk.CTkButton(
self, text="", width=22, height=22, self, text="", width=22, height=22,
font=(FONT_SMALL[0], 9), command=self._toggle, font=(FONT_SMALL[0], 9), command=self._toggle,
) )
self._toggle_btn.grid(row=0, column=0, padx=(4, 4), pady=(2, 2), sticky="w") self._toggle_btn.grid(row=0, column=0, padx=(4, 4), pady=(2, 2), sticky="w")
@@ -899,16 +868,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
self.grid_columnconfigure(1, weight=1) self.grid_columnconfigure(1, weight=1)
# Apply initial collapsed state (hide sub-items)
self._hide()
def _hide(self) -> None:
"""Hide sub-items without toggling state."""
for item in self._sub_items:
item["dot"].grid_remove()
item["label"].grid_remove()
item["pct"].grid_remove()
def _toggle(self) -> None: def _toggle(self) -> None:
self._collapsed = not self._collapsed self._collapsed = not self._collapsed
self._toggle_btn.configure(text="" if self._collapsed else "") self._toggle_btn.configure(text="" if self._collapsed else "")
@@ -937,8 +896,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
pct_text = f"{pct}%" if pct >= 0 else "" pct_text = f"{pct}%" if pct >= 0 else ""
for i, item in enumerate(self._sub_items): for i, item in enumerate(self._sub_items):
if i in self._skipped:
continue # leave skipped items untouched
if i < idx: if i < idx:
item["dot"].configure(text="", text_color=_C_DONE) item["dot"].configure(text="", text_color=_C_DONE)
item["label"].configure(text_color=_C_DONE) item["label"].configure(text_color=_C_DONE)
@@ -978,18 +935,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
item["label"].configure(text_color=_C_ERROR) item["label"].configure(text_color=_C_ERROR)
self._stop_pulse() self._stop_pulse()
def set_step_skipped(self, phase: str) -> None:
"""Mark a specific step as skipped (warning color, Skip text)."""
idx = self._substep_names.index(phase) if phase in self._substep_names else -1
if idx < 0:
return
self._skipped.add(idx)
item = self._sub_items[idx]
item["dot"].configure(text="", text_color=WARNING_COLOR)
item["label"].configure(text_color=WARNING_COLOR)
item["pct"].configure(text="Skip", text_color=WARNING_COLOR)
self._stop_pulse()
def _start_pulse(self, idx: int) -> None: def _start_pulse(self, idx: int) -> None:
"""Start pulsing the running sub-step.""" """Start pulsing the running sub-step."""
_LIGHT = RUNNING_FADE_LIGHT _LIGHT = RUNNING_FADE_LIGHT
@@ -1014,7 +959,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
def reset(self) -> None: def reset(self) -> None:
self._stop_pulse() self._stop_pulse()
self._skipped.clear()
_C = ACCENT_PENDING _C = ACCENT_PENDING
for i, item in enumerate(self._sub_items): for i, item in enumerate(self._sub_items):
item["dot"].configure(text="", text_color=_C) item["dot"].configure(text="", text_color=_C)
@@ -1058,6 +1002,8 @@ class UartMonitorWindow(ctk.CTkToplevel):
self.geometry("680x540") self.geometry("680x540")
self.minsize(480, 360) self.minsize(480, 360)
self.protocol("WM_DELETE_WINDOW", self._on_close) self.protocol("WM_DELETE_WINDOW", self._on_close)
self.lift() # ensure visible on Windows
self.focus()
self._on_log = on_log self._on_log = on_log
@@ -1180,7 +1126,8 @@ class UartMonitorWindow(ctk.CTkToplevel):
def _bg_open() -> None: def _bg_open() -> None:
try: try:
import serial import serial
ser = serial.Serial(self._port, self._baudrate, timeout=1) from serial_monitor import _open_serial
ser = _open_serial(self._port, self._baudrate, 1)
ser.close() ser.close()
self.after(0, self._on_open_success) self.after(0, self._on_open_success)
except Exception as e: except Exception as e:
@@ -1306,5 +1253,4 @@ class UartMonitorWindow(ctk.CTkToplevel):
if self._monitor: if self._monitor:
self._monitor.stop() self._monitor.stop()
self._monitor = None self._monitor = None
self.grab_release()
self.destroy() self.destroy()
+3 -2
View File
@@ -125,11 +125,12 @@ def reboot_via_serial(
try: try:
import serial import serial
from serial_monitor import _open_serial
with serial.Serial( with _open_serial(
config.serial_port, config.serial_port,
config.serial_baudrate, config.serial_baudrate,
timeout=5, timeout=5.0,
) as ser: ) as ser:
ser.reset_input_buffer() ser.reset_input_buffer()
# Send Ctrl+C to break out of any running process # Send Ctrl+C to break out of any running process
+42 -26
View File
@@ -8,6 +8,7 @@ Provides UartMonitor for continuous background monitoring.
from __future__ import annotations from __future__ import annotations
import re import re
import sys
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -17,6 +18,33 @@ import serial
import serial.tools.list_ports import serial.tools.list_ports
def _open_serial(port: str, baudrate: int, timeout: float = 1.0) -> serial.Serial:
"""Open a serial port, trying both name formats on Windows.
pyserial normally adds \\\\.\\COM prefix for COM>=10, but some
versions don't. Try raw name first, then explicit NT namespace.
"""
port = port.strip()
last_err = None
# Try 1: port name as-is
try:
return serial.Serial(port, baudrate, timeout=timeout)
except serial.SerialException as e:
last_err = e
# Try 2: Windows NT namespace prefix
if sys.platform == "win32":
m = re.match(r"^(COM\d+)$", port, re.IGNORECASE)
if m:
try:
return serial.Serial(r"\\.\%s" % port, baudrate, timeout=timeout)
except serial.SerialException as e:
last_err = e
raise last_err or OSError(f"Cannot open serial port: {port}")
@dataclass @dataclass
class BootInfo: class BootInfo:
"""Parsed information extracted from Zynq boot output.""" """Parsed information extracted from Zynq boot output."""
@@ -137,7 +165,6 @@ def check_uart_available(
port: str, port: str,
baudrate: int = 115200, baudrate: int = 115200,
timeout: float = 3.0, timeout: float = 3.0,
monitor: UartMonitor | None = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Check whether the UART serial port is available and readable. """Check whether the UART serial port is available and readable.
@@ -146,24 +173,19 @@ def check_uart_available(
considered available (the device may simply not be outputting yet). considered available (the device may simply not be outputting yet).
If the port cannot be opened at all, it is unavailable. If the port cannot be opened at all, it is unavailable.
If *monitor* is provided and currently running, the function
skips the port-open test (which would fail on Windows with
Error 13 due to exclusive access) and returns True immediately.
Args: Args:
port: Serial port device path (e.g., '/dev/ttyUSB0'). port: Serial port device path (e.g., '/dev/ttyUSB0').
baudrate: Baud rate for serial communication. baudrate: Baud rate for serial communication.
timeout: Seconds to wait for data after opening. timeout: Seconds to wait for data after opening.
monitor: Optional running UartMonitor — if active, reuse it.
Returns: Returns:
Tuple of (is_available, reason_string). Tuple of (is_available, reason_string).
- (True, "Port open") — port is usable
- (True, "Port open, no data yet") — port open, no data currently
- (False, "No serial ports detected") — system has no serial ports
- (False, "Port not in detected ports") — configured port missing
- (False, "Failed to open: <error>") — port open error
""" """
# Reuse existing monitor when available (avoids Error 13 on Windows)
if monitor is not None and monitor.is_running():
return True, "Already monitoring"
# 1. Quick port list check
# 1. Quick port list check # 1. Quick port list check
ports = detect_serial_ports() ports = detect_serial_ports()
if not ports: if not ports:
@@ -175,7 +197,7 @@ def check_uart_available(
# 3. Try to open and read # 3. Try to open and read
try: try:
with serial.Serial(port, baudrate, timeout=timeout) as ser: with _open_serial(port, baudrate, timeout) as ser:
ser.reset_input_buffer() ser.reset_input_buffer()
lines: list[str] = [] lines: list[str] = []
while ser.in_waiting: while ser.in_waiting:
@@ -229,33 +251,27 @@ def test_serial_version(
port: str, port: str,
baudrate: int = 115200, baudrate: int = 115200,
timeout: float = 5.0, timeout: float = 5.0,
monitor: UartMonitor | None = None,
) -> tuple[bool, str, str]: ) -> tuple[bool, str, str]:
"""Test serial port by sending 'ver()' command and parsing response. """Test serial port by sending 'ver()' command and parsing response.
If *monitor* is running, skips opening a new connection and Sends 'ver\\r\\n' to the serial port and reads the response.
returns the latest parsed version from the monitor instead. Attempts to parse version string from the response.
Args: Args:
port: Serial port device path (e.g., '/dev/ttyUSB0'). port: Serial port device path (e.g., '/dev/ttyUSB0').
baudrate: Baud rate for serial communication. baudrate: Baud rate for serial communication.
timeout: Read timeout in seconds. timeout: Read timeout in seconds.
monitor: Optional running UartMonitor — if active, reuse it.
Returns: Returns:
Tuple of (success, message, version_string). Tuple of (success, message, version_string).
- (True, "Version: x.y.z", "x.y.z") — version detected
- (True, "Port responded", "") — port responded but no version
- (False, "Error message", "") — error occurred
""" """
import serial import serial
# Reuse existing monitor when available (avoids Error 13 on Windows)
if monitor is not None and monitor.is_running():
info = monitor.latest_info
if info and info.version:
return True, f"Version: {info.version}", info.version
return True, "Port monitored, no version parsed yet", ""
try: try:
with serial.Serial(port, baudrate, timeout=timeout) as ser: with _open_serial(port, baudrate, timeout) as ser:
ser.reset_input_buffer() ser.reset_input_buffer()
ser.reset_output_buffer() ser.reset_output_buffer()
@@ -396,7 +412,7 @@ class UartMonitor:
# Quick port check before starting thread # Quick port check before starting thread
try: try:
test_ser = serial.Serial(self._port, self._baudrate, timeout=1) test_ser = _open_serial(self._port, self._baudrate, 1)
test_ser.close() test_ser.close()
except serial.SerialException as e: except serial.SerialException as e:
if self.on_error: if self.on_error:
@@ -422,7 +438,7 @@ class UartMonitor:
def _read_loop(self) -> None: def _read_loop(self) -> None:
"""Main read loop running in background thread.""" """Main read loop running in background thread."""
try: try:
ser = serial.Serial( ser = _open_serial(
self._port, self._port,
self._baudrate, self._baudrate,
timeout=self._timeout, timeout=self._timeout,
+7 -14
View File
@@ -60,8 +60,7 @@ TOOL_ALIASES: dict[str, list[str]] = {
} }
# Product subdirectories under xilinx_root to scan (in preference order) # Product subdirectories under xilinx_root to scan (in preference order)
# SDK covers pre-2019 installations where xsct lived in SDK/*/bin/ _PRODUCT_DIRS = ("Vitis", "Vivado")
_PRODUCT_DIRS = ("Vitis", "Vivado", "SDK")
# ── Version helpers ────────────────────────────────────────────────── # ── Version helpers ──────────────────────────────────────────────────
@@ -164,9 +163,9 @@ def _find_executable(
"""Find an executable by name using PATH, Vitis, and Xilinx root. """Find an executable by name using PATH, Vitis, and Xilinx root.
Search order: Search order:
1. System PATH (shutil.which) — tries bare name, .exe, .bat on Windows 1. System PATH (shutil.which)
2. vitis_path/bin (legacy config) 2. vitis_path/bin (legacy config)
3. xilinx_root/Vitis/*/bin, Vivado/*/bin, SDK/*/bin — pick NEWEST version 3. xilinx_root/Vitis/*/bin and Vivado/*/bin — pick NEWEST version
4. Common install paths (cross-platform) 4. Common install paths (cross-platform)
Args: Args:
@@ -180,20 +179,14 @@ def _find_executable(
""" """
exec_suffix = ".exe" if system == "windows" else "" exec_suffix = ".exe" if system == "windows" else ""
# 1. System PATH — search without explicit extension first # 1. System PATH
# so Windows PATHEXT resolves .bat/.exe/.cmd automatically
found = shutil.which(exec_name)
if not found and exec_suffix:
found = shutil.which(f"{exec_name}{exec_suffix}") found = shutil.which(f"{exec_name}{exec_suffix}")
if not found:
found = shutil.which(f"{exec_name}.bat")
if found: if found:
return found return found
# 2. Legacy vitis_path/bin # 2. Legacy vitis_path/bin
if vitis_path: if vitis_path:
for sfx in (".bat", ".exe") if system == "windows" else (exec_suffix,): candidate = Path(vitis_path) / "bin" / f"{exec_name}{exec_suffix}"
candidate = Path(vitis_path) / "bin" / f"{exec_name}{sfx}"
if candidate.is_file(): if candidate.is_file():
return str(candidate) return str(candidate)
@@ -210,8 +203,8 @@ def _find_executable(
if system == "windows": if system == "windows":
common: list[str] = [] common: list[str] = []
for sfx in (".bat", ".exe"): for sfx in (".bat", ".exe"):
for ver in ("2023.2", "2022.2", "2018.3"): for ver in ("2023.2", "2022.2"):
for prod in ("Vitis", "Vivado", "SDK"): for prod in ("Vitis", "Vivado"):
common.append(f"C:/Xilinx/{prod}/{ver}/bin/{exec_name}{sfx}") common.append(f"C:/Xilinx/{prod}/{ver}/bin/{exec_name}{sfx}")
else: else:
common = [ common = [
+22 -19
View File
@@ -125,7 +125,7 @@ def check_zynq_jtag(
if callback: if callback:
callback("progress", "Scanning JTAG chain...") callback("progress", "Scanning JTAG chain...")
# ── 3. Run Vivado TCL ────────────────────────────────────── # ── 3. Run Vivado TCL (stream output to callback) ──────────
tcl = _build_jtag_query_tcl() tcl = _build_jtag_query_tcl()
python_timeout = config.step_timeouts.get("check_env", 120) python_timeout = config.step_timeouts.get("check_env", 120)
@@ -141,27 +141,31 @@ def check_zynq_jtag(
output_lines: list[str] = [] output_lines: list[str] = []
try: try:
cmd = build_tool_command(vivado_path, *vivado_args) proc = subprocess.Popen(
# Use run() with communicate() under the hood — reliably build_tool_command(vivado_path, *vivado_args),
# drains pipes on Windows even when .bat wrappers are involved stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
result = subprocess.run(
cmd,
capture_output=True,
text=True, text=True,
timeout=python_timeout,
) )
output = result.stdout + result.stderr # Read line by line so user sees real-time progress
for line in output.splitlines(): deadline = time.monotonic() + python_timeout
stripped = line.strip() while True:
if stripped: line = proc.stdout.readline() if proc.stdout else ""
output_lines.append(stripped) if not line:
if callback: if proc.poll() is not None:
callback("progress", stripped) break
except subprocess.TimeoutExpired: if time.monotonic() > deadline:
proc.kill()
proc.wait()
return _fail( return _fail(
f"JTAG scan timed out after {python_timeout}s", f"JTAG scan timed out after {python_timeout}s",
hw_server_url, hw_server_url,
) )
time.sleep(0.1)
continue
line = line.rstrip("\n\r")
output_lines.append(line)
if callback and line.strip():
callback("progress", line)
except OSError as e: except OSError as e:
return _fail(f"Failed to run Vivado: {e}", hw_server_url) return _fail(f"Failed to run Vivado: {e}", hw_server_url)
finally: finally:
@@ -169,6 +173,7 @@ def check_zynq_jtag(
Path(tcl).unlink(missing_ok=True) Path(tcl).unlink(missing_ok=True)
except Exception: except Exception:
pass pass
# Clean up Vivado temp files we redirected
for f in ("vivado_jtag.jou", "vivado_jtag.log"): for f in ("vivado_jtag.jou", "vivado_jtag.log"):
try: try:
(Path(tmp) / f).unlink(missing_ok=True) (Path(tmp) / f).unlink(missing_ok=True)
@@ -252,7 +257,7 @@ def _extract_relevant(output: str) -> str:
def _build_jtag_query_tcl() -> str: def _build_jtag_query_tcl() -> str:
"""Build Vivado TCL script file for JTAG device enumeration.""" """Build Vivado TCL script file for JTAG device enumeration."""
content = """\ content = """\
open_hw open_hw_manager
connect_hw_server connect_hw_server
open_hw_target open_hw_target
puts "===JTAG_START===" puts "===JTAG_START==="
@@ -262,8 +267,6 @@ foreach dev [get_hw_devices] {
puts "DEVICE:$name IDCODE:$idcode" puts "DEVICE:$name IDCODE:$idcode"
} }
puts "===JTAG_END===" puts "===JTAG_END==="
close_hw
disconnect_hw_server
quit quit
""" """
import os import os