Compare commits

...
15 Commits
Author SHA1 Message Date
yuysh 97a116b36f fix: cancel SD timer on new log output; change popup prompt to QSPI 2026-06-12 18:29:36 +08:00
yuysh fce940926f feat: SD boot mode detection — kill hw_server + popup after 30s if Boot mode is SD 2026-06-12 18:16:35 +08:00
yuysh c0f7219d30 fix: replace Popen readline loop with subprocess.run for Windows pipe reliability; add close_hw before quit in TCL; revert console=True 2026-06-12 17:46:11 +08:00
yuysh 4da03e97d8 fix: open_hw_manager→open_hw for 2018.3 batch mode compat; revert console=True 2026-06-12 17:37:54 +08:00
yuysh c9eae220f0 Fix naming 2026-06-12 17:24:01 +08:00
yuysh e09f16a3aa fix: Xilinx 2018.3 tool discovery — add SDK dir, .bat PATH search, older versions 2026-06-12 17:20:49 +08:00
yuysh 47ea614ba2 fix: switch to console=False (GUI only, no terminal window) 2026-06-12 16:56:56 +08:00
yuysh 13ed0d89f8 fix: resolve config.yaml path to exe directory when frozen (PyInstaller) 2026-06-12 16:55:35 +08:00
yuysh 0d66f65ef2 feat: add PyInstaller spec + Windows build script (console=True) 2026-06-12 16:51:26 +08:00
yuysh 4c01acdf69 fix: print log messages to terminal in addition to GUI 2026-06-12 16:38:51 +08:00
yuysh c0cac5b2c5 fix: ping_ip bool unpack, skip sub-step visibility, IP 192.168.100.11 after Step 3
- ping_ip returns bool, not tuple — fixed 2 unpack errors
- TftpSubStepFrame: _skipped set prevents set_phase from overwriting Skip
- set_expanded(True) before skip marking so UI shows it immediately
- After Step 3 success, fix IP to 192.168.100.11
2026-06-12 16:35:32 +08:00
yuysh 5ed195e2d6 fix: skip marking, fixed panel ratio, dialog hints, 30s boot, ping+UDP fallback
- TftpSubStepFrame.set_step_skipped(): ◌ Skip in WARNING color
- _reload_config(): suppress callbacks to avoid duplicate log/status wipe
- _log_all_file_info(): removed clear_info(), updates in-place per-key
- Removed duplicate _log_file_info method
- main_frame grid: uniform col group for strict 7:3 ratio
- FileSelector browse title shows expected file type hint
- boot_wait_delay: default 10→30s
- Step 4 IP fallback: ping first, then UDP scan, extra boot_delay retry
2026-06-12 16:10:28 +08:00
yuysh 639f2e3ce6 feat: 5 improvements — file info, download verify, UART warning, status display, COM port reuse
- file_utils: compute_file_info() — CRC32, human-readable size, mtime
- StatusDisplay: simplified to info-only key-value panel; status messages
  removed (log-only); file info shown in status panel at startup/select
- config: added download_verify checkbox (default ON), skips Step 4.2
- UART warning: CTkTextbox with word-wrap and auto-height
- COM port: check_uart_available / test_serial_version accept optional
  UartMonitor to avoid redundant serial opens (Error 13 on Windows)
- layout: left:right 7:3 ratio, config panel stretches full width
2026-06-12 15:47:40 +08:00
yuysh 9502bba636 fix: config save broken (relative_to→relpath), startup validation, auto-create, untrack config.yaml
- _relative_path(): replace Path.relative_to() with os.path.relpath()
  (ValueError crossing filesystems silently returned absolute paths)
- _sync_config_from_ui(): always derive relative from get_path() absolute,
  never trust stale FileSelector._relative_path after Browse
- validate_paths(): check all file paths exist on startup, clear missing
- Clear UI selectors after validation to prevent re-save of stale paths
- _load_config(): auto-create blank config.yaml when missing on disk
- .gitignore: add config.yaml (user-specific, must not be tracked)
2026-06-12 15:02:09 +08:00
yuysh 9238cbc63e fix: correct indentation errors in widgets.py (TftpSubStepFrame, UartMonitorWindow) 2026-06-12 14:14:17 +08:00
12 changed files with 1041 additions and 232 deletions
+3 -1
View File
@@ -6,6 +6,7 @@ dist/
build/ build/
*.egg-info/ *.egg-info/
*.egg *.egg
*.exe
# ── Python ─────────────────────────────────────────────────── # ── Python ───────────────────────────────────────────────────
__pycache__/ __pycache__/
@@ -33,7 +34,8 @@ htmlcov/
# ── Opencode (internal tooling) ────────────────────────────── # ── Opencode (internal tooling) ──────────────────────────────
.opencode/ .opencode/
# ── Config overrides (user-specific) ───────────────────────── # ── Config (user-specific, auto-generated if missing) ─────────
config.yaml
config/zynq_flasher.yaml config/zynq_flasher.yaml
# ── Logs ───────────────────────────────────────────────────── # ── Logs ─────────────────────────────────────────────────────
+63
View File
@@ -0,0 +1,63 @@
# -*- 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
@@ -0,0 +1,47 @@
@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
@@ -1,27 +0,0 @@
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
+3 -1
View File
@@ -271,12 +271,14 @@ def _program_with_vivado(
callback("progress", "Using Vivado Hardware Manager...") callback("progress", "Using Vivado Hardware Manager...")
tcl_content = f""" tcl_content = f"""
open_hw_manager open_hw
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"
+39 -4
View File
@@ -28,13 +28,14 @@ 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": 10, "boot_wait_delay": 30,
"step_timeouts": { "step_timeouts": {
"check_env": 120, "check_env": 120,
"flash_erase": 120, "flash_erase": 120,
@@ -67,13 +68,14 @@ 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 = 10 boot_wait_delay: int = 30
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
@@ -176,6 +178,7 @@ 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,
@@ -190,8 +193,11 @@ 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 path string. path: Absolute or relative path string.
Returns: Returns:
Relative path string, or the original if conversion fails. Relative path string, or the original if conversion fails.
@@ -201,7 +207,8 @@ class Config:
try: try:
target = Path(path) target = Path(path)
if target.is_absolute(): if target.is_absolute():
return str(target.relative_to(self._config_path.parent)) base = str(self._config_path.parent)
return os.path.relpath(str(target), base)
except (ValueError, OSError): except (ValueError, OSError):
pass pass
return path return path
@@ -219,6 +226,34 @@ 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
@@ -0,0 +1,82 @@
"""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
+583 -106
View File
File diff suppressed because it is too large Load Diff
+149 -44
View File
@@ -6,6 +6,7 @@ consistent styling from gui/styles.py.
from __future__ import annotations from __future__ import annotations
import os
import threading import threading
import customtkinter as ctk import customtkinter as ctk
@@ -436,54 +437,91 @@ class ProgressIndicator(ctk.CTkFrame):
class StatusDisplay(ctk.CTkFrame): class StatusDisplay(ctk.CTkFrame):
"""Status display widget for operation results. """Info display widget — persistent key-value file/device info.
Shows a status message with color-coded feedback. Shows aligned ``key : value`` lines for file metadata, Zynq
part info, tool versions, etc.
""" """
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.""" self.grid_rowconfigure(0, weight=1)
self._status_label = ctk.CTkLabel( self.grid_columnconfigure(0, weight=1)
self._info_text = ctk.CTkTextbox(
self, self,
text="Ready",
font=FONT_BODY, font=FONT_BODY,
anchor="w", corner_radius=CORNER_RADIUS,
state="disabled",
wrap="word",
) )
self._status_label.pack( self._info_text.grid(
fill="x", 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:
"""Update the status message and color. """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.
Args: Args:
message: Status message text. key: Label for the info line.
status: Status type for coloring ('info', 'success', 'error', 'warning'). value: Value string.
""" """
self._status_label.configure(text=message) for i, (k, _) in enumerate(self._info_items):
colors = { if k == key:
"info": INFO_COLOR, self._info_items[i] = (key, value)
"success": SUCCESS_COLOR, break
"error": DANGER_COLOR, else:
"warning": WARNING_COLOR, self._info_items.append((key, value))
} self._refresh_info()
self._status_label.configure(text_color=colors.get(status, INFO_COLOR))
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")
class FileSelector(ctk.CTkFrame): class FileSelector(ctk.CTkFrame):
"""File selector widget with browse button and path display. """File selector widget with browse button and path display.
Allows users to browse for files and displays the selected path. 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.
""" """
def __init__( def __init__(
@@ -506,7 +544,11 @@ 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._selected_path = ctk.StringVar(value="") 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._create_widgets(label_text) self._create_widgets(label_text)
@@ -524,10 +566,10 @@ class FileSelector(ctk.CTkFrame):
) )
label.grid(row=0, column=0, sticky="w", padx=(PADDING, PADDING_SMALL)) label.grid(row=0, column=0, sticky="w", padx=(PADDING, PADDING_SMALL))
# Path entry # Path entry — shows only the filename
self._entry = ctk.CTkEntry( self._entry = ctk.CTkEntry(
self, self,
textvariable=self._selected_path, textvariable=self._display_text,
font=FONT_MONO, font=FONT_MONO,
state="readonly", state="readonly",
) )
@@ -549,33 +591,60 @@ class FileSelector(ctk.CTkFrame):
def _browse(self) -> None: def _browse(self) -> None:
"""Open file dialog and set selected path.""" """Open file dialog and set selected path."""
import customtkinter
from tkinter import filedialog from tkinter import filedialog
file_path = filedialog.askopenfilename( file_path = filedialog.askopenfilename(
title=f"Select {self._selected_path.get() or 'file'}", title=f"Select {self._hint_text}",
filetypes=self._file_types, filetypes=self._file_types,
) )
if file_path: if file_path:
self.set_path(file_path) self.set_path(file_path)
def set_path(self, path: str) -> None: def set_path(self, path: str) -> None:
"""Set the selected file path. """Set the selected file path (absolute or relative).
Internally stores the absolute path and displays only the
filename.
Args: Args:
path: Absolute path to the selected file. path: Absolute or relative path to the selected file.
""" """
self._selected_path.set(path) self._full_path = path
if self._callback: 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._callback(path) self._callback(path)
def get_path(self) -> str: def get_path(self) -> str:
"""Get the currently selected file path. """Get the currently selected file path (absolute).
Returns: Returns:
Selected file path string. Selected file path string.
""" """
return self._selected_path.get() 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
class LogDisplay(ctk.CTkFrame): class LogDisplay(ctk.CTkFrame):
@@ -650,15 +719,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 expanded. green complete, red error. Default collapsed.
""" """
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 = False self._collapsed = True
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")
@@ -683,6 +752,16 @@ 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 "")
@@ -784,19 +863,20 @@ 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 expanded. green complete, red error. Default collapsed.
""" """
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 = False self._collapsed = True
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")
@@ -819,6 +899,16 @@ 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 "")
@@ -847,6 +937,8 @@ 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)
@@ -886,6 +978,18 @@ 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
@@ -910,6 +1014,7 @@ 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)
@@ -967,7 +1072,7 @@ class UartMonitorWindow(ctk.CTkToplevel):
self._build_log_area() self._build_log_area()
self._build_status_bar() self._build_status_bar()
# Try to open serial port after window is drawn # Try to open serial port after window is drawn
self.after(100, self._try_open) self.after(100, self._try_open)
# ── Title Bar ────────────────────────────────────────────── # ── Title Bar ──────────────────────────────────────────────
+22 -10
View File
@@ -137,6 +137,7 @@ 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.
@@ -145,19 +146,24 @@ 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:
@@ -223,25 +229,31 @@ 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.
Sends 'ver\\r\\n' to the serial port and reads the response. If *monitor* is running, skips opening a new connection and
Attempts to parse version string from the response. returns the latest parsed version from the monitor instead.
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 serial.Serial(port, baudrate, timeout=timeout) as ser:
ser.reset_input_buffer() ser.reset_input_buffer()
+17 -10
View File
@@ -60,7 +60,8 @@ 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)
_PRODUCT_DIRS = ("Vitis", "Vivado") # SDK covers pre-2019 installations where xsct lived in SDK/*/bin/
_PRODUCT_DIRS = ("Vitis", "Vivado", "SDK")
# ── Version helpers ────────────────────────────────────────────────── # ── Version helpers ──────────────────────────────────────────────────
@@ -163,9 +164,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) 1. System PATH (shutil.which) — tries bare name, .exe, .bat on Windows
2. vitis_path/bin (legacy config) 2. vitis_path/bin (legacy config)
3. xilinx_root/Vitis/*/bin and Vivado/*/bin — pick NEWEST version 3. xilinx_root/Vitis/*/bin, Vivado/*/bin, SDK/*/bin — pick NEWEST version
4. Common install paths (cross-platform) 4. Common install paths (cross-platform)
Args: Args:
@@ -179,16 +180,22 @@ def _find_executable(
""" """
exec_suffix = ".exe" if system == "windows" else "" exec_suffix = ".exe" if system == "windows" else ""
# 1. System PATH # 1. System PATH — search without explicit extension first
found = shutil.which(f"{exec_name}{exec_suffix}") # 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")
if found: if found:
return found return found
# 2. Legacy vitis_path/bin # 2. Legacy vitis_path/bin
if vitis_path: if vitis_path:
candidate = Path(vitis_path) / "bin" / f"{exec_name}{exec_suffix}" for sfx in (".bat", ".exe") if system == "windows" else (exec_suffix,):
if candidate.is_file(): candidate = Path(vitis_path) / "bin" / f"{exec_name}{sfx}"
return str(candidate) if candidate.is_file():
return str(candidate)
# 3. Scan xilinx_root — pick newest version across Vitis + Vivado # 3. Scan xilinx_root — pick newest version across Vitis + Vivado
if xilinx_root: if xilinx_root:
@@ -203,8 +210,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"): for ver in ("2023.2", "2022.2", "2018.3"):
for prod in ("Vitis", "Vivado"): for prod in ("Vitis", "Vivado", "SDK"):
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 = [
+33 -29
View File
@@ -53,6 +53,7 @@ class ZynqCheckResult:
pl_detected: bool pl_detected: bool
hw_server_url: str hw_server_url: str
error: str = "" error: str = ""
hw_server_pid: int | None = None
# ── Patterns ────────────────────────────────────────────────────────── # ── Patterns ──────────────────────────────────────────────────────────
@@ -104,12 +105,14 @@ def check_zynq_jtag(
# ── 2. Start hw_server ───────────────────────────────────── # ── 2. Start hw_server ─────────────────────────────────────
hw_proc: subprocess.Popen | None = None hw_proc: subprocess.Popen | None = None
hw_pid: int | None = None
try: try:
hw_proc = subprocess.Popen( hw_proc = subprocess.Popen(
build_tool_command(hw_server_path), build_tool_command(hw_server_path),
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
hw_pid = hw_proc.pid
# Wait up to 10s for hw_server to bind # Wait up to 10s for hw_server to bind
started = False started = False
for _ in range(100): for _ in range(100):
@@ -118,14 +121,14 @@ def check_zynq_jtag(
started = True started = True
break break
if not started: if not started:
return _fail("hw_server did not bind port 3121 within 10s", hw_server_url) return _fail("hw_server did not bind port 3121 within 10s", hw_server_url, pid=hw_pid)
except OSError as e: except OSError as e:
return _fail(f"Failed to start hw_server: {e}", hw_server_url) return _fail(f"Failed to start hw_server: {e}", hw_server_url)
if callback: if callback:
callback("progress", "Scanning JTAG chain...") callback("progress", "Scanning JTAG chain...")
# ── 3. Run Vivado TCL (stream output to callback) ────────── # ── 3. Run Vivado TCL ──────────────────────────────────────
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,39 +144,35 @@ def check_zynq_jtag(
output_lines: list[str] = [] output_lines: list[str] = []
try: try:
proc = subprocess.Popen( cmd = build_tool_command(vivado_path, *vivado_args)
build_tool_command(vivado_path, *vivado_args), # Use run() with communicate() under the hood — reliably
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, # drains pipes on Windows even when .bat wrappers are involved
result = subprocess.run(
cmd,
capture_output=True,
text=True, 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,
pid=hw_pid,
) )
# 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: 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, pid=hw_pid)
finally: finally:
try: try:
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)
@@ -206,6 +205,7 @@ def check_zynq_jtag(
ps_detected=False, pl_detected=False, ps_detected=False, pl_detected=False,
hw_server_url=hw_server_url, hw_server_url=hw_server_url,
error=msg, error=msg,
hw_server_pid=hw_pid,
) )
ps_detected = any(d.is_arm_dap for d in jtag_chain) ps_detected = any(d.is_arm_dap for d in jtag_chain)
@@ -217,16 +217,18 @@ def check_zynq_jtag(
ps_detected=ps_detected, pl_detected=pl_detected, ps_detected=ps_detected, pl_detected=pl_detected,
hw_server_url=hw_server_url, hw_server_url=hw_server_url,
error="", error="",
hw_server_pid=hw_pid,
) )
# ── Helpers ─────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────
def _fail(error: str, url: str) -> ZynqCheckResult: def _fail(error: str, url: str, pid: int | None = None) -> ZynqCheckResult:
return ZynqCheckResult( return ZynqCheckResult(
devices=[], jtag_chain=[], is_present=False, devices=[], jtag_chain=[], is_present=False,
ps_detected=False, pl_detected=False, ps_detected=False, pl_detected=False,
hw_server_url=url, error=error, hw_server_url=url, error=error,
hw_server_pid=pid,
) )
@@ -257,7 +259,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_manager open_hw
connect_hw_server connect_hw_server
open_hw_target open_hw_target
puts "===JTAG_START===" puts "===JTAG_START==="
@@ -267,6 +269,8 @@ 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