Compare commits
15
Commits
5f9049a321
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a116b36f | ||
|
|
fce940926f | ||
|
|
c0f7219d30 | ||
|
|
4da03e97d8 | ||
|
|
c9eae220f0 | ||
|
|
e09f16a3aa | ||
|
|
47ea614ba2 | ||
|
|
13ed0d89f8 | ||
|
|
0d66f65ef2 | ||
|
|
4c01acdf69 | ||
|
|
c0cac5b2c5 | ||
|
|
5ed195e2d6 | ||
|
|
639f2e3ce6 | ||
|
|
9502bba636 | ||
|
|
9238cbc63e |
+3
-1
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -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
|
|
||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+149
-44
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user