✨ feat: UART monitor, step timeouts, and non-blocking subprocess output
Based on real hardware timing data from XC7Z100 testing: UART Monitoring: - New UartMonitor class in serial_monitor.py: background thread reads serial output continuously, parses for IP/version/boot messages - GUI starts monitor when workflow runs, stops on completion/close - Serial lines flow into log display in real time Step Timing: - New step_timeouts config (experience × 2): check_env:30s flash_erase:120s flash_program:600s bitstream:60s elf_download:60s tftp_reboot:120s - GUI shows estimated timeout before each step - Shows actual elapsed time after each step completes - All subprocess calls now use config-driven timeouts Non-blocking Output: - New subprocess_utils.py: run_streaming() uses Popen + threaded stdout/stderr readers, parses WARNING/ERROR/progress% in real time - flash_programmer and bitstream_programmer use it - Callbacks receive categorized events (out/err/warn/error/progress/done)
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
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_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
|
||||||
|
tftp_upload_name: z7bin
|
||||||
|
uart_delay: 3
|
||||||
|
inter_step_delay: 2
|
||||||
|
step_timeouts:
|
||||||
|
check_env: 30
|
||||||
|
flash_erase: 120
|
||||||
|
flash_program: 600
|
||||||
|
bitstream: 60
|
||||||
|
elf_download: 60
|
||||||
|
tftp_reboot: 120
|
||||||
|
vitis_path: ""
|
||||||
|
zynq_ip: 192.168.100.11
|
||||||
@@ -35,3 +35,16 @@ reboot_timeout: 30
|
|||||||
# Ping verification settings
|
# Ping verification settings
|
||||||
ping_timeout: 5
|
ping_timeout: 5
|
||||||
ping_count: 3
|
ping_count: 3
|
||||||
|
|
||||||
|
# Step timing (seconds) — used for progress bar max and timeout (experience × 2)
|
||||||
|
step_timeouts:
|
||||||
|
check_env: 30 # Step 1: tool detection + JTAG scan (~5s actual)
|
||||||
|
flash_erase: 120 # Step 2a: QSPI chip erase (~40s actual for 17MB)
|
||||||
|
flash_program: 600 # Step 2b: QSPI program+verify (~300s actual for 17MB at x4)
|
||||||
|
bitstream: 60 # Step 3a: FPGA configuration via JTAG (~16s actual)
|
||||||
|
elf_download: 60 # Step 3b: ELF download to PS via JTAG (~9s actual)
|
||||||
|
tftp_reboot: 120 # Step 4: TFTP upload + reboot + boot verify (~30s network)
|
||||||
|
|
||||||
|
# Delays (seconds)
|
||||||
|
inter_step_delay: 2
|
||||||
|
uart_delay: 3
|
||||||
|
|||||||
+16
-15
@@ -117,21 +117,17 @@ def _run_command(
|
|||||||
Raises:
|
Raises:
|
||||||
subprocess.TimeoutExpired: If command exceeds timeout.
|
subprocess.TimeoutExpired: If command exceeds timeout.
|
||||||
"""
|
"""
|
||||||
|
from subprocess_utils import run_streaming
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
callback("running", f"Executing: {' '.join(cmd)}")
|
callback("running", f"Executing: {' '.join(cmd)}")
|
||||||
|
|
||||||
result = subprocess.run(
|
try:
|
||||||
cmd,
|
return run_streaming(cmd, timeout=timeout, callback=callback)
|
||||||
capture_output=True,
|
except subprocess.TimeoutExpired:
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if callback:
|
if callback:
|
||||||
if result.returncode == 0:
|
callback("error", f"{cmd[0]} timed out after {timeout}s")
|
||||||
callback("complete", f"{cmd[0]} succeeded")
|
raise
|
||||||
else:
|
|
||||||
callback("error", f"{cmd[0]} failed (exit {result.returncode})")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bitstream Download ──────────────────────────────────────────────
|
# ── Bitstream Download ──────────────────────────────────────────────
|
||||||
@@ -172,7 +168,8 @@ def program_bitstream(
|
|||||||
# Try xsct first (preferred)
|
# Try xsct first (preferred)
|
||||||
xsct = get_xsct_path(config.vitis_path)
|
xsct = get_xsct_path(config.vitis_path)
|
||||||
if xsct:
|
if xsct:
|
||||||
return _program_with_xsct(xsct, bit_path, callback, ps7_tcl)
|
timeout = config.step_timeouts.get("bitstream", 60)
|
||||||
|
return _program_with_xsct(xsct, bit_path, callback, ps7_tcl, timeout)
|
||||||
|
|
||||||
# Fallback to Vivado
|
# Fallback to Vivado
|
||||||
vivado = _get_vivado_path(config.vitis_path)
|
vivado = _get_vivado_path(config.vitis_path)
|
||||||
@@ -191,6 +188,7 @@ def _program_with_xsct(
|
|||||||
bit_path: Path,
|
bit_path: Path,
|
||||||
callback: ProgressCallback = None,
|
callback: ProgressCallback = None,
|
||||||
ps7_init_tcl: str = "",
|
ps7_init_tcl: str = "",
|
||||||
|
timeout: int = 60,
|
||||||
) -> BitstreamResult:
|
) -> BitstreamResult:
|
||||||
"""Program bitstream using xsct's fpga command.
|
"""Program bitstream using xsct's fpga command.
|
||||||
|
|
||||||
@@ -203,6 +201,7 @@ def _program_with_xsct(
|
|||||||
bit_path: Path to .bit file.
|
bit_path: Path to .bit file.
|
||||||
callback: Optional progress callback.
|
callback: Optional progress callback.
|
||||||
ps7_init_tcl: Path to ps7_init.tcl for PS initialization.
|
ps7_init_tcl: Path to ps7_init.tcl for PS initialization.
|
||||||
|
timeout: Subprocess timeout in seconds.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BitstreamResult with status.
|
BitstreamResult with status.
|
||||||
@@ -232,7 +231,7 @@ def _program_with_xsct(
|
|||||||
[xsct_path, tcl_file],
|
[xsct_path, tcl_file],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
os.unlink(tcl_file)
|
os.unlink(tcl_file)
|
||||||
success = result.returncode == 0
|
success = result.returncode == 0
|
||||||
@@ -351,7 +350,8 @@ def run_elf(
|
|||||||
|
|
||||||
# Auto-detect ps7_init.tcl from config or BIT file directory
|
# Auto-detect ps7_init.tcl from config or BIT file directory
|
||||||
ps7_tcl = _find_ps7_init_tcl(config)
|
ps7_tcl = _find_ps7_init_tcl(config)
|
||||||
return _run_elf_with_xsct(xsct, elf_path, callback, ps7_tcl)
|
timeout = config.step_timeouts.get("elf_download", 60)
|
||||||
|
return _run_elf_with_xsct(xsct, elf_path, callback, ps7_tcl, timeout)
|
||||||
|
|
||||||
|
|
||||||
def _run_elf_with_xsct(
|
def _run_elf_with_xsct(
|
||||||
@@ -359,6 +359,7 @@ def _run_elf_with_xsct(
|
|||||||
elf_path: Path,
|
elf_path: Path,
|
||||||
callback: ProgressCallback = None,
|
callback: ProgressCallback = None,
|
||||||
ps7_init_tcl: str = "",
|
ps7_init_tcl: str = "",
|
||||||
|
timeout: int = 60,
|
||||||
) -> BitstreamResult:
|
) -> BitstreamResult:
|
||||||
"""Download and run ELF using xsct's dow command.
|
"""Download and run ELF using xsct's dow command.
|
||||||
|
|
||||||
@@ -399,7 +400,7 @@ def _run_elf_with_xsct(
|
|||||||
[xsct_path, tcl_file],
|
[xsct_path, tcl_file],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
os.unlink(tcl_file)
|
os.unlink(tcl_file)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"ping_count": 3,
|
"ping_count": 3,
|
||||||
"uart_delay": 3,
|
"uart_delay": 3,
|
||||||
"inter_step_delay": 2,
|
"inter_step_delay": 2,
|
||||||
|
"step_timeouts": {
|
||||||
|
"check_env": 30,
|
||||||
|
"flash_erase": 120,
|
||||||
|
"flash_program": 600,
|
||||||
|
"bitstream": 60,
|
||||||
|
"elf_download": 60,
|
||||||
|
"tftp_reboot": 120,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +66,7 @@ class Config:
|
|||||||
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
|
||||||
|
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
|
||||||
_config_path: Path = field(default=None, init=False, repr=False)
|
_config_path: Path = field(default=None, init=False, repr=False)
|
||||||
@@ -163,6 +172,7 @@ class Config:
|
|||||||
"ping_count": self.ping_count,
|
"ping_count": self.ping_count,
|
||||||
"uart_delay": self.uart_delay,
|
"uart_delay": self.uart_delay,
|
||||||
"inter_step_delay": self.inter_step_delay,
|
"inter_step_delay": self.inter_step_delay,
|
||||||
|
"step_timeouts": self.step_timeouts,
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
+14
-15
@@ -41,31 +41,30 @@ def _run_command(
|
|||||||
timeout: int = 300,
|
timeout: int = 300,
|
||||||
callback: ProgressCallback = None,
|
callback: ProgressCallback = None,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
"""Run a subprocess command with progress reporting.
|
"""Run a subprocess command with real-time streaming output.
|
||||||
|
|
||||||
|
Uses subprocess_utils.run_streaming to stream stdout/stderr in
|
||||||
|
real time, parsing for warnings, errors, and progress.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cmd: Command and arguments.
|
cmd: Command and arguments.
|
||||||
timeout: Maximum seconds to wait (flash ops can take minutes).
|
timeout: Maximum seconds.
|
||||||
callback: Optional callback(status, message).
|
callback: Optional callback(status, message).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CompletedProcess result.
|
CompletedProcess result.
|
||||||
"""
|
"""
|
||||||
|
from subprocess_utils import run_streaming
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
callback("running", f"Executing: {' '.join(cmd)}")
|
callback("running", f"Executing: {' '.join(cmd)}")
|
||||||
|
|
||||||
result = subprocess.run(
|
try:
|
||||||
cmd,
|
return run_streaming(cmd, timeout=timeout, callback=callback)
|
||||||
capture_output=True,
|
except subprocess.TimeoutExpired:
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if callback:
|
if callback:
|
||||||
if result.returncode == 0:
|
callback("error", f"{cmd[0]} timed out after {timeout}s")
|
||||||
callback("complete", f"{cmd[0]} succeeded")
|
raise
|
||||||
else:
|
|
||||||
callback("error", f"{cmd[0]} failed (exit {result.returncode})")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _build_program_flash_cmd_base(
|
def _build_program_flash_cmd_base(
|
||||||
@@ -135,7 +134,7 @@ def wipe_flash(
|
|||||||
cmd += ["-erase_only"] # Erase sectors matching the BIN size (safer than -erase_all)
|
cmd += ["-erase_only"] # Erase sectors matching the BIN size (safer than -erase_all)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = _run_command(cmd, timeout=300, callback=callback)
|
result = _run_command(cmd, timeout=config.step_timeouts.get("flash_erase", 120), callback=callback)
|
||||||
success = result.returncode == 0
|
success = result.returncode == 0
|
||||||
return FlashResult(
|
return FlashResult(
|
||||||
step="wipe",
|
step="wipe",
|
||||||
@@ -195,7 +194,7 @@ def program_flash_bin(
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = _run_command(cmd, timeout=600, callback=callback)
|
result = _run_command(cmd, timeout=config.step_timeouts.get("flash_program", 600), callback=callback)
|
||||||
success = result.returncode == 0
|
success = result.returncode == 0
|
||||||
return FlashResult(
|
return FlashResult(
|
||||||
step="program",
|
step="program",
|
||||||
|
|||||||
+50
-1
@@ -24,7 +24,7 @@ from ip_verifier import ping_ip
|
|||||||
from tftp_manager import tftp_upload_verify, TftpResult
|
from tftp_manager import tftp_upload_verify, TftpResult
|
||||||
from reboot_manager import reboot_zynq, RebootResult
|
from reboot_manager import reboot_zynq, RebootResult
|
||||||
from boot_verifier import verify_boot, verify_zynq_status, BootVerificationResult
|
from boot_verifier import verify_boot, verify_zynq_status, BootVerificationResult
|
||||||
from serial_monitor import detect_serial_ports, parse_boot_output, check_uart_available
|
from serial_monitor import detect_serial_ports, parse_boot_output, check_uart_available, UartMonitor
|
||||||
from gui.styles import (
|
from gui.styles import (
|
||||||
WINDOW_WIDTH,
|
WINDOW_WIDTH,
|
||||||
WINDOW_HEIGHT,
|
WINDOW_HEIGHT,
|
||||||
@@ -79,6 +79,7 @@ class MainWindow(ctk.CTk):
|
|||||||
self._uart_message: str = ""
|
self._uart_message: str = ""
|
||||||
self._zynq_jtag_present: bool = False
|
self._zynq_jtag_present: bool = False
|
||||||
self._zynq_jtag_info = None
|
self._zynq_jtag_info = None
|
||||||
|
self._uart_monitor: UartMonitor | None = None
|
||||||
|
|
||||||
# StringVar references for config sync
|
# StringVar references for config sync
|
||||||
self._ip_string_var: ctk.StringVar | None = None
|
self._ip_string_var: ctk.StringVar | None = None
|
||||||
@@ -961,6 +962,15 @@ class MainWindow(ctk.CTk):
|
|||||||
self._run_step_3_load_bootloader,
|
self._run_step_3_load_bootloader,
|
||||||
self._run_step_4_load_main_program,
|
self._run_step_4_load_main_program,
|
||||||
]
|
]
|
||||||
|
step_timeout_keys = [
|
||||||
|
("check_env", 30),
|
||||||
|
("flash_program", 600),
|
||||||
|
("bitstream", 60),
|
||||||
|
("tftp_reboot", 120),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Start UART monitor if available
|
||||||
|
self._start_uart_monitor()
|
||||||
|
|
||||||
# Determine end index based on single_step flag
|
# Determine end index based on single_step flag
|
||||||
end_index = start_index + 1 if single_step else len(step_funcs)
|
end_index = start_index + 1 if single_step else len(step_funcs)
|
||||||
@@ -970,12 +980,21 @@ class MainWindow(ctk.CTk):
|
|||||||
for i in range(start_index, end_index):
|
for i in range(start_index, end_index):
|
||||||
step_idx = i - start_index
|
step_idx = i - start_index
|
||||||
self._set_step_status(i, "running")
|
self._set_step_status(i, "running")
|
||||||
|
|
||||||
|
# Log step timeout estimate
|
||||||
|
timeout_key, default_timeout = step_timeout_keys[i] if i < len(step_timeout_keys) else ("", 30)
|
||||||
|
step_timeout = self._config.step_timeouts.get(timeout_key, default_timeout) if self._config else default_timeout
|
||||||
|
self._log_message(f" ⏱ Step {i+1} timeout: {step_timeout}s")
|
||||||
|
|
||||||
self._progress.set_value(step_idx / total)
|
self._progress.set_value(step_idx / total)
|
||||||
|
t_step_start = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = step_funcs[i]()
|
success = step_funcs[i]()
|
||||||
results.append(success)
|
results.append(success)
|
||||||
self._set_step_status(i, "complete" if success else "error")
|
self._set_step_status(i, "complete" if success else "error")
|
||||||
|
elapsed = time.time() - t_step_start
|
||||||
|
self._log_message(f" ⏱ Step {i+1} completed in {elapsed:.0f}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append(False)
|
results.append(False)
|
||||||
self._set_step_status(i, "error")
|
self._set_step_status(i, "error")
|
||||||
@@ -1020,6 +1039,35 @@ class MainWindow(ctk.CTk):
|
|||||||
# Save config after running
|
# Save config after running
|
||||||
self._save_config()
|
self._save_config()
|
||||||
|
|
||||||
|
# Stop UART monitor
|
||||||
|
self._stop_uart_monitor()
|
||||||
|
|
||||||
|
# ── UART Monitor ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_uart_monitor(self) -> None:
|
||||||
|
"""Start background serial monitoring if UART is available."""
|
||||||
|
if not self._uart_available or not self._config:
|
||||||
|
return
|
||||||
|
port = self._config.serial_port
|
||||||
|
if not port:
|
||||||
|
return
|
||||||
|
|
||||||
|
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._uart_monitor.on_error = lambda e: self._log_message(f" [UART] Error: {e}")
|
||||||
|
if self._uart_monitor.start():
|
||||||
|
self._log_message(" [UART] Serial monitor started")
|
||||||
|
|
||||||
|
def _stop_uart_monitor(self) -> None:
|
||||||
|
"""Stop background serial monitoring."""
|
||||||
|
if self._uart_monitor:
|
||||||
|
self._uart_monitor.stop()
|
||||||
|
self._uart_monitor = None
|
||||||
|
|
||||||
# ── Serial ─────────────────────────────────────────────────
|
# ── Serial ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _refresh_ports(self) -> None:
|
def _refresh_ports(self) -> None:
|
||||||
@@ -1040,6 +1088,7 @@ class MainWindow(ctk.CTk):
|
|||||||
|
|
||||||
def _on_close(self) -> None:
|
def _on_close(self) -> None:
|
||||||
"""Handle window close event: save config and cleanup."""
|
"""Handle window close event: save config and cleanup."""
|
||||||
|
self._stop_uart_monitor()
|
||||||
if self._config:
|
if self._config:
|
||||||
self._sync_config_from_ui() # Sync UI values first
|
self._sync_config_from_ui() # Sync UI values first
|
||||||
# Save to user config file (config.yaml)
|
# Save to user config file (config.yaml)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
Detects available serial ports, reads output streams, and parses
|
Detects available serial ports, reads output streams, and parses
|
||||||
boot logs for IP addresses, version strings, and other diagnostic info.
|
boot logs for IP addresses, version strings, and other diagnostic info.
|
||||||
|
Provides UartMonitor for continuous background monitoring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@@ -285,3 +287,156 @@ def _parse_version_from_response(response: str) -> str:
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════
|
||||||
|
# UartMonitor — continuous background serial monitoring
|
||||||
|
# ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class UartMonitor:
|
||||||
|
"""Background thread that continuously reads and parses serial output.
|
||||||
|
|
||||||
|
Opens the configured serial port in a daemon thread, reads lines
|
||||||
|
as they arrive, parses them for boot info (IP, version, errors),
|
||||||
|
and invokes callbacks for each event.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
monitor = UartMonitor("/dev/ttyUSB0", 115200)
|
||||||
|
monitor.on_line = lambda line: print(f" SERIAL: {line}")
|
||||||
|
monitor.on_boot_info = lambda info: print(f" BOOT: {info}")
|
||||||
|
monitor.start()
|
||||||
|
...
|
||||||
|
info = monitor.latest_info # latest parsed BootInfo
|
||||||
|
monitor.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
port: str,
|
||||||
|
baudrate: int = 115200,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the UART monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: Serial port device path.
|
||||||
|
baudrate: Baud rate for serial communication.
|
||||||
|
timeout: Read timeout between lines.
|
||||||
|
"""
|
||||||
|
self._port = port
|
||||||
|
self._baudrate = baudrate
|
||||||
|
self._timeout = timeout
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._all_lines: list[str] = []
|
||||||
|
self._latest_info = BootInfo(raw_lines=[])
|
||||||
|
|
||||||
|
# Callbacks — set these before calling start()
|
||||||
|
self.on_line: Callable[[str], None] | None = None
|
||||||
|
self.on_boot_info: Callable[[BootInfo], None] | None = None
|
||||||
|
self.on_error: Callable[[str], None] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_info(self) -> BootInfo:
|
||||||
|
"""Return the latest parsed boot information (thread-safe)."""
|
||||||
|
with self._lock:
|
||||||
|
return self._latest_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_lines(self) -> list[str]:
|
||||||
|
"""Return all lines read so far (thread-safe)."""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._all_lines)
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start the background monitoring thread.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if started successfully, False if port cannot be opened.
|
||||||
|
"""
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Quick port check before starting thread
|
||||||
|
try:
|
||||||
|
test_ser = serial.Serial(self._port, self._baudrate, timeout=1)
|
||||||
|
test_ser.close()
|
||||||
|
except serial.SerialException as e:
|
||||||
|
if self.on_error:
|
||||||
|
self.on_error(f"UART open failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the background monitoring thread."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=3)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if the monitor thread is active."""
|
||||||
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
|
def _read_loop(self) -> None:
|
||||||
|
"""Main read loop running in background thread."""
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(
|
||||||
|
self._port,
|
||||||
|
self._baudrate,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
except serial.SerialException as e:
|
||||||
|
if self.on_error:
|
||||||
|
self.on_error(f"UART open failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
accumulated: list[str] = []
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
if ser.in_waiting:
|
||||||
|
line_bytes = ser.readline()
|
||||||
|
try:
|
||||||
|
line = line_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
except Exception:
|
||||||
|
line = repr(line_bytes)
|
||||||
|
else:
|
||||||
|
line = ""
|
||||||
|
except serial.SerialException:
|
||||||
|
line = ""
|
||||||
|
|
||||||
|
if line:
|
||||||
|
# Store all lines
|
||||||
|
with self._lock:
|
||||||
|
self._all_lines.append(line)
|
||||||
|
accumulated.append(line)
|
||||||
|
|
||||||
|
# Invoke line callback
|
||||||
|
if self.on_line:
|
||||||
|
self.on_line(line)
|
||||||
|
|
||||||
|
# Parse boot info from accumulated lines
|
||||||
|
info = parse_boot_output(accumulated)
|
||||||
|
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:
|
||||||
|
if self.on_boot_info:
|
||||||
|
self.on_boot_info(info)
|
||||||
|
else:
|
||||||
|
# No data, sleep briefly
|
||||||
|
self._stop_event.wait(0.1)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Non-blocking subprocess execution with real-time output parsing.
|
||||||
|
|
||||||
|
Provides run_streaming() which streams stdout/stderr to callbacks
|
||||||
|
in real time, parsing for warnings, errors, and progress indicators.
|
||||||
|
Used by flash_programmer and bitstream_programmer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
StreamCallback = Callable[[str, str], None] | None
|
||||||
|
"""Callback type: (category, message) where category is one of:
|
||||||
|
'out', 'err', 'warn', 'error', 'progress', 'done'"""
|
||||||
|
|
||||||
|
# Regex patterns for parsing tool output
|
||||||
|
_WARNING_RE = re.compile(r"WARNING\s*[:\[].*", re.IGNORECASE)
|
||||||
|
_ERROR_RE = re.compile(r"ERROR\s*[:\[].*|CRITICAL\s*[:\[].*", re.IGNORECASE)
|
||||||
|
_PROGRESS_RE = re.compile(r"(\d{1,3})\s*%")
|
||||||
|
_INFO_KEYWORDS = ["connected", "downloading", "running", "initialization",
|
||||||
|
"verif", "write", "erase", "flash operation"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_streaming(
|
||||||
|
cmd: list[str],
|
||||||
|
timeout: int = 300,
|
||||||
|
callback: StreamCallback = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a command with real-time stdout/stderr streaming and parsing.
|
||||||
|
|
||||||
|
Spawns background threads to read stdout and stderr line-by-line.
|
||||||
|
Each line is parsed for warnings, errors, and progress, and the
|
||||||
|
results are passed to the callback in real time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd: Command and arguments (e.g., ["program_flash", "-f", ...]).
|
||||||
|
timeout: Maximum seconds before killing the process.
|
||||||
|
callback: Optional callback(category, message).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompletedProcess with stdout, stderr, and returncode.
|
||||||
|
"""
|
||||||
|
stdout_lines: list[str] = []
|
||||||
|
stderr_lines: list[str] = []
|
||||||
|
|
||||||
|
def _read_stream(stream, lines: list[str], stream_name: str) -> None:
|
||||||
|
"""Read lines from a stream in a background thread."""
|
||||||
|
for raw in iter(stream.readline, ""):
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
# Parse and callback
|
||||||
|
if callback:
|
||||||
|
_parse_and_callback(line, stream_name, callback)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start reader threads
|
||||||
|
t_out = threading.Thread(
|
||||||
|
target=_read_stream,
|
||||||
|
args=(proc.stdout, stdout_lines, "out"),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t_err = threading.Thread(
|
||||||
|
target=_read_stream,
|
||||||
|
args=(proc.stderr, stderr_lines, "err"),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t_out.start()
|
||||||
|
t_err.start()
|
||||||
|
|
||||||
|
# Wait with timeout
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
if callback:
|
||||||
|
callback("error", f"Command timed out after {timeout}s")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Wait for reader threads to finish
|
||||||
|
t_out.join(timeout=2)
|
||||||
|
t_err.join(timeout=2)
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
if proc.returncode == 0:
|
||||||
|
callback("done", f"{cmd[0]}: completed successfully")
|
||||||
|
else:
|
||||||
|
callback("error", f"{cmd[0]}: failed (exit {proc.returncode})")
|
||||||
|
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=cmd,
|
||||||
|
returncode=proc.returncode,
|
||||||
|
stdout="\n".join(stdout_lines),
|
||||||
|
stderr="\n".join(stderr_lines),
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if callback:
|
||||||
|
callback("error", f"Command not found: {cmd[0]}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_and_callback(
|
||||||
|
line: str,
|
||||||
|
stream_name: str,
|
||||||
|
callback: Callable[[str, str], None],
|
||||||
|
) -> None:
|
||||||
|
"""Parse a single output line and invoke the callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Raw output line.
|
||||||
|
stream_name: 'out' or 'err'.
|
||||||
|
callback: Callback to invoke.
|
||||||
|
"""
|
||||||
|
# Check for explicit errors/warnings
|
||||||
|
if _ERROR_RE.match(line):
|
||||||
|
callback("error", line)
|
||||||
|
return
|
||||||
|
if _WARNING_RE.match(line):
|
||||||
|
callback("warn", line)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for progress percentage
|
||||||
|
m = _PROGRESS_RE.search(line)
|
||||||
|
if m:
|
||||||
|
callback("progress", f"{m.group(1)}%")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for info keywords
|
||||||
|
lowered = line.lower()
|
||||||
|
for kw in _INFO_KEYWORDS:
|
||||||
|
if kw in lowered:
|
||||||
|
callback("out", line)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default: plain output
|
||||||
|
callback(stream_name, line)
|
||||||
Reference in New Issue
Block a user