Compare commits

...
10 Commits
Author SHA1 Message Date
Jeremy Shen 5ce544cd3c 🐛 fix: recover JTAG DAP after flash programming with system reset
- Add preemptive rst -system at Step 3 start to clean post-flash DAP state
- Replace ineffective SLCR write with targets 1 → rst -system recovery
- Exit with clear power-cycle instructions when DAP recovery fails
- Show prominent DAP error guidance in GUI for Step 3 and Run All mode

Verified: Step 1→2→3 now passes end-to-end (340s) without board power cycle.
2026-06-10 18:29:26 +08:00
Jeremy Shen 73b8225a27 feat: DAP error detection and recovery in Step 3 TCL
- TCL now checks for ARM core visibility before attempting flow
- If ARM cores are not found (DAP APB transaction error):
  * Logs WARNING with target list
  * Attempts SLCR system reset (0xF8000200)
  * Reconnects after recovery attempt
- DAP errors detected in xsct output and logged to GUI log
- Result message shows 'DAP error — board needs power cycle'
2026-06-10 17:52:14 +08:00
Jeremy Shen 0568315b83 fix: re-read config.yaml before each workflow run
_reload_config(): re-reads config.yaml and updates all UI selectors
(FSBL, BIT, ELF, BIN, FW BIN) and IP/xilinx_path fields with latest
values from file.

Called at the top of both _execute_step() and _run_all_steps(),
so changing config.yaml externally is picked up before every run.
2026-06-10 17:04:32 +08:00
Jeremy Shen 7067ce273f feat: user TCL flow + parser dedup + Python TFTP
1. bitstream_programmer — combined BIT+ELF flow (user's TCL):
   - targets -set -nocase -filter {name =~ "arm*#0"}
   - rst -processor (bypass BootROM, block Flash boot)
   - dow fsbl → con → stop (FSBL hardware init)
   - fpga -f bit (configure PL)
   - dow app → con (run bootloader)
   - Single xsct session, no ps7_init dependency

2. serial_monitor — parser fixes:
   - Boot mode parsing: 'Boot mode is JTAG' → Boot=JTAG
   - Version noise filter: rejects '3.1','update','mode','loaded'
   - Deduplication: UartMonitor only emits when (boot_mode, IP, ver) changes
   - Fixed false match: 'Boot mode' no longer captured as version

3. tftp_manager — pure Python TFTP client:
   - _tftp_put(): RFC 1350 WRQ/DATA/ACK via UDP socket
   - No external tftp command dependency
   - tftp_download(): documented as not-implemented (needs PC server)
2026-06-10 16:57:54 +08:00
Jeremy Shen 9a7a625c46 feat: boot mode parsing, FSBL-first flow, UI reorder
1. Boot mode parsing:
   - BootInfo.boot_mode field added
   - Matches 'Boot mode is JTAG', 'Boot Mode: QSPI', etc.
   - Fixed false version match: 'Boot mode is' no longer
     captures 'mode' as a version string

2. UART window cleanup:
   - Parsed boot info (mode/IP/ver) now only goes to main log
   - Raw serial lines still appear in UART window

3. Step 3 FSBL enforcement:
   - BIT step: after fpga -file, CPU is immediately halted (stop)
     to prevent Flash code from enabling MMU
   - ELF step: always dow FSBL → con → stop → dow bootloader

4. UI reorder:
   - FSBL ELF selector moved BEFORE Bootloader BIT
   - New layout: FSBL → BIT → ELF → Bootloader BIN → Flash → FW BIN
   - Fixed grid overlap (FSBL and Flash were both at row 6)
2026-06-10 16:20:12 +08:00
Jeremy Shen 47569a0e43 refactor: ELF step uses FSBL instead of ps7_init
Problem: dow app.elf to DDR (0x100000) failed with MMU section
translation fault because the CPU had MMU enabled from previously
executed Flash code.  ps7_init could reset this but user wants
FSBL-based flow.

Fix: run_elf() now downloads FSBL first (to OCM 0x0 — always
accessible), runs it to initialize clocks/DDR/MIO and force JTAG
boot mode, then downloads the app ELF to DDR.

New flow:
  connect → targets 2 → dow fsbl.elf → con → after 3s → stop
  → dow app.elf → mwr 0xF800025C 0x1 → con

Fallback: if no FSBL configured, use direct dow (legacy path).

Removed: _run_elf_with_xsct (2-attempt ps7_init recovery) replaced
by _run_elf_via_fsbl and _run_elf_direct.
2026-06-10 16:09:05 +08:00
Jeremy Shen 8b2fac931c fix: FirmWare version parsing + prevent boot-from-Flash in ELF step
Issue 1 — FirmWare version not parsed:
  Input: [Info] FirmWare:V1.2.1.2.1_11_07_pmu_log3
  Fix:  regex [Ff]irm[Ww]are:?\s*  (was [Ff]irmware\s+ — no : or camelCase)
        Plus broader version capture: [Vv]\d+\.\d+\.\d+[^\s;,\n]*

Issue 2 — ELF loaded from Flash instead of downloaded ELF:
  Root cause: Zynq devcfg.CTRL (0xF800025C) PCAP_PROG_B may trigger
  reconfiguration from Flash after fpga, causing CPU to jump to
  Flash-loaded code instead of our dow'd ELF.

  BIT step: add ps7_post_config after fpga (finalizes PL-PS interfaces)
             add mwr 0xF800025C 0x1 (disables PCAP reconfig)
  ELF step: add mwr 0xF800025C 0x1 after dow, before con
             (both attempt-1 and attempt-2 fallback)
2026-06-10 15:50:31 +08:00
Jeremy Shen 09be20791f feat: forward UART parsed boot info to main log window
UartMonitorWindow now accepts on_log callback. When boot info
(IP, version) is parsed from serial output, it:
- Appears in the UART window's own log (prefix [UART])
- Forwards to main window's log panel via on_log callback
- Unified [UART] prefix across both windows (was [BOOT] in
  UART window, [UART] Boot detected: in main log)
2026-06-10 15:40:09 +08:00
Jeremy Shen b294dfe58a fix: ELF step no longer re-runs ps7_init unnecessarily
Problem: _run_elf_with_xsct() ALWAYS appended ps7_init before dow,
even though PS was already initialized by the prior BIT download step.
Re-running ps7_init after FPGA config resets PS registers (clocks,
DDR, MIO), which breaks subsequent ELF download.

Fix: two-attempt strategy —
  1. First try WITHOUT ps7_init (DAP alive from BIT step):
     connect → targets 2 → dow → con
  2. If that fails, retry WITH ps7_init as DAP recovery:
     connect → targets 1 → source ps7_init.tcl → ps7_init → targets 2 → dow → con

Additional improvements:
- Inner _run() helper deduplicates TCL execution logic
- Better progress messages distinguish attempts
- Extended success phrase detection (adds 'Successfully downloaded')
- Nested TimeoutExpired handling per attempt
2026-06-10 15:30:11 +08:00
Jeremy Shen a47e3b78ae fix: avoid serial port conflict between workflow and UART window
Flow execution's _start_uart_monitor() now checks if a UART window
is already monitoring before starting its own UartMonitor instance.
This prevents two UartMonitor threads from fighting over the same
serial port.

Changes:
- UartMonitorWindow: add is_monitoring property
- MainWindow._uart_window: store reference to open UART window
- _start_uart_monitor(): skip if UART window already monitoring
- _on_close(): destroy UART window on main window close
2026-06-10 15:23:28 +08:00
6 changed files with 566 additions and 237 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
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/bootloader/app.elf
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
+324 -53
View File
@@ -214,7 +214,7 @@ def _program_with_xsct(
if callback:
callback("progress", "Initializing PS then programming FPGA...")
# Build TCL: PS init first, then FPGA config
# Build TCL: PS init FPGA config → post-config → halt CPU → disable Flash boot
lines = ["connect"]
if ps7_init_tcl and Path(ps7_init_tcl).exists():
lines.append("targets 1")
@@ -223,6 +223,17 @@ def _program_with_xsct(
if callback:
callback("progress", "PS initialized (clocks, DDR, MIO)")
lines.append(f'fpga -file "{bit_path}"')
if ps7_init_tcl and Path(ps7_init_tcl).exists():
lines.append("ps7_post_config")
if callback:
callback("progress", "PS post-config (PL-PS interfaces)")
# After FPGA config, CPU may be reset — halt it to prevent Flash code
# from enabling MMU before the ELF step can download FSBL
lines.append("targets 2")
lines.append("after 100")
lines.append("stop")
# Prevent boot from Flash by disabling PCAP reconfiguration
lines.append("mwr 0xF800025C 0x00000001")
lines.append("exit")
tcl_script = "\n".join(lines)
@@ -323,7 +334,9 @@ def run_elf(
) -> BitstreamResult:
"""Download and run a .elf executable on the Zynq PS ARM core.
Uses xsct's `dow` (download) command, with Vivado as fallback.
First downloads FSBL (First Stage Bootloader) to OCM (0x0) to
initialize clocks, DDR, MIO and force JTAG boot mode. Then
downloads the target ELF to DDR.
Args:
config: Application configuration.
@@ -341,10 +354,15 @@ def run_elf(
message=f"ELF file not found: {elf_path}",
)
fsbl_path = config.fsbl_elf_path if hasattr(config, 'fsbl_elf_path') else ""
if fsbl_path and not Path(fsbl_path).exists():
if callback:
callback("warning", f"FSBL not found: {fsbl_path}, will try direct dow")
fsbl_path = ""
if callback:
callback("start", f"Running ELF: {elf_path.name}...")
# Use xsct for CPU operations (Vivado HM cannot program PS CPU)
xsct = get_xsct_path(
vitis_path=config.vitis_path if hasattr(config, 'vitis_path') else "",
xilinx_root=config.xilinx_path if hasattr(config, 'xilinx_path') else "",
@@ -356,91 +374,168 @@ def run_elf(
message="xsct not found in PATH (required for CPU operations)",
)
# Auto-detect ps7_init.tcl from config or BIT file directory
ps7_tcl = _find_ps7_init_tcl(config)
timeout = config.step_timeouts.get("elf_download", 60)
return _run_elf_with_xsct(xsct, elf_path, callback, ps7_tcl, timeout)
if fsbl_path:
return _run_elf_via_fsbl(xsct, elf_path, fsbl_path, callback, timeout)
else:
return _run_elf_direct(xsct, elf_path, callback, timeout)
def _run_elf_with_xsct(
def _run_elf_via_fsbl(
xsct_path: str,
elf_path: Path,
fsbl_path: str,
callback: ProgressCallback = None,
ps7_init_tcl: str = "",
timeout: int = 60,
) -> BitstreamResult:
"""Download and run ELF using xsct's dow command.
"""Download and run ELF via FSBL bootloader.
Assumes PS was already initialized (by a prior BIT download step).
Falls back to PS init if DAP is broken.
FSBL loads to OCM (0x0) which is always accessible even when
MMU is enabled. FSBL initializes clocks/DDR/MIO and forces
JTAG boot mode, making DDR accessible for the app ELF.
Args:
xsct_path: Path to xsct executable.
elf_path: Path to .elf file.
elf_path: Path to target .elf file.
fsbl_path: Path to FSBL .elf file.
callback: Optional progress callback.
ps7_init_tcl: Optional ps7_init.tcl path for recovery.
timeout: Subprocess timeout in seconds.
Returns:
BitstreamResult with status.
"""
if callback:
callback("progress", "Downloading ELF...")
lines = ["connect"]
# Recovery: if DAP is broken, re-init PS
if ps7_init_tcl and Path(ps7_init_tcl).exists():
lines.append("targets 1")
lines.append(f'source "{ps7_init_tcl}"')
lines.append("ps7_init")
lines.append("targets 2")
lines.append(f'dow "{elf_path}"')
lines.append("con")
lines.append("exit")
tcl_script = "\n".join(lines)
import tempfile, os
with tempfile.NamedTemporaryFile(mode='w', suffix='.tcl', delete=False) as f:
f.write(tcl_script)
tcl_file = f.name
if callback:
callback("progress", "Downloading FSBL to OCM...")
tcl = "\n".join([
"connect",
"targets 2",
"dow \"%s\"" % fsbl_path,
"puts \"FSBL downloaded\"",
"con",
"after 3000",
"stop",
"puts \"FSBL done, CPU halted\"",
"dow \"%s\"" % elf_path,
"mwr 0xF800025C 0x00000001",
"con",
"puts \"App running\"",
"exit",
])
with tempfile.NamedTemporaryFile(
mode="w", suffix=".tcl", delete=False
) as tf:
tf.write(tcl)
tcl_file = tf.name
try:
result = subprocess.run(
[xsct_path, tcl_file],
capture_output=True,
text=True,
timeout=timeout,
capture_output=True, text=True, timeout=timeout,
)
os.unlink(tcl_file)
success = result.returncode == 0
output = (result.stdout + result.stderr).strip()
# xsct may return non-zero even if download succeeded
if any(phrase in output for phrase in ("Download successful", "Done", "done")):
success = True
success = result.returncode == 0 or any(
p in output for p in (
"App running", "Download successful",
"Successfully downloaded",
)
)
if callback:
callback(
"complete" if success else "error",
"ELF " + ("executed" if success else "failed"),
"ELF " + ("executed via FSBL" if success else "failed"),
)
return BitstreamResult(
step="elf",
success=success,
message="ELF executed successfully" if success else "ELF execution failed",
message="ELF executed via FSBL" if success else "ELF execution failed",
output=output[:4000],
)
except subprocess.TimeoutExpired:
os.unlink(tcl_file)
return BitstreamResult(
step="elf",
success=False,
message="xsct ELF download timed out",
return BitstreamResult(step="elf", success=False,
message="ELF download timed out")
def _run_elf_direct(
xsct_path: str,
elf_path: Path,
callback: ProgressCallback = None,
timeout: int = 60,
) -> BitstreamResult:
"""Download and run ELF directly (no FSBL, assumes DAP alive).
Used when no FSBL ELF is configured. Attempts direct dow
which only works if the CPU is in a clean state with MMU off.
Args:
xsct_path: Path to xsct executable.
elf_path: Path to .elf file.
callback: Optional progress callback.
timeout: Subprocess timeout in seconds.
Returns:
BitstreamResult with status.
"""
import tempfile, os
if callback:
callback("progress", "Downloading ELF directly...")
tcl = "\n".join([
"connect",
"targets 2",
"dow \"%s\"" % elf_path,
"mwr 0xF800025C 0x00000001",
"con",
"exit",
])
with tempfile.NamedTemporaryFile(
mode="w", suffix=".tcl", delete=False
) as tf:
tf.write(tcl)
tcl_file = tf.name
try:
result = subprocess.run(
[xsct_path, tcl_file],
capture_output=True, text=True, timeout=timeout,
)
os.unlink(tcl_file)
output = (result.stdout + result.stderr).strip()
success = result.returncode == 0 or any(
p in output for p in (
"Download successful", "Successfully downloaded",
)
)
if callback:
callback("complete" if success else "error",
"ELF " + ("executed" if success else "failed"))
# ── Full Workflow ───────────────────────────────────────────────────
return BitstreamResult(
step="elf",
success=success,
message="ELF executed" if success else "ELF execution failed",
output=output[:4000],
)
except subprocess.TimeoutExpired:
os.unlink(tcl_file)
return BitstreamResult(step="elf", success=False,
message="ELF download timed out")
# ── Full Workflow (combined BIT+ELF in one xsct session) ──────────
def full_bitstream_program(
@@ -449,7 +544,13 @@ def full_bitstream_program(
elf_path: str | Path,
callback: ProgressCallback = None,
) -> list[BitstreamResult]:
"""Execute full bootloader workflow: program bitstream → run ELF.
"""Execute full bootloader workflow in one xsct session.
Flow:
1. rst -processor (bypass BootROM, block Flash boot)
2. dow fsbl → con → stop (FSBL initializes clocks/DDR/MIO)
3. fpga -f bit (configure PL)
4. dow app → con (run bootloader)
Args:
config: Application configuration.
@@ -460,11 +561,181 @@ def full_bitstream_program(
Returns:
List of BitstreamResult for each step.
"""
results: list[BitstreamResult] = []
import tempfile, os
results.append(program_bitstream(config, bit_path, callback))
if not results[-1].success:
return results
bit_path = Path(bit_path)
elf_path = Path(elf_path)
fsbl_path = config.fsbl_elf_path if hasattr(config, 'fsbl_elf_path') else ""
results.append(run_elf(config, elf_path, callback))
return results
# Validate files
if not bit_path.exists():
return [BitstreamResult(step="bitstream", success=False,
message=f"BIT not found: {bit_path}")]
if not elf_path.exists():
return [BitstreamResult(step="elf", success=False,
message=f"ELF not found: {elf_path}")]
if not fsbl_path or not Path(fsbl_path).exists():
return [BitstreamResult(step="elf", success=False,
message=f"FSBL not found: {fsbl_path or '(not configured)'}")]
xsct = get_xsct_path(
vitis_path=config.vitis_path if hasattr(config, 'vitis_path') else "",
xilinx_root=config.xilinx_path if hasattr(config, 'xilinx_path') else "",
)
if not xsct:
return [BitstreamResult(step="bitstream", success=False,
message="xsct not found")]
if callback:
callback("start", "Initializing Zynq via JTAG (FSBL override)...")
# Build TCL with preemptive system reset + DAP error recovery
tcl_lines = [
'puts "INFO: Starting Zynq initialization via JTAG override..."',
"connect",
"after 500",
]
# Preemptive system reset (safe on clean board; may help after flash)
tcl_lines.extend([
'# Preemptive system reset to clean up any leftover state',
'if {[catch {',
' targets -set -nocase -filter {name =~ "arm*#0"}',
' rst -system',
' after 1000',
' stop',
' puts "INFO: Preemptive system reset OK"',
'}]} { puts "INFO: Preemptive system reset skipped (no ARM target)" }',
'',
])
# DAP error check — if ARM cores not visible, try recovery via DAP
tcl_lines.extend([
'# Check for DAP errors and attempt recovery',
'set target_list [targets]',
'set has_arm 0',
'foreach t $target_list { if {[regexp -nocase {arm|cortex} $t]} { set has_arm 1 } }',
'if {!$has_arm} {',
' puts "WARNING: ARM cores not found — DAP may be in error state"',
' # Show DAP status for logging',
' foreach t $target_list { puts " TARGET: $t" }',
' # Attempt recovery: select DAP (target 1) and issue system reset',
' puts "INFO: Attempting DAP recovery via system reset..."',
' catch {',
' targets 1',
' rst -system',
' after 1000',
' stop',
' }',
' # Disconnect and reconnect to refresh JTAG chain',
' catch { disconnect; after 1000; connect; after 500 }',
' # Check if ARM cores reappeared',
' set target_list2 [targets]',
' set has_arm2 0',
' foreach t $target_list2 {',
' puts " AFTER RECOVERY: $t"',
' if {[regexp -nocase {arm|cortex} $t]} { set has_arm2 1 }',
' }',
' if {!$has_arm2} {',
' puts "FATAL: DAP recovery failed — board requires power cycle"',
' puts "FATAL: Unplug/replug power, then retry Step 3"',
' exit 1',
' }',
' puts "INFO: ARM cores recovered after DAP reset"',
'}',
'',
])
# Core flow
tcl_lines.extend([
'targets -set -nocase -filter {name =~ "arm*#0"}',
'puts "INFO: Resetting processor (Bypassing BootROM Flash boot)..."',
"rst -processor",
"after 500",
'puts "INFO: Downloading FSBL..."',
'dow "%s"' % fsbl_path,
'puts "INFO: Running FSBL for hardware initialization..."',
"con",
"after 2000",
"stop",
'puts "INFO: Hardware initialization completed."',
'puts "INFO: Downloading Bitstream..."',
'fpga -f "%s"' % bit_path,
"after 1000",
'targets -set -nocase -filter {name =~ "arm*#0"}',
'puts "INFO: Downloading Application ELF..."',
'dow "%s"' % elf_path,
'puts "INFO: Executing Application..."',
"con",
'puts "SUCCESS: Zynq target is running the specified application."',
"exit",
])
tcl = "\n".join(tcl_lines)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".tcl", delete=False
) as tf:
tf.write(tcl)
tcl_file = tf.name
timeout = config.step_timeouts.get("bitstream", 60) + \
config.step_timeouts.get("elf_download", 60)
try:
if callback:
callback("progress", "Executing combined JTAG flow...")
result = subprocess.run(
[xsct, tcl_file],
capture_output=True, text=True, timeout=timeout,
)
os.unlink(tcl_file)
output = (result.stdout + result.stderr).strip()
success = result.returncode == 0 and "SUCCESS" in output
# Detect DAP errors for logging
dap_errors = [l for l in output.split("\n") if "DAP" in l and "error" in l.lower()]
if dap_errors:
if callback:
for e in dap_errors:
callback("error", f"DAP: {e.strip()[:120]}")
success = False
if callback:
callback(
"complete" if success else "error",
"Bootloader " + ("running" if success else "failed"),
)
return [
BitstreamResult(
step="bitstream",
success=success,
message=(
"DAP error — board needs power cycle"
if dap_errors else
"Bitstream loaded" if success else "Combined flow failed"
),
output=output[:4000],
),
BitstreamResult(
step="elf",
success=success,
message=(
"DAP error — board needs power cycle"
if dap_errors else
"ELF executed" if success else "Combined flow failed"
),
output=output[:4000],
),
]
except subprocess.TimeoutExpired:
os.unlink(tcl_file)
return [
BitstreamResult(step="bitstream", success=False,
message="Combined JTAG flow timed out"),
BitstreamResult(step="elf", success=False,
message="Combined JTAG flow timed out"),
]
+91 -22
View File
@@ -82,6 +82,7 @@ class MainWindow(ctk.CTk):
self._zynq_jtag_present: bool = False
self._zynq_jtag_info = None
self._uart_monitor: UartMonitor | None = None
self._uart_window: UartMonitorWindow | None = None
self._flash_phase: str = ""
# StringVar references for config sync
@@ -157,21 +158,46 @@ class MainWindow(ctk.CTk):
if self._config_path:
try:
self._config = Config.from_file(self._config_path)
# If loaded from default_config.yaml, set save path to config.yaml
if self._config_path.name == "default_config.yaml":
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path
except Exception:
self._config = Config.from_default()
# Set save path to config.yaml for new configs
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path
else:
self._config = Config.from_default()
# Set save path to config.yaml for new configs
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."""
user_path = self._get_user_config_path()
if not user_path.exists():
return
try:
fresh = Config.from_file(user_path)
self._config = fresh
# Update UI selectors
for attr, selector in [
("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector),
("bootloader_bin_path", self._bootloader_bin_selector),
("fsbl_elf_path", self._fsbl_selector),
("firmware_bin_path", self._firmware_bin_selector),
]:
val = getattr(fresh, attr, "")
if val:
selector.set_path(str(fresh.resolve_path(val)))
# Update IP
if self._ip_string_var:
self._ip_string_var.set(fresh.zynq_ip)
# Update xilinx path
if hasattr(fresh, 'xilinx_path') and self._xilinx_path_var:
self._xilinx_path_var.set(fresh.xilinx_path)
except Exception:
pass # Keep existing config on error
def _sync_config_from_ui(self) -> None:
"""Read current UI values and update the Config object.
@@ -432,7 +458,7 @@ class MainWindow(ctk.CTk):
row=0, column=0, sticky="nsew",
padx=PADDING, pady=PADDING,
)
panel.grid_rowconfigure(8, weight=1)
panel.grid_rowconfigure(9, weight=1)
title = ctk.CTkLabel(
panel,
@@ -465,6 +491,17 @@ class MainWindow(ctk.CTk):
self._ip_entry = ctk.CTkEntry(ip_frame, textvariable=self._ip_string_var)
self._ip_entry.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL)
# FSBL ELF path (First Stage Bootloader — initialises PS, forces JTAG boot)
self._fsbl_selector = FileSelector(
panel,
label_text="FSBL ELF (.elf):",
file_types=[("ELF files", "*.elf"), ("All files", "*")],
)
if self._config.fsbl_elf_path:
resolved = self._config.resolve_path(self._config.fsbl_elf_path)
self._fsbl_selector.set_path(str(resolved))
self._fsbl_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# BIT path (Bootloader)
self._bit_selector = FileSelector(
panel,
@@ -474,7 +511,7 @@ class MainWindow(ctk.CTk):
if self._config.bootloader_bit_path:
resolved = self._config.resolve_path(self._config.bootloader_bit_path)
self._bit_selector.set_path(str(resolved))
self._bit_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
self._bit_selector.grid(row=4, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# ELF path (Bootloader)
self._elf_selector = FileSelector(
@@ -485,7 +522,7 @@ class MainWindow(ctk.CTk):
if self._config.bootloader_elf_path:
resolved = self._config.resolve_path(self._config.bootloader_elf_path)
self._elf_selector.set_path(str(resolved))
self._elf_selector.grid(row=4, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
self._elf_selector.grid(row=5, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# Bootloader BIN path (Flash programming)
self._bootloader_bin_selector = FileSelector(
@@ -496,22 +533,11 @@ class MainWindow(ctk.CTk):
if self._config.bootloader_bin_path:
resolved = self._config.resolve_path(self._config.bootloader_bin_path)
self._bootloader_bin_selector.set_path(str(resolved))
self._bootloader_bin_selector.grid(row=5, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# FSBL ELF path (required by program_flash for QSPI programming)
self._fsbl_selector = FileSelector(
panel,
label_text="FSBL ELF (.elf):",
file_types=[("ELF files", "*.elf"), ("All files", "*")],
)
if self._config.fsbl_elf_path:
resolved = self._config.resolve_path(self._config.fsbl_elf_path)
self._fsbl_selector.set_path(str(resolved))
self._fsbl_selector.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
self._bootloader_bin_selector.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
# Flash options
flash_frame = ctk.CTkFrame(panel)
flash_frame.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
flash_frame.grid(row=7, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
flash_frame.grid_columnconfigure(0, weight=1)
self._erase_cb_var = ctk.BooleanVar(value=self._config.erase_all)
@@ -845,9 +871,22 @@ class MainWindow(ctk.CTk):
self._bitstream_callback,
)
dap_fail = False
for result in results:
status = "" if result.success else ""
self._log_message(f" {status} {result.step}: {result.message}")
if "DAP" in result.message and not result.success:
dap_fail = True
if dap_fail:
self._log_message("")
self._log_message(" ╔══════════════════════════════════════════╗")
self._log_message(" ║ DAP ERROR — JTAG debug port hung ║")
self._log_message(" ║ This is caused by prior flash programming║")
self._log_message(" ║ → Power-cycle the board (unplug/replug) ║")
self._log_message(" ║ → Then re-run Step 3 ║")
self._log_message(" ╚══════════════════════════════════════════╝")
self._log_message("")
return all(r.success for r in results)
except Exception as e:
@@ -988,6 +1027,9 @@ class MainWindow(ctk.CTk):
if index >= len(self._steps):
return
# Always re-read config.yaml before running
self._reload_config()
self._is_running = True
self._run_btn.configure(state="disabled", text="Running...")
self._progress.reset()
@@ -1021,6 +1063,9 @@ class MainWindow(ctk.CTk):
if self._is_running:
return
# Always re-read config.yaml before running
self._reload_config()
self._is_running = True
self._run_btn.configure(state="disabled", text="Running...")
self._progress.reset()
@@ -1106,6 +1151,17 @@ class MainWindow(ctk.CTk):
# Stop on first failure when running all steps
if not single_step and not success:
# Special case: Step 2 pass → Step 3 DAP fail = board needs power cycle
if i == 2 and results[0] and not success:
self._log_message("")
self._log_message(" ┌─────────────────────────────────────────┐")
self._log_message(" │ Step 3 failed after flash programming. │")
self._log_message(" │ This is normal — program_flash leaves │")
self._log_message(" │ the JTAG DAP in error state. │")
self._log_message(" │ → Power-cycle the board, then re-run │")
self._log_message(" │ Step 3 (Load Bootloader) alone. │")
self._log_message(" └─────────────────────────────────────────┘")
self._log_message("")
break
# Inter-step delay (skip after last step)
@@ -1148,6 +1204,12 @@ class MainWindow(ctk.CTk):
def _start_uart_monitor(self) -> None:
"""Start background serial monitoring if UART is available."""
# If UART window is already monitoring, don't start a second instance
if (self._uart_window
and self._uart_window.winfo_exists()
and self._uart_window.is_monitoring):
self._log_message(" [UART] Already monitoring via UART window")
return
if not self._uart_available or not self._config:
return
port = self._config.serial_port
@@ -1157,8 +1219,9 @@ class MainWindow(ctk.CTk):
self._uart_monitor = UartMonitor(port, self._config.serial_baudrate)
self._uart_monitor.on_line = lambda line: self._log_message(f" [UART] {line}")
self._uart_monitor.on_boot_info = lambda info: (
self._log_message(f" [UART] Boot detected: IP={info.ip_address}, Ver={info.version}")
if info.ip_address or info.version else None
self._log_message(
f" [UART] Boot={info.boot_mode}, IP={info.ip_address}, Ver={info.version}"
) if any([info.boot_mode, info.ip_address, info.version]) else None
)
self._uart_monitor.on_error = lambda e: self._log_message(f" [UART] Error: {e}")
if self._uart_monitor.start():
@@ -1191,6 +1254,9 @@ class MainWindow(ctk.CTk):
def _on_close(self) -> None:
"""Handle window close event: save config and cleanup."""
self._stop_uart_monitor()
if self._uart_window and self._uart_window.winfo_exists():
self._uart_window.destroy()
self._uart_window = None
if self._config:
self._sync_config_from_ui() # Sync UI values first
# Save to user config file (config.yaml)
@@ -1206,7 +1272,10 @@ class MainWindow(ctk.CTk):
"""Open the UART Monitor window."""
port = self._port_var.get()
baudrate = self._config.serial_baudrate if self._config else 115200
UartMonitorWindow(self, port=port, baudrate=baudrate)
self._uart_window = UartMonitorWindow(
self, port=port, baudrate=baudrate,
on_log=self._log_message,
)
def _test_serial_version(self) -> None:
"""Test serial port by sending 'ver()' command and parsing response."""
+15 -1
View File
@@ -786,6 +786,7 @@ class UartMonitorWindow(ctk.CTkToplevel):
master: ctk.CTk | ctk.CTkToplevel,
port: str,
baudrate: int = 115200,
on_log: Callable[[str], None] | None = None,
) -> None:
"""Initialize the UART monitor window.
@@ -793,6 +794,7 @@ class UartMonitorWindow(ctk.CTkToplevel):
master: Parent window (MainWindow).
port: Serial port device path (e.g., '/dev/ttyUSB0').
baudrate: Baud rate for serial communication.
on_log: Optional callback to forward parsed events to main log.
"""
super().__init__(master)
self.title("UART Monitor")
@@ -800,6 +802,8 @@ class UartMonitorWindow(ctk.CTkToplevel):
self.minsize(480, 360)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self._on_log = on_log
self._port = port
self._baudrate = baudrate
self._monitor: UartMonitor | None = None
@@ -991,12 +995,17 @@ class UartMonitorWindow(ctk.CTkToplevel):
def _on_boot_info(self, info) -> None:
parts = []
if info.boot_mode:
parts.append(f"Boot={info.boot_mode}")
if info.ip_address:
parts.append(f"IP={info.ip_address}")
if info.version:
parts.append(f"Ver={info.version}")
if parts:
self.after(0, lambda: self._log(f"[BOOT] {' '.join(parts)}\n"))
# Forward to main log only (not UART window)
if self._on_log:
msg = f"[UART] {' '.join(parts)}"
self.after(0, lambda: self._on_log(msg))
def _on_error(self, error: str) -> None:
self.after(0, lambda: self._log_error(error))
@@ -1024,6 +1033,11 @@ class UartMonitorWindow(ctk.CTkToplevel):
# ── Window Close ───────────────────────────────────────────
@property
def is_monitoring(self) -> bool:
"""True if the UART monitor thread is currently running."""
return self._monitor is not None and self._monitor.is_running()
def _on_close(self) -> None:
"""Cleanup UartMonitor and destroy window."""
if self._monitor:
+31 -6
View File
@@ -23,6 +23,7 @@ class BootInfo:
ip_address: str = ""
version: str = ""
boot_message: str = ""
boot_mode: str = ""
raw_lines: list[str] | None = None
@@ -73,10 +74,19 @@ def parse_boot_output(lines: list[str]) -> BootInfo:
ip_pattern = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b")
version_patterns = [
re.compile(r"[Vv]ersion[:\s]+([^\s;,\n]+)", re.IGNORECASE),
re.compile(r"[Bb]oot\s+([^\s;,\n]+)", re.IGNORECASE),
re.compile(r"[Ff]irmware\s+([^\s;,\n]+)", re.IGNORECASE),
re.compile(r"(?:^|[\s:=])(v\d+\.\d+\.\d+)(?:\s|$)", re.IGNORECASE),
re.compile(r"[Bb]oot\s+(?:version\s+)?([Vv]?\d+\.\d+[^\s;,\n]*)", re.IGNORECASE),
re.compile(r"[Ff]irm[Ww]are:?\s*([^\s;,\n]+)", re.IGNORECASE),
re.compile(r"(?:^|[\s:=])([Vv]\d+\.\d+\.\d+[^\s;,\n]*)", re.IGNORECASE),
]
def _is_valid_version(v: str) -> bool:
"""Filter out noise like '3.1', 'mode', 'update'."""
if len(v) < 4:
return False
has_digit = any(c.isdigit() for c in v)
has_dot = "." in v or "_" in v
noise = {"mode", "upate", "update", "done", "error", "loaded", "running"}
return has_digit and has_dot and v.lower() not in noise
boot_complete_patterns = [
re.compile(r"boot\s+complete", re.IGNORECASE),
re.compile(r"system\s+ready", re.IGNORECASE),
@@ -84,6 +94,10 @@ def parse_boot_output(lines: list[str]) -> BootInfo:
re.compile(r"Linux\s+booted", re.IGNORECASE),
re.compile(r"QSYS\s+ready", re.IGNORECASE),
]
boot_mode_patterns = [
re.compile(r"[Bb]oot\s*[Mm]ode\s*(?:is)?\s*[:\[=\s]*(\w+)"),
re.compile(r"BootMode\s*[:\[=\s]*(\w+)", re.IGNORECASE),
]
for line in lines:
# Extract IP address
@@ -96,7 +110,7 @@ def parse_boot_output(lines: list[str]) -> BootInfo:
if not info.version:
for pattern in version_patterns:
match = pattern.search(line)
if match:
if match and _is_valid_version(match.group(1)):
info.version = match.group(1)
break
@@ -107,6 +121,14 @@ def parse_boot_output(lines: list[str]) -> BootInfo:
info.boot_message = line.strip()
break
# Extract boot mode
if not info.boot_mode:
for pattern in boot_mode_patterns:
match = pattern.search(line)
if match:
info.boot_mode = match.group(1)
break
return info
@@ -332,6 +354,7 @@ class UartMonitor:
self._lock = threading.Lock()
self._all_lines: list[str] = []
self._latest_info = BootInfo(raw_lines=[])
self._last_emitted: tuple[str, str, str] = ("", "", "")
# Callbacks — set these before calling start()
self.on_line: Callable[[str], None] | None = None
@@ -428,8 +451,10 @@ class UartMonitor:
with self._lock:
self._latest_info = info
# Notify if we have new boot info
if info.boot_message or info.ip_address or info.version:
# Notify only when parsed info changes (dedup)
current = (info.boot_mode, info.ip_address, info.version)
if current != self._last_emitted and any(current):
self._last_emitted = current
if self.on_boot_info:
self.on_boot_info(info)
else:
+104 -154
View File
@@ -1,16 +1,14 @@
"""TFTP file transfer manager for Zynq Flasher GUI.
Handles uploading, downloading, and CRC verification of files
via TFTP to the Zynq device.
via TFTP to the Zynq device using a pure-Python TFTP client.
"""
from __future__ import annotations
import binascii
import os
import platform
import shutil
import subprocess
import socket
import struct
import tempfile
from dataclasses import dataclass
from pathlib import Path
@@ -35,18 +33,17 @@ class TftpResult:
ProgressCallback = Callable[[str, str], None] | None
# TFTP opcodes
_TFTP_RRQ = 1
_TFTP_WRQ = 2
_TFTP_DATA = 3
_TFTP_ACK = 4
_TFTP_ERROR = 5
_TFTP_BLOCK_SIZE = 512
def _compute_crc32(file_path: str | Path) -> int:
"""Compute CRC32 checksum of a file.
Args:
file_path: Path to the file.
Returns:
CRC32 value as unsigned integer.
"""
import binascii
"""Compute CRC32 checksum of a file."""
crc = 0
with open(file_path, "rb") as f:
while True:
@@ -57,15 +54,66 @@ def _compute_crc32(file_path: str | Path) -> int:
return crc & 0xFFFFFFFF
def _find_tftp_client() -> str | None:
"""Find the tftp client executable.
def _tftp_put(host: str, file_path: str, remote_name: str,
timeout: float = 10.0) -> tuple[bool, str]:
"""Upload a file to a TFTP server using pure Python.
Uses shutil.which() for cross-platform compatibility.
Args:
host: TFTP server IP address.
file_path: Local file path.
remote_name: Remote filename.
timeout: Socket timeout in seconds.
Returns:
Path to tftp executable, or None if not found.
(success, message).
"""
return shutil.which("tftp")
data = Path(file_path).read_bytes()
total = len(data)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
# ── Send WRQ ──
wrq = struct.pack("!H", _TFTP_WRQ)
wrq += remote_name.encode("ascii") + b"\x00"
wrq += b"octet\x00"
sock.sendto(wrq, (host, 69))
# ── Wait for ACK(0) to get server's data port ──
ack_data, server_addr = sock.recvfrom(516)
if len(ack_data) < 4:
return False, "Invalid ACK from server"
opcode = struct.unpack("!H", ack_data[:2])[0]
if opcode == _TFTP_ERROR:
err_msg = ack_data[4:].decode("ascii", errors="replace").rstrip("\x00")
return False, f"TFTP error: {err_msg}"
if opcode != _TFTP_ACK:
return False, f"Expected ACK, got opcode {opcode}"
# ── Send data blocks ──
block = 1
offset = 0
while offset < total:
chunk = data[offset:offset + _TFTP_BLOCK_SIZE]
offset += len(chunk)
data_pkt = struct.pack("!HH", _TFTP_DATA, block) + chunk
sock.sendto(data_pkt, server_addr)
# Wait for ACK
try:
ack_data, _ = sock.recvfrom(516)
ack_op = struct.unpack("!H", ack_data[:2])[0]
ack_block = struct.unpack("!H", ack_data[2:4])[0]
if ack_op != _TFTP_ACK or ack_block != block:
return False, f"Bad ACK: op={ack_op} block={ack_block}"
except socket.timeout:
return False, f"Timeout waiting for ACK block {block}"
block += 1
return True, f"Uploaded {total} bytes in {block - 1} blocks"
finally:
sock.close()
def tftp_upload(
@@ -74,7 +122,7 @@ def tftp_upload(
remote_name: str | None = None,
callback: ProgressCallback = None,
) -> TftpResult:
"""Upload a file to the Zynq device via TFTP.
"""Upload a file to the Zynq device via TFTP (pure Python).
Args:
config: Application configuration.
@@ -90,83 +138,42 @@ def tftp_upload(
if not file_path.exists():
return TftpResult(
step="upload",
success=False,
step="upload", success=False,
message=f"File not found: {file_path}",
local_path=str(file_path),
remote_path=remote_name,
local_path=str(file_path), remote_path=remote_name,
)
if callback:
callback("start", f"Uploading {file_path.name} -> {remote_name}")
callback("start",
f"Uploading {file_path.name} -> {remote_name}")
# Verify device is reachable first
# Verify device is reachable
if not ping_ip(config.zynq_ip, config.ping_count, config.ping_timeout):
return TftpResult(
step="upload",
success=False,
step="upload", success=False,
message=f"Device {config.zynq_ip} not reachable",
local_path=str(file_path),
remote_path=remote_name,
local_path=str(file_path), remote_path=remote_name,
)
tftp_cmd = _find_tftp_client()
if not tftp_cmd:
try:
ok, msg = _tftp_put(config.zynq_ip, str(file_path), remote_name)
except OSError as e:
return TftpResult(
step="upload",
success=False,
message="tftp client not found",
local_path=str(file_path),
remote_path=remote_name,
step="upload", success=False,
message=f"Socket error: {e}",
local_path=str(file_path), remote_path=remote_name,
)
if callback:
callback("progress", f"Running: {tftp_cmd}")
callback("complete" if ok else "error",
"Upload " + ("succeeded" if ok else "failed"))
# Build TFTP command
cmd = [tftp_cmd, "-m", "binary", config.zynq_ip, "put", str(file_path), remote_name]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
)
success = result.returncode == 0
output = (result.stdout + result.stderr).strip()
if callback:
callback("complete" if success else "error",
"Upload " + ("succeeded" if success else "failed"))
return TftpResult(
step="upload",
success=success,
message=output or ("Upload succeeded" if success else "Upload failed"),
local_path=str(file_path),
remote_path=remote_name,
crc_local=_compute_crc32(file_path),
)
except subprocess.TimeoutExpired:
if callback:
callback("error", "Upload timed out")
return TftpResult(
step="upload",
success=False,
message="Upload timed out",
local_path=str(file_path),
remote_path=remote_name,
crc_local=_compute_crc32(file_path),
)
except OSError as e:
return TftpResult(
step="upload",
success=False,
message=f"OS error: {e}",
local_path=str(file_path),
remote_path=remote_name,
)
return TftpResult(
step="upload", success=ok,
message=msg,
local_path=str(file_path), remote_path=remote_name,
crc_local=_compute_crc32(file_path) if ok else 0,
)
def tftp_download(
@@ -175,85 +182,28 @@ def tftp_download(
local_dir: str | Path | None = None,
callback: ProgressCallback = None,
) -> TftpResult:
"""Download a file from the Zynq device via TFTP.
"""Download a file from the Zynq device via TFTP (pure Python).
Note: This requires the Zynq to act as a TFTP client (U-Boot tftpboot),
which typically means we need a TFTP server on the PC. For simplicity,
this operation is not natively supported — use ping-based verification
or UART log analysis instead.
Args:
config: Application configuration.
remote_name: Remote filename on the TFTP server.
local_dir: Local directory to save the file (default: temp directory).
local_dir: Local directory to save the file.
callback: Optional progress callback.
Returns:
TftpResult with download status.
TftpResult with status (always not-implemented).
"""
local_dir = Path(local_dir or tempfile.gettempdir())
local_path = local_dir / remote_name
if callback:
callback("start", f"Downloading {remote_name} -> {local_path}")
# Verify device is reachable
if not ping_ip(config.zynq_ip, config.ping_count, config.ping_timeout):
return TftpResult(
step="download",
success=False,
message=f"Device {config.zynq_ip} not reachable",
local_path=str(local_path),
remote_path=remote_name,
)
tftp_cmd = _find_tftp_client()
if not tftp_cmd:
return TftpResult(
step="download",
success=False,
message="tftp client not found",
local_path=str(local_path),
remote_path=remote_name,
)
cmd = [tftp_cmd, "-m", "binary", config.zynq_ip, "get", remote_name, str(local_path)]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
)
success = result.returncode == 0
output = (result.stdout + result.stderr).strip()
if callback:
callback("complete" if success else "error",
"Download " + ("succeeded" if success else "failed"))
crc = _compute_crc32(local_path) if success and local_path.exists() else 0
return TftpResult(
step="download",
success=success,
message=output or ("Download succeeded" if success else "Download failed"),
local_path=str(local_path),
remote_path=remote_name,
crc_remote=crc,
)
except subprocess.TimeoutExpired:
return TftpResult(
step="download",
success=False,
message="Download timed out",
local_path=str(local_path),
remote_path=remote_name,
)
except OSError as e:
return TftpResult(
step="download",
success=False,
message=f"OS error: {e}",
local_path=str(local_path),
remote_path=remote_name,
)
return TftpResult(
step="download",
success=False,
message="TFTP download requires PC-side server (not implemented)",
remote_path=remote_name,
)
def tftp_upload_verify(