feat: UART boot wait, serial validation, xsct logging, layout fix

Config:
- Add boot_wait_delay (default 10s) for post-reboot wait time

Layout:
- Restore row weights for proportional scaling when maximized
- CTkScrollableFrame handles overflow when window is small

Step 3:
- Log xsct/vivado tool output (last 20 lines per result)
  for detailed diagnostics

Step 4:
- UART boot wait: monitor for IP/version via UART after reboot
- If UART unavailable, fall back to config.boot_wait_delay
- Reduces unnecessary wait time when boot is detected early

Serial:
- Real-time port validation on selection change (CTkComboBox command)
- Persistent UART unavailable warning until valid port selected
- Auto-restart UART monitor on port switch
- Graceful cross-platform handling
This commit is contained in:
2026-06-11 14:44:58 +08:00
parent 9b07a4d5ca
commit f6cf5de96f
6 changed files with 406 additions and 108 deletions
+1
View File
@@ -21,5 +21,6 @@ step_timeouts:
tftp_reboot: 120 tftp_reboot: 120
tftp_upload_name: z7bin tftp_upload_name: z7bin
uart_delay: 3 uart_delay: 3
boot_wait_delay: 10
xilinx_path: /data/xilinx xilinx_path: /data/xilinx
zynq_ip: 192.168.100.11 zynq_ip: 192.168.100.11
+3
View File
@@ -33,6 +33,7 @@ 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,
"boot_wait_delay": 10,
"step_timeouts": { "step_timeouts": {
"check_env": 30, "check_env": 30,
"flash_erase": 120, "flash_erase": 120,
@@ -70,6 +71,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
boot_wait_delay: int = 10
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
@@ -178,6 +180,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,
"boot_wait_delay": self.boot_wait_delay,
"step_timeouts": self.step_timeouts, "step_timeouts": self.step_timeouts,
} }
return data return data
+74
View File
@@ -268,6 +268,7 @@ class MainWindow(ctk.CTk):
# Scrollable main container — scrollbar appears when content overflows # Scrollable main container — scrollbar appears when content overflows
main_frame = ctk.CTkScrollableFrame(self) main_frame = ctk.CTkScrollableFrame(self)
main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE) main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE)
main_frame.grid_rowconfigure(1, weight=1)
main_frame.grid_columnconfigure(0, weight=1) main_frame.grid_columnconfigure(0, weight=1)
# ── Header ── # ── Header ──
@@ -285,6 +286,7 @@ class MainWindow(ctk.CTk):
# ── Right Panel: Configuration ── # ── Right Panel: Configuration ──
right_frame = ctk.CTkFrame(main_frame) right_frame = ctk.CTkFrame(main_frame)
right_frame.grid(row=1, column=1, sticky="nsew", padx=(PADDING, 0)) right_frame.grid(row=1, column=1, sticky="nsew", padx=(PADDING, 0))
right_frame.grid_rowconfigure(3, weight=1)
right_frame.grid_columnconfigure(0, weight=1) right_frame.grid_columnconfigure(0, weight=1)
self._build_config_panel(right_frame) self._build_config_panel(right_frame)
@@ -622,6 +624,7 @@ class MainWindow(ctk.CTk):
port_frame, port_frame,
values=[""] + [p.device for p in detect_serial_ports()], values=[""] + [p.device for p in detect_serial_ports()],
variable=self._port_var, variable=self._port_var,
command=self._on_port_changed,
) )
self._port_menu.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL) self._port_menu.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL)
@@ -852,6 +855,49 @@ class MainWindow(ctk.CTk):
self._uart_warning_label.configure(text=msg) self._uart_warning_label.configure(text=msg)
self._uart_warning_label.pack(fill="x", padx=PADDING, pady=(0, PADDING_SMALL)) self._uart_warning_label.pack(fill="x", padx=PADDING, pady=(0, PADDING_SMALL))
def _hide_uart_warning(self) -> None:
"""Hide the persistent UART warning label."""
self._uart_warning_label.pack_forget()
def _on_port_changed(self, new_port: str) -> None:
"""Handle serial port selection change — validate immediately.
Args:
new_port: The newly selected serial port path.
"""
if not new_port:
self._uart_available = False
self._show_uart_notification("No port selected")
if self._uart_monitor:
self._stop_uart_monitor()
return
# Attempt to open port to validate
try:
port = new_port
baudrate = self._config.serial_baudrate if self._config else 115200
available, reason = check_uart_available(port, baudrate)
self._uart_available = available
if available:
self._uart_message = reason
self._hide_uart_warning()
self._log_message(f" ✓ UART {port}: {reason}")
# If UART monitor was running, restart on new port
if self._uart_monitor and self._uart_monitor.is_running():
self._stop_uart_monitor()
self._start_uart_monitor()
else:
self._uart_available = False
self._uart_message = reason
self._show_uart_notification(reason)
if self._uart_monitor:
self._stop_uart_monitor()
except Exception as e:
self._uart_available = False
self._show_uart_notification(str(e))
if self._uart_monitor:
self._stop_uart_monitor()
def _run_step_2_program_flash(self) -> bool: def _run_step_2_program_flash(self) -> bool:
"""Step 2: Program and verify Flash using program_flash.""" """Step 2: Program and verify Flash using program_flash."""
if not self._config: if not self._config:
@@ -937,6 +983,11 @@ class MainWindow(ctk.CTk):
for result in results: for result in results:
status = "" if result.success else "" status = "" if result.success else ""
self._log_message(f" {status} {result.step}: {result.message}") self._log_message(f" {status} {result.step}: {result.message}")
if result.output:
# Log tool output for diagnostics (truncated)
output_lines = result.output.strip().splitlines()
for line in output_lines[-20:]: # last 20 lines
self._log_message(f" [xsct] {line}")
if "DAP" in result.message and not result.success: if "DAP" in result.message and not result.success:
dap_fail = True dap_fail = True
@@ -1077,6 +1128,29 @@ class MainWindow(ctk.CTk):
self._log_message(" Skipping boot verify (reboot failed)") self._log_message(" Skipping boot verify (reboot failed)")
return False return False
# Wait for board to boot — UART if available, else config delay
boot_delay = self._config.boot_wait_delay
uart_alive = (self._uart_monitor is not None
and self._uart_monitor.is_running())
if uart_alive:
self._log_message(f" ⏳ Waiting for UART boot signal (max {boot_delay}s)...")
elapsed = 0.0
while elapsed < boot_delay:
info = self._uart_monitor.latest_info
if info.ip_address or info.version:
self._log_message(
f" ✓ Boot detected after {elapsed:.1f}s"
f" (IP={info.ip_address}, Ver={info.version})"
)
break
time.sleep(0.5)
elapsed += 0.5
else:
self._log_message(f" ⏱ UART timeout — no boot signal in {boot_delay}s")
else:
self._log_message(f" ⏳ UART unavailable — sleeping {boot_delay}s for boot...")
time.sleep(boot_delay)
# 4e: Verify boot # 4e: Verify boot
self._log_message("Step 4e: Verifying boot...") self._log_message("Step 4e: Verifying boot...")
if hasattr(self, '_tftp_sub_step_frame'): if hasattr(self, '_tftp_sub_step_frame'):
+14 -3
View File
@@ -619,6 +619,7 @@ class LogDisplay(ctk.CTkFrame):
""" """
self._text_widget.configure(state="normal") self._text_widget.configure(state="normal")
self._text_widget.insert("end", message + "\n") self._text_widget.insert("end", message + "\n")
self._text_widget.update_idletasks()
self._text_widget.see("end") self._text_widget.see("end")
self._text_widget.configure(state="disabled") self._text_widget.configure(state="disabled")
@@ -696,7 +697,7 @@ class SubStepFrame(ctk.CTkFrame):
item["pct"].grid() item["pct"].grid()
def set_expanded(self, expanded: bool) -> None: def set_expanded(self, expanded: bool) -> None:
if expanded != self._collapsed: if expanded == self._collapsed:
self._toggle() self._toggle()
def set_phase(self, phase: str, pct: int = -1) -> None: def set_phase(self, phase: str, pct: int = -1) -> None:
@@ -810,9 +811,11 @@ class TftpSubStepFrame(ctk.CTkFrame):
for i, name in enumerate(self._substep_names): for i, name in enumerate(self._substep_names):
dot = ctk.CTkLabel(self, text="", font=FONT_BODY, text_color=ACCENT_PENDING, anchor="w") dot = ctk.CTkLabel(self, text="", font=FONT_BODY, text_color=ACCENT_PENDING, anchor="w")
label = ctk.CTkLabel(self, text=name, font=FONT_BODY, text_color=ACCENT_PENDING, anchor="w") label = ctk.CTkLabel(self, text=name, font=FONT_BODY, text_color=ACCENT_PENDING, anchor="w")
pct = ctk.CTkLabel(self, text="", font=FONT_BODY, text_color=ACCENT_PENDING, anchor="e")
dot.grid(row=i + 1, column=0, padx=(16, 2), pady=(2, 2), sticky="w") dot.grid(row=i + 1, column=0, padx=(16, 2), pady=(2, 2), sticky="w")
label.grid(row=i + 1, column=1, padx=(2, 4), pady=(2, 2), sticky="w") label.grid(row=i + 1, column=1, padx=(2, 4), pady=(2, 2), sticky="w")
self._sub_items.append({"dot": dot, "label": label}) pct.grid(row=i + 1, column=2, padx=(4, 8), pady=(2, 2), sticky="e")
self._sub_items.append({"dot": dot, "label": label, "pct": pct})
self.grid_columnconfigure(1, weight=1) self.grid_columnconfigure(1, weight=1)
@@ -823,12 +826,14 @@ class TftpSubStepFrame(ctk.CTkFrame):
if self._collapsed: if self._collapsed:
item["dot"].grid_remove() item["dot"].grid_remove()
item["label"].grid_remove() item["label"].grid_remove()
item["pct"].grid_remove()
else: else:
item["dot"].grid() item["dot"].grid()
item["label"].grid() item["label"].grid()
item["pct"].grid()
def set_expanded(self, expanded: bool) -> None: def set_expanded(self, expanded: bool) -> None:
if expanded != self._collapsed: if expanded == self._collapsed:
self._toggle() self._toggle()
def set_phase(self, phase: str, pct: int = -1) -> None: def set_phase(self, phase: str, pct: int = -1) -> None:
@@ -840,16 +845,20 @@ class TftpSubStepFrame(ctk.CTkFrame):
if idx < 0: if idx < 0:
return return
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 < 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)
item["pct"].configure(text="OK", text_color=_C_DONE)
elif i == idx: elif i == idx:
item["dot"].configure(text="", text_color=_C_RUNNING) item["dot"].configure(text="", text_color=_C_RUNNING)
item["label"].configure(text_color=_C_RUNNING) item["label"].configure(text_color=_C_RUNNING)
item["pct"].configure(text=pct_text, text_color=_C_RUNNING)
else: else:
item["dot"].configure(text="", text_color=_C_PENDING) item["dot"].configure(text="", text_color=_C_PENDING)
item["label"].configure(text_color=_C_PENDING) item["label"].configure(text_color=_C_PENDING)
item["pct"].configure(text="", text_color=_C_PENDING)
if phase in self._substep_names and not self._pulse_job: if phase in self._substep_names and not self._pulse_job:
self._start_pulse(idx) self._start_pulse(idx)
@@ -863,6 +872,7 @@ class TftpSubStepFrame(ctk.CTkFrame):
item = self._sub_items[idx] item = self._sub_items[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)
item["pct"].configure(text="OK", text_color=_C_DONE)
self._stop_pulse() self._stop_pulse()
def set_step_error(self, phase: str) -> None: def set_step_error(self, phase: str) -> None:
@@ -904,6 +914,7 @@ class TftpSubStepFrame(ctk.CTkFrame):
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)
item["label"].configure(text_color=_C) item["label"].configure(text_color=_C)
item["pct"].configure(text="", text_color=_C)
if not self._collapsed: if not self._collapsed:
self._toggle() self._toggle()
self._toggle() # collapse then expand to reset self._toggle() # collapse then expand to reset
+270 -83
View File
@@ -1,14 +1,15 @@
"""Reboot manager for Zynq Flasher GUI. """Reboot manager for Zynq Flasher GUI.
Handles rebooting the Zynq device via SSH or serial commands. Handles rebooting the Zynq device via JTAG, UDP, or serial.
Also provides UDP-based version query.
""" """
from __future__ import annotations from __future__ import annotations
import importlib import os
import platform import socket
import shutil
import subprocess import subprocess
import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable
@@ -26,85 +27,75 @@ class RebootResult:
output: str = "" output: str = ""
@dataclass
class UdpVersionResult:
"""Result of a UDP version query."""
success: bool
message: str
version: str = ""
ProgressCallback = Callable[[str, str], None] | None ProgressCallback = Callable[[str, str], None] | None
# ── UDP helpers ──────────────────────────────────────────────────────
def reboot_via_ssh(
config: Config,
timeout: int | None = None,
callback: ProgressCallback = None,
) -> RebootResult:
"""Reboot the Zynq device via SSH.
Sends a 'reboot' command over SSH to the Zynq device. def _udp_port_from_ip(ip: str) -> int:
"""Derive UDP command port from the last octet of the IP address.
Port = last_octet - 3 (e.g., 192.168.100.11 → 8).
Args: Args:
config: Application configuration. ip: Zynq IP address string.
timeout: Seconds to wait for reboot acknowledgment.
callback: Optional progress callback.
Returns: Returns:
RebootResult with status. UDP port number, or -1 on parse failure.
""" """
timeout = timeout or config.reboot_timeout
ip = config.zynq_ip
if callback:
callback("start", f"Sending reboot via SSH to {ip}...")
# Check ssh availability
if not shutil.which("ssh"):
return RebootResult(
success=False,
message="ssh command not found in PATH",
method="ssh",
)
# Try SSH reboot
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
f"root@{ip}", "reboot"]
try: try:
result = subprocess.run( octets = ip.strip().split(".")
cmd, last = int(octets[3])
capture_output=True, return last - 3
text=True, except (ValueError, IndexError):
timeout=10, return -1
)
if callback:
if result.returncode == 0:
callback("progress", f"Reboot command sent to {ip}")
else:
callback("error", f"SSH reboot failed: {result.stderr.strip()}")
# Wait for device to come back
if result.returncode == 0 and callback:
callback("progress", f"Waiting {timeout}s for device to reboot...")
return RebootResult( def _send_udp_command(
success=result.returncode == 0, ip: str,
message="Reboot command sent" if result.returncode == 0 else f"SSH reboot failed: {result.stderr.strip()}", command: bytes,
method="ssh", timeout: float = 5.0,
output=result.stdout, buffer_size: int = 4096,
) ) -> tuple[bool, str]:
except subprocess.TimeoutExpired: """Send a UDP command to the Zynq and receive the response.
return RebootResult(
success=False, Args:
message="SSH reboot timed out", ip: Zynq IP address.
method="ssh", command: Command bytes to send (e.g., b'reboot()').
) timeout: Receive timeout in seconds.
except FileNotFoundError: buffer_size: Max receive buffer size.
return RebootResult(
success=False, Returns:
message="ssh command not found", (success, response_text).
method="ssh", """
) port = _udp_port_from_ip(ip)
if port <= 0:
return False, f"Invalid IP for UDP port derivation: {ip}"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
sock.sendto(command, (ip, port))
data, addr = sock.recvfrom(buffer_size)
return True, data.decode("utf-8", errors="replace").strip()
except socket.timeout:
return False, f"UDP timeout ({timeout}s)"
except OSError as e: except OSError as e:
return RebootResult( return False, f"UDP error: {e}"
success=False, finally:
message=f"OS error: {e}", sock.close()
method="ssh",
)
# ── Reboot methods ───────────────────────────────────────────────────
def reboot_via_serial( def reboot_via_serial(
@@ -113,7 +104,7 @@ def reboot_via_serial(
) -> RebootResult: ) -> RebootResult:
"""Reboot the Zynq device via serial port. """Reboot the Zynq device via serial port.
Sends a reboot command through the serial console. Sends reboot() through the serial console.
Args: Args:
config: Application configuration. config: Application configuration.
@@ -144,16 +135,16 @@ def reboot_via_serial(
# Send Ctrl+C to break out of any running process # Send Ctrl+C to break out of any running process
ser.write(b"\x03") ser.write(b"\x03")
time.sleep(0.5) time.sleep(0.5)
# Send reboot command # Send reboot command as function call
ser.write(b"reboot\r\n") ser.write(b"reboot()\r\n")
ser.reset_input_buffer() ser.reset_input_buffer()
if callback: if callback:
callback("progress", "Reboot command sent via serial") callback("progress", "reboot() sent via serial")
return RebootResult( return RebootResult(
success=True, success=True,
message="Reboot command sent via serial", message="reboot() sent via serial",
method="serial", method="serial",
) )
except ImportError: except ImportError:
@@ -176,6 +167,194 @@ def reboot_via_serial(
) )
def reboot_via_jtag(
config: Config,
callback: ProgressCallback = None,
) -> RebootResult:
"""Reboot the Zynq device via JTAG system reset.
Connects to the Zynq over JTAG through hw_server and issues
a full system reset (rst -system), then resumes ARM execution.
Args:
config: Application configuration.
callback: Optional progress callback.
Returns:
RebootResult with status.
"""
from vitis_checker import get_xsct_path
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 RebootResult(
success=False,
message="xsct not found — Xilinx tools not available",
method="jtag",
)
if callback:
callback("start", f"JTAG reset via xsct ({xsct})...")
# Build TCL: system reset then resume ARM execution (no stop)
tcl_script = """\
puts "INFO: Connecting to hw_server..."
connect
after 500
puts "INFO: Selecting ARM target..."
targets -set -nocase -filter {name =~ "arm*#0"}
after 200
puts "INFO: Issuing system reset..."
rst -system
after 1500
puts "INFO: Resuming ARM execution..."
con
puts "INFO: System reset complete — disconnecting..."
disconnect
exit
"""
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".tcl", delete=False,
) as f:
f.write(tcl_script)
tcl_path = f.name
if callback:
callback("progress", "Running JTAG system reset...")
result = subprocess.run(
[xsct, tcl_path],
capture_output=True,
text=True,
timeout=30,
)
os.unlink(tcl_path)
success = result.returncode == 0
output = (result.stdout + result.stderr).strip()
if callback:
callback(
"complete" if success else "error",
"JTAG reset " + ("succeeded" if success else "failed"),
)
return RebootResult(
success=success,
message=f"JTAG system reset {'OK' if success else 'failed'}",
method="jtag",
output=output[:2000],
)
except subprocess.TimeoutExpired:
try:
os.unlink(tcl_path)
except OSError:
pass
return RebootResult(
success=False,
message="JTAG reset timed out (30s)",
method="jtag",
)
except OSError as e:
try:
os.unlink(tcl_path)
except OSError:
pass
return RebootResult(
success=False,
message=f"JTAG reset error: {e}",
method="jtag",
)
def reboot_via_udp(
config: Config,
callback: ProgressCallback = None,
) -> RebootResult:
"""Reboot the Zynq device via UDP command.
Sends reboot() to the Zynq's derived UDP port (last-octet - 3).
Args:
config: Application configuration.
callback: Optional progress callback.
Returns:
RebootResult with status.
"""
ip = config.zynq_ip
port = _udp_port_from_ip(ip)
if callback:
callback("start", f"UDP reboot → {ip}:{port}")
ok, resp = _send_udp_command(ip, b"reboot()")
if callback:
if ok:
callback("complete", f"UDP reboot OK: {resp}")
else:
callback("error", f"UDP reboot failed: {resp}")
return RebootResult(
success=ok,
message=f"reboot() via UDP: {resp}" if ok else resp,
method="udp",
output=resp,
)
# ── Version query ────────────────────────────────────────────────────
def get_version_via_udp(
config: Config,
callback: ProgressCallback = None,
) -> UdpVersionResult:
"""Query Zynq firmware version via UDP.
Sends ver() to the Zynq's derived UDP port (last-octet - 3).
Args:
config: Application configuration.
callback: Optional progress callback.
Returns:
UdpVersionResult with version string.
"""
ip = config.zynq_ip
port = _udp_port_from_ip(ip)
if callback:
callback("start", f"UDP version query → {ip}:{port}")
ok, resp = _send_udp_command(ip, b"ver()")
if callback:
if ok:
callback("complete", f"Version: {resp}")
else:
callback("error", f"Version query failed: {resp}")
return UdpVersionResult(
success=ok,
message=f"Version: {resp}" if ok else resp,
version=resp if ok else "",
)
# ── Top-level dispatcher ─────────────────────────────────────────────
def reboot_zynq( def reboot_zynq(
config: Config, config: Config,
method: str = "auto", method: str = "auto",
@@ -184,12 +363,12 @@ def reboot_zynq(
) -> RebootResult: ) -> RebootResult:
"""Reboot the Zynq device using the best available method. """Reboot the Zynq device using the best available method.
Tries SSH first, falls back to serial if SSH is unavailable. Tries JTAG first, falls back to UDP then serial.
Args: Args:
config: Application configuration. config: Application configuration.
method: Reboot method: 'auto', 'ssh', or 'serial'. method: Reboot method: 'auto', 'jtag', 'udp', or 'serial'.
timeout: Seconds to wait for reboot. timeout: Seconds to wait for reboot (ssh-only, kept for compat).
callback: Optional progress callback. callback: Optional progress callback.
Returns: Returns:
@@ -197,17 +376,25 @@ def reboot_zynq(
""" """
timeout = timeout or config.reboot_timeout timeout = timeout or config.reboot_timeout
if method == "ssh": if method == "jtag":
return reboot_via_ssh(config, timeout, callback) return reboot_via_jtag(config, callback)
elif method == "udp":
return reboot_via_udp(config, callback)
elif method == "serial": elif method == "serial":
return reboot_via_serial(config, callback) return reboot_via_serial(config, callback)
# Auto: try SSH first, then serial # Auto: try JTAG first, then UDP, then serial
result = reboot_via_ssh(config, timeout, callback) result = reboot_via_jtag(config, callback)
if result.success: if result.success:
return result return result
if callback: if callback:
callback("progress", "SSH failed, trying serial...") callback("progress", "JTAG failed, trying UDP...")
result = reboot_via_udp(config, callback)
if result.success:
return result
if callback:
callback("progress", "UDP failed, trying serial...")
return reboot_via_serial(config, callback) return reboot_via_serial(config, callback)
+44 -22
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import binascii import binascii
import socket import socket
import struct import struct
import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -55,7 +54,8 @@ def _compute_crc32(file_path: str | Path) -> int:
def _tftp_put(host: str, file_path: str, remote_name: str, def _tftp_put(host: str, file_path: str, remote_name: str,
timeout: float = 10.0) -> tuple[bool, str]: timeout: float = 10.0,
callback: Callable[[str, str], None] | None = None) -> tuple[bool, str]:
"""Upload a file to a TFTP server using pure Python. """Upload a file to a TFTP server using pure Python.
Args: Args:
@@ -63,6 +63,7 @@ def _tftp_put(host: str, file_path: str, remote_name: str,
file_path: Local file path. file_path: Local file path.
remote_name: Remote filename. remote_name: Remote filename.
timeout: Socket timeout in seconds. timeout: Socket timeout in seconds.
callback: Optional progress callback (status, message).
Returns: Returns:
(success, message). (success, message).
@@ -112,6 +113,8 @@ def _tftp_put(host: str, file_path: str, remote_name: str,
pct = offset * 100 // total pct = offset * 100 // total
print(f"[TFTP] Block {block}/{-(-total // block_size)} ({pct}%) sent to {srv_ip}:{srv_port}", print(f"[TFTP] Block {block}/{-(-total // block_size)} ({pct}%) sent to {srv_ip}:{srv_port}",
file=sys.stderr) file=sys.stderr)
if callback:
callback("progress", f"{pct}%")
# Wait for ACK # Wait for ACK
try: try:
@@ -130,7 +133,9 @@ def _tftp_put(host: str, file_path: str, remote_name: str,
def _tftp_get(host: str, remote_name: str, local_dir: str | Path, def _tftp_get(host: str, remote_name: str, local_dir: str | Path,
timeout: float = 30.0) -> tuple[bool, str, Path]: timeout: float = 30.0,
callback: Callable[[str, str], None] | None = None,
expected_size: int = 0) -> tuple[bool, str, Path]:
"""Download a file from a TFTP server using pure Python. """Download a file from a TFTP server using pure Python.
Args: Args:
@@ -138,6 +143,8 @@ def _tftp_get(host: str, remote_name: str, local_dir: str | Path,
remote_name: Remote filename. remote_name: Remote filename.
local_dir: Local directory to save the downloaded file. local_dir: Local directory to save the downloaded file.
timeout: Socket timeout in seconds. timeout: Socket timeout in seconds.
callback: Optional progress callback (status, message).
expected_size: Expected file size in bytes (for progress percentage).
Returns: Returns:
(success, message, local_path). (success, message, local_path).
@@ -170,46 +177,56 @@ def _tftp_get(host: str, remote_name: str, local_dir: str | Path,
srv_ip, srv_port = server_addr srv_ip, srv_port = server_addr
block = struct.unpack("!H", data_pkt[2:4])[0] block = struct.unpack("!H", data_pkt[2:4])[0]
file_data = data_pkt[4:] chunk = data_pkt[4:]
import sys import sys
print(f"[TFTP] DATA block 1 received from {srv_ip}:{srv_port} " print(f"[TFTP] DATA block {block} received from {srv_ip}:{srv_port} "
f"({len(file_data)} bytes)", file=sys.stderr) f"({len(chunk)} bytes)", file=sys.stderr)
# ── ACK block 0 and receive subsequent blocks ── # ── ACK this block with the actual block number ──
ack = struct.pack("!HH", _TFTP_ACK, 0) file_data = bytearray(chunk)
ack = struct.pack("!HH", _TFTP_ACK, block)
sock.sendto(ack, server_addr) sock.sendto(ack, server_addr)
total = len(file_data) if callback:
while len(file_data) == _TFTP_BLOCK_SIZE: pct = len(file_data) * 100 // expected_size if expected_size > 0 else -1
# Wait for next DATA block callback("progress", f"{pct}%")
# ── Receive subsequent blocks while last chunk was full-size ──
while len(chunk) == _TFTP_BLOCK_SIZE:
try: try:
data_pkt, _ = sock.recvfrom(516) data_pkt, _ = sock.recvfrom(516)
if len(data_pkt) < 4: if len(data_pkt) < 4:
return False, "Incomplete DATA packet", local_path return False, "Incomplete DATA packet", local_path
opcode = struct.unpack("!H", data_pkt[:2])[0] opcode = struct.unpack("!H", data_pkt[:2])[0]
if opcode == _TFTP_ERROR:
err_msg = data_pkt[4:].decode("ascii", errors="replace").rstrip("\x00")
return False, f"TFTP error: {err_msg}", local_path
if opcode != _TFTP_DATA: if opcode != _TFTP_DATA:
return False, f"Expected DATA, got opcode {opcode}", local_path return False, f"Expected DATA, got opcode {opcode}", local_path
new_block = struct.unpack("!H", data_pkt[2:4])[0] new_block = struct.unpack("!H", data_pkt[2:4])[0]
chunk = data_pkt[4:] chunk = data_pkt[4:]
file_data += chunk file_data += chunk
total = len(file_data)
block = new_block block = new_block
if block % 100 == 0: if block % 100 == 0:
pct = total * 100 // max(total, 1) print(f"[TFTP] Block {block} ({len(file_data)} bytes) "
print(f"[TFTP] Block {block}/{block} ({pct}%) received from {srv_ip}:{srv_port}", f"received from {srv_ip}:{srv_port}", file=sys.stderr)
file=sys.stderr)
# ACK this block # ACK this block
ack = struct.pack("!HH", _TFTP_ACK, block) ack = struct.pack("!HH", _TFTP_ACK, block)
sock.sendto(ack, server_addr) sock.sendto(ack, server_addr)
if callback:
pct = len(file_data) * 100 // expected_size if expected_size > 0 else -1
callback("progress", f"{pct}%")
except socket.timeout: except socket.timeout:
return False, f"Timeout waiting for DATA block {block}", local_path return False, f"Timeout waiting for DATA block {block + 1}", local_path
# ── Write file ── # ── Write file ──
local_path.write_bytes(file_data) total = len(file_data)
return True, f"Downloaded {total} bytes to {local_path}", local_path local_path.write_bytes(bytes(file_data))
return True, f"Downloaded {total} bytes in {block} blocks to {local_path}", local_path
finally: finally:
sock.close() sock.close()
@@ -254,7 +271,8 @@ def tftp_upload(
) )
try: try:
ok, msg = _tftp_put(config.zynq_ip, str(file_path), remote_name) ok, msg = _tftp_put(config.zynq_ip, str(file_path), remote_name,
callback=callback)
except OSError as e: except OSError as e:
return TftpResult( return TftpResult(
step="upload", success=False, step="upload", success=False,
@@ -279,20 +297,23 @@ def tftp_download(
remote_name: str, remote_name: str,
local_dir: str | Path | None = None, local_dir: str | Path | None = None,
callback: ProgressCallback = None, callback: ProgressCallback = None,
expected_size: int = 0,
) -> TftpResult: ) -> TftpResult:
"""Download a file from the Zynq device via TFTP (pure Python). """Download a file from the Zynq device via TFTP (pure Python).
Args: Args:
config: Application configuration. config: Application configuration.
remote_name: Remote filename on the TFTP server. 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 (default: project .tmp/tftp/).
callback: Optional progress callback. callback: Optional progress callback.
expected_size: Expected file size in bytes (for progress percentage).
Returns: Returns:
TftpResult with download status and CRC. TftpResult with download status and CRC.
""" """
if local_dir is None: if local_dir is None:
local_dir = tempfile.gettempdir() # Use project-local temp dir for cross-platform compatibility
local_dir = Path(__file__).resolve().parent.parent / ".tmp" / "tftp"
local_dir = Path(local_dir) local_dir = Path(local_dir)
if callback: if callback:
@@ -307,7 +328,8 @@ def tftp_download(
) )
try: try:
ok, msg, local_path = _tftp_get(config.zynq_ip, remote_name, local_dir) ok, msg, local_path = _tftp_get(config.zynq_ip, remote_name, local_dir,
callback=callback, expected_size=expected_size)
except OSError as e: except OSError as e:
return TftpResult( return TftpResult(
step="download", success=False, step="download", success=False,