Compare commits

..
12 Commits
Author SHA1 Message Date
yuysh c9636b2fcd Revert "fix: UART window stability on Windows — lift/focus, remove stray grab_release"
This reverts commit 4ef1ce0c6b.
2026-06-12 14:18:49 +08:00
yuysh 03bc5d5268 Revert "fix: try both COM11 and \\.\COM11 formats when opening serial port"
This reverts commit 36fbb6a662.
2026-06-12 14:18:49 +08:00
yuysh ef685d6657 Revert "fix: 5 UI improvements — relative paths, filename-only display, real-time config, multi-line status, maximize layout"
This reverts commit a196211708.
2026-06-12 14:18:49 +08:00
yuysh 8a83794dc5 Revert "fix: tag_config for CTkTextbox + auto-save on file selection"
This reverts commit e2e1fdd2a0.
2026-06-12 14:18:45 +08:00
yuysh 5704ada93c Revert "fix: suppress FileSelector callbacks during construction"
This reverts commit 5f9049a321.
2026-06-12 14:18:45 +08:00
yuysh 63711ffede merge: fast-forward to origin/master 2026-06-12 14:18:38 +08:00
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
12 changed files with 211 additions and 752 deletions
+1 -2
View File
@@ -33,8 +33,7 @@ htmlcov/
# ── Opencode (internal tooling) ──────────────────────────────
.opencode/
# ── Config (user-specific, auto-generated if missing) ─────────
config.yaml
# ── Config overrides (user-specific) ─────────────────────────
config/zynq_flasher.yaml
# ── 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...")
tcl_content = f"""
open_hw
open_hw_manager
connect_hw_server
open_hw_target
current_hw_device [lindex [get_hw_devices] 0]
program_hw_device -file "{bit_path}"
refresh_hw_device
close_hw
disconnect_hw_server
quit
"""
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_model": "s25fl256s1",
"erase_all": False,
"download_verify": True,
"firmware_bin_path": "",
"reboot_timeout": 30,
"ping_timeout": 5,
"ping_count": 3,
"uart_delay": 3,
"inter_step_delay": 2,
"boot_wait_delay": 30,
"boot_wait_delay": 10,
"step_timeouts": {
"check_env": 120,
"flash_erase": 120,
@@ -68,14 +67,13 @@ class Config:
flash_type: str = "qspi-x4-single"
flash_model: str = "s25fl256s1"
erase_all: bool = False
download_verify: bool = True
firmware_bin_path: str = ""
reboot_timeout: int = 30
ping_timeout: int = 5
ping_count: int = 3
uart_delay: int = 3
inter_step_delay: int = 2
boot_wait_delay: int = 30
boot_wait_delay: int = 10
step_timeouts: dict[str, int] = field(default_factory=lambda: {})
# Internal: path to the config file on disk
@@ -178,7 +176,6 @@ class Config:
"flash_type": self.flash_type,
"flash_model": self.flash_model,
"erase_all": self.erase_all,
"download_verify": self.download_verify,
"firmware_bin_path": self._relative_path(self.firmware_bin_path),
"reboot_timeout": self.reboot_timeout,
"ping_timeout": self.ping_timeout,
@@ -193,11 +190,8 @@ class Config:
def _relative_path(self, path: str) -> str:
"""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:
path: Absolute or relative path string.
path: Absolute path string.
Returns:
Relative path string, or the original if conversion fails.
@@ -207,8 +201,7 @@ class Config:
try:
target = Path(path)
if target.is_absolute():
base = str(self._config_path.parent)
return os.path.relpath(str(target), base)
return str(target.relative_to(self._config_path.parent))
except (ValueError, OSError):
pass
return path
@@ -226,34 +219,6 @@ class Config:
return Path()
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
def config_path(self) -> Path | None:
"""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
+95 -332
View File
@@ -7,7 +7,6 @@ built with CustomTkinter.
from __future__ import annotations
import os
import sys
import threading
import time
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 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,
@@ -94,35 +92,6 @@ class MainWindow(ctk.CTk):
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
@@ -140,18 +109,6 @@ class MainWindow(ctk.CTk):
# ── 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:
"""Find the user configuration file.
@@ -163,7 +120,8 @@ class MainWindow(ctk.CTk):
Returns:
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
user_config = app_dir / "config.yaml"
@@ -186,39 +144,33 @@ class MainWindow(ctk.CTk):
def _get_user_config_path(self) -> Path:
"""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:
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.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()
"""Load configuration from file or create default."""
if self._config_path:
try:
self._config = Config.from_file(self._config_path)
if self._config_path.name == "default_config.yaml":
user_config_path = self._get_user_config_path()
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
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
def _reload_config(self) -> None:
"""Re-read config.yaml and update UI selectors with latest paths.
@@ -233,15 +185,8 @@ class MainWindow(ctk.CTk):
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
# Update UI selectors with resolved (absolute) paths — selectors
# display only the filename internally.
for attr, selector in [
("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector),
@@ -254,8 +199,6 @@ class MainWindow(ctk.CTk):
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)
@@ -275,24 +218,18 @@ class MainWindow(ctk.CTk):
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)
# Sync IP address
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()
self._config.xilinx_path = self._xilinx_path_var.get()
# Sync serial port
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.
files = self._get_selected_files()
for attr, selector in [
("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector),
@@ -300,34 +237,15 @@ class MainWindow(ctk.CTk):
("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))
rel = selector.get_relative_path()
if rel:
setattr(self._config, attr, rel)
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
abs_path = selector.get_path()
if abs_path:
self._config._config_path # ensure _config_path is set
setattr(self._config, attr, self._config._relative_path(abs_path))
self._config.erase_all = self._erase_cb_var.get()
def _save_config(self) -> None:
"""Save current configuration to user config file (config.yaml).
@@ -355,11 +273,6 @@ class MainWindow(ctk.CTk):
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
@@ -380,60 +293,6 @@ class MainWindow(ctk.CTk):
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:
@@ -442,8 +301,7 @@ class MainWindow(ctk.CTk):
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")
self.main_frame.grid_columnconfigure(0, weight=1)
# ── Header ──
self._build_header(self.main_frame)
@@ -460,7 +318,7 @@ class MainWindow(ctk.CTk):
# ── 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_rowconfigure(3, weight=1)
right_frame.grid_columnconfigure(0, weight=1)
self._build_config_panel(right_frame)
@@ -659,7 +517,6 @@ class MainWindow(ctk.CTk):
padx=PADDING, pady=PADDING,
)
panel.grid_rowconfigure(9, weight=1)
panel.grid_columnconfigure(0, weight=1) # let content stretch
title = ctk.CTkLabel(
panel,
@@ -701,9 +558,7 @@ class MainWindow(ctk.CTk):
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)
@@ -715,9 +570,7 @@ class MainWindow(ctk.CTk):
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)
@@ -729,9 +582,7 @@ class MainWindow(ctk.CTk):
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)
@@ -743,9 +594,7 @@ class MainWindow(ctk.CTk):
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)
@@ -774,39 +623,18 @@ class MainWindow(ctk.CTk):
)
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,
@@ -933,27 +761,23 @@ class MainWindow(ctk.CTk):
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(
# UART warning label (persistent, not overwritten by other status updates)
self._uart_warning_label = ctk.CTkLabel(
panel,
text="",
font=FONT_BODY,
height=40,
corner_radius=4,
fg_color="transparent",
state="disabled",
wrap="word",
anchor="w",
text_color=WARNING_COLOR,
)
self._uart_warning.grid(
self._uart_warning_label.grid(
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 ─────────────────────────────────────────
def _log_message(self, message: str) -> None:
"""Append a message to the log display and terminal (thread-safe)."""
print(message, flush=True)
"""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:
@@ -1010,10 +834,6 @@ class MainWindow(ctk.CTk):
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:
@@ -1067,7 +887,6 @@ class MainWindow(ctk.CTk):
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
@@ -1079,8 +898,7 @@ class MainWindow(ctk.CTk):
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)
available, reason = check_uart_available(port, self._config.serial_baudrate)
self._uart_available = available
self._uart_message = reason
if available:
@@ -1108,26 +926,17 @@ class MainWindow(ctk.CTk):
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).
"""Display the persistent UART warning message.
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))
self._uart_warning_label.configure(text=msg)
self._uart_warning_label.grid()
def _hide_uart_warning(self) -> None:
"""Hide the persistent UART warning."""
self._uart_warning.grid_remove()
"""Hide the persistent UART warning label."""
self._uart_warning_label.grid_remove()
def _on_window_resize(self, event) -> None:
"""Handle window resize / maximize events.
@@ -1425,65 +1234,54 @@ class MainWindow(ctk.CTk):
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
# 4b: TFTP 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_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._tftp_phase = "Download Verify"
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_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)
self._tftp_sub_step_frame.set_step_error("Download Verify")
all_results.append(False)
if not all_results[-1]:
self._log_message(" Skipping reboot (download/CRC failed)")
self._log_message(" Skipping CRC check (download failed)")
return False
# 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 (CRC mismatch)")
return False
# 4d: Reboot
@@ -1580,38 +1378,16 @@ class MainWindow(ctk.CTk):
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
# Discover actual IP after reboot (may have changed via DHCP)
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}")
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:
# 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")
self._log_message(" ⚠ Zynq not found on any scanned IP")
if discovered_ip and discovered_ip != self._config.zynq_ip:
self._log_message(
@@ -1820,13 +1596,6 @@ class MainWindow(ctk.CTk):
elapsed = time.time() - t_step_start
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
if i == 1 and hasattr(self, '_sub_step_frame'):
if success:
@@ -1843,11 +1612,6 @@ class MainWindow(ctk.CTk):
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)
@@ -1999,7 +1763,6 @@ class MainWindow(ctk.CTk):
success, message, version = test_serial_version(
port,
self._config.serial_baudrate if self._config else 115200,
monitor=self._uart_monitor,
)
if success:
+37 -122
View File
@@ -6,7 +6,6 @@ consistent styling from gui/styles.py.
from __future__ import annotations
import os
import threading
import customtkinter as ctk
@@ -437,91 +436,54 @@ class ProgressIndicator(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
part info, tool versions, etc.
Shows a status message with color-coded feedback.
"""
def __init__(self, master, **kwargs):
"""Initialize the status display.
Args:
master: Parent widget.
"""
super().__init__(master, **kwargs)
self._info_items: list[tuple[str, str]] = []
self._create_widgets()
def _create_widgets(self) -> None:
self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(0, weight=1)
self._info_text = ctk.CTkTextbox(
"""Create the status display UI."""
self._status_label = ctk.CTkLabel(
self,
text="Ready",
font=FONT_BODY,
corner_radius=CORNER_RADIUS,
state="disabled",
wrap="word",
anchor="w",
)
self._info_text.grid(
row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL
self._status_label.pack(
fill="x", 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:
"""Compatibility stub — status messages go to log now."""
def set_info(self, key: str, value: str) -> None:
"""Set or update a persistent info line.
Displays as ``key : value`` in the info block.
"""Update the status message and color.
Args:
key: Label for the info line.
value: Value string.
message: Status message text.
status: Status type for coloring ('info', 'success', 'error', 'warning').
"""
for i, (k, _) in enumerate(self._info_items):
if k == key:
self._info_items[i] = (key, value)
break
else:
self._info_items.append((key, value))
self._refresh_info()
def clear_info(self) -> None:
"""Remove all persistent info lines."""
self._info_items.clear()
self._info_text.configure(state="normal")
self._info_text.delete("1.0", "end")
self._info_text.configure(state="disabled")
def _refresh_info(self) -> None:
"""Rebuild the info text block from current items."""
self._info_text.configure(state="normal")
self._info_text.delete("1.0", "end")
if not self._info_items:
self._info_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")
self._status_label.configure(text=message)
colors = {
"info": INFO_COLOR,
"success": SUCCESS_COLOR,
"error": DANGER_COLOR,
"warning": WARNING_COLOR,
}
self._status_label.configure(text_color=colors.get(status, INFO_COLOR))
class FileSelector(ctk.CTkFrame):
"""File selector widget with browse button and path display.
Stores the full absolute path internally but displays only the
filename in the entry field. Supports relative paths: the
``set_relative_path()`` / ``get_relative_path()`` methods work
with paths relative to a given base directory.
Allows users to browse for files and displays the selected path.
"""
def __init__(
@@ -544,11 +506,7 @@ class FileSelector(ctk.CTkFrame):
self._callback = callback
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._relative_path: str = "" # relative path (synced to Config)
self._display_text = ctk.StringVar(value="")
self._suppress_callback: bool = False # True during construction
self._selected_path = ctk.StringVar(value="")
self._create_widgets(label_text)
@@ -566,10 +524,10 @@ class FileSelector(ctk.CTkFrame):
)
label.grid(row=0, column=0, sticky="w", padx=(PADDING, PADDING_SMALL))
# Path entry — shows only the filename
# Path entry
self._entry = ctk.CTkEntry(
self,
textvariable=self._display_text,
textvariable=self._selected_path,
font=FONT_MONO,
state="readonly",
)
@@ -591,60 +549,33 @@ class FileSelector(ctk.CTkFrame):
def _browse(self) -> None:
"""Open file dialog and set selected path."""
import customtkinter
from tkinter import filedialog
file_path = filedialog.askopenfilename(
title=f"Select {self._hint_text}",
title=f"Select {self._selected_path.get() or 'file'}",
filetypes=self._file_types,
)
if file_path:
self.set_path(file_path)
def set_path(self, path: str) -> None:
"""Set the selected file path (absolute or relative).
Internally stores the absolute path and displays only the
filename.
"""Set the selected file path.
Args:
path: Absolute or relative path to the selected file.
path: Absolute path to the selected file.
"""
self._full_path = path
self._display_text.set(os.path.basename(path) if path else "")
if self._callback and not self._suppress_callback:
self._callback(path)
def set_relative_path(self, path: str) -> None:
"""Set a relative path (from config).
Resolves to absolute for internal storage, shows only filename.
Args:
path: Relative path string.
"""
self._relative_path = path
# Resolve to absolute for internal use
if path:
self._full_path = path # caller should resolve before calling
self._display_text.set(os.path.basename(path) if path else "")
if self._callback and not self._suppress_callback:
self._selected_path.set(path)
if self._callback:
self._callback(path)
def get_path(self) -> str:
"""Get the currently selected file path (absolute).
"""Get the currently selected file path.
Returns:
Selected file path string.
"""
return self._full_path
def get_relative_path(self) -> str:
"""Get the currently selected relative path.
Returns:
Relative path string, or empty string.
"""
return self._relative_path
return self._selected_path.get()
class LogDisplay(ctk.CTkFrame):
@@ -871,7 +802,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
self._collapsed = True
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._num_steps = len(self._substep_names)
@@ -937,8 +867,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
pct_text = f"{pct}%" if pct >= 0 else ""
for i, item in enumerate(self._sub_items):
if i in self._skipped:
continue # leave skipped items untouched
if i < idx:
item["dot"].configure(text="", text_color=_C_DONE)
item["label"].configure(text_color=_C_DONE)
@@ -978,18 +906,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
item["label"].configure(text_color=_C_ERROR)
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:
"""Start pulsing the running sub-step."""
_LIGHT = RUNNING_FADE_LIGHT
@@ -1014,7 +930,6 @@ class TftpSubStepFrame(ctk.CTkFrame):
def reset(self) -> None:
self._stop_pulse()
self._skipped.clear()
_C = ACCENT_PENDING
for i, item in enumerate(self._sub_items):
item["dot"].configure(text="", text_color=_C)
+10 -22
View File
@@ -137,7 +137,6 @@ def check_uart_available(
port: str,
baudrate: int = 115200,
timeout: float = 3.0,
monitor: UartMonitor | None = None,
) -> tuple[bool, str]:
"""Check whether the UART serial port is available and readable.
@@ -146,24 +145,19 @@ def check_uart_available(
considered available (the device may simply not be outputting yet).
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:
port: Serial port device path (e.g., '/dev/ttyUSB0').
baudrate: Baud rate for serial communication.
timeout: Seconds to wait for data after opening.
monitor: Optional running UartMonitor — if active, reuse it.
Returns:
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
ports = detect_serial_ports()
if not ports:
@@ -229,31 +223,25 @@ def test_serial_version(
port: str,
baudrate: int = 115200,
timeout: float = 5.0,
monitor: UartMonitor | None = None,
) -> tuple[bool, str, str]:
"""Test serial port by sending 'ver()' command and parsing response.
If *monitor* is running, skips opening a new connection and
returns the latest parsed version from the monitor instead.
Sends 'ver\\r\\n' to the serial port and reads the response.
Attempts to parse version string from the response.
Args:
port: Serial port device path (e.g., '/dev/ttyUSB0').
baudrate: Baud rate for serial communication.
timeout: Read timeout in seconds.
monitor: Optional running UartMonitor — if active, reuse it.
Returns:
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
# 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:
with serial.Serial(port, baudrate, timeout=timeout) as ser:
ser.reset_input_buffer()
+10 -17
View File
@@ -60,8 +60,7 @@ TOOL_ALIASES: dict[str, list[str]] = {
}
# 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", "SDK")
_PRODUCT_DIRS = ("Vitis", "Vivado")
# ── Version helpers ──────────────────────────────────────────────────
@@ -164,9 +163,9 @@ def _find_executable(
"""Find an executable by name using PATH, Vitis, and Xilinx root.
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)
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)
Args:
@@ -180,22 +179,16 @@ def _find_executable(
"""
exec_suffix = ".exe" if system == "windows" else ""
# 1. System PATH — search without explicit extension first
# 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}")
if not found:
found = shutil.which(f"{exec_name}.bat")
# 1. System PATH
found = shutil.which(f"{exec_name}{exec_suffix}")
if found:
return found
# 2. Legacy vitis_path/bin
if vitis_path:
for sfx in (".bat", ".exe") if system == "windows" else (exec_suffix,):
candidate = Path(vitis_path) / "bin" / f"{exec_name}{sfx}"
if candidate.is_file():
return str(candidate)
candidate = Path(vitis_path) / "bin" / f"{exec_name}{exec_suffix}"
if candidate.is_file():
return str(candidate)
# 3. Scan xilinx_root — pick newest version across Vitis + Vivado
if xilinx_root:
@@ -210,8 +203,8 @@ def _find_executable(
if system == "windows":
common: list[str] = []
for sfx in (".bat", ".exe"):
for ver in ("2023.2", "2022.2", "2018.3"):
for prod in ("Vitis", "Vivado", "SDK"):
for ver in ("2023.2", "2022.2"):
for prod in ("Vitis", "Vivado"):
common.append(f"C:/Xilinx/{prod}/{ver}/bin/{exec_name}{sfx}")
else:
common = [
+26 -23
View File
@@ -125,7 +125,7 @@ def check_zynq_jtag(
if callback:
callback("progress", "Scanning JTAG chain...")
# ── 3. Run Vivado TCL ──────────────────────────────────────
# ── 3. Run Vivado TCL (stream output to callback) ──────────
tcl = _build_jtag_query_tcl()
python_timeout = config.step_timeouts.get("check_env", 120)
@@ -141,27 +141,31 @@ def check_zynq_jtag(
output_lines: list[str] = []
try:
cmd = build_tool_command(vivado_path, *vivado_args)
# Use run() with communicate() under the hood — reliably
# drains pipes on Windows even when .bat wrappers are involved
result = subprocess.run(
cmd,
capture_output=True,
proc = subprocess.Popen(
build_tool_command(vivado_path, *vivado_args),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True,
timeout=python_timeout,
)
output = result.stdout + result.stderr
for line in output.splitlines():
stripped = line.strip()
if stripped:
output_lines.append(stripped)
if callback:
callback("progress", stripped)
except subprocess.TimeoutExpired:
return _fail(
f"JTAG scan timed out after {python_timeout}s",
hw_server_url,
)
# Read line by line so user sees real-time progress
deadline = time.monotonic() + python_timeout
while True:
line = proc.stdout.readline() if proc.stdout else ""
if not line:
if proc.poll() is not None:
break
if time.monotonic() > deadline:
proc.kill()
proc.wait()
return _fail(
f"JTAG scan timed out after {python_timeout}s",
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:
return _fail(f"Failed to run Vivado: {e}", hw_server_url)
finally:
@@ -169,6 +173,7 @@ def check_zynq_jtag(
Path(tcl).unlink(missing_ok=True)
except Exception:
pass
# Clean up Vivado temp files we redirected
for f in ("vivado_jtag.jou", "vivado_jtag.log"):
try:
(Path(tmp) / f).unlink(missing_ok=True)
@@ -252,7 +257,7 @@ def _extract_relevant(output: str) -> str:
def _build_jtag_query_tcl() -> str:
"""Build Vivado TCL script file for JTAG device enumeration."""
content = """\
open_hw
open_hw_manager
connect_hw_server
open_hw_target
puts "===JTAG_START==="
@@ -262,8 +267,6 @@ foreach dev [get_hw_devices] {
puts "DEVICE:$name IDCODE:$idcode"
}
puts "===JTAG_END==="
close_hw
disconnect_hw_server
quit
"""
import os