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:
Jeremy Shen
2026-06-10 13:21:32 +08:00
parent ab7264d5c7
commit 2c48224aea
8 changed files with 434 additions and 33 deletions
+150
View File
@@ -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)