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
418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""TFTP file transfer manager for Zynq Flasher GUI.
|
|
|
|
Handles uploading, downloading, and CRC verification of files
|
|
via TFTP to the Zynq device using a pure-Python TFTP client.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import binascii
|
|
import socket
|
|
import struct
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from config_manager import Config
|
|
from ip_verifier import ping_ip
|
|
|
|
|
|
@dataclass
|
|
class TftpResult:
|
|
"""Result of a TFTP operation."""
|
|
|
|
step: str
|
|
success: bool
|
|
message: str
|
|
local_path: str = ""
|
|
remote_path: str = ""
|
|
crc_local: int = 0
|
|
crc_remote: int = 0
|
|
|
|
|
|
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."""
|
|
crc = 0
|
|
with open(file_path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(65536)
|
|
if not chunk:
|
|
break
|
|
crc = binascii.crc32(chunk, crc)
|
|
return crc & 0xFFFFFFFF
|
|
|
|
|
|
def _tftp_put(host: str, file_path: str, remote_name: 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.
|
|
|
|
Args:
|
|
host: TFTP server IP address.
|
|
file_path: Local file path.
|
|
remote_name: Remote filename.
|
|
timeout: Socket timeout in seconds.
|
|
callback: Optional progress callback (status, message).
|
|
|
|
Returns:
|
|
(success, message).
|
|
"""
|
|
data = Path(file_path).read_bytes()
|
|
total = len(data)
|
|
block_size = _TFTP_BLOCK_SIZE
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.settimeout(timeout)
|
|
# Allow reuse of local port (prevents "Address already in use")
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
|
try:
|
|
# ── Send WRQ to port 69 ──
|
|
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) — discover 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(0), got opcode {opcode} from {server_addr}"
|
|
|
|
srv_ip, srv_port = server_addr
|
|
import sys
|
|
print(f"[TFTP] ACK(0) received from {srv_ip}:{srv_port} "
|
|
f"(file={total}B, blocks={-(-total // block_size)})", file=sys.stderr)
|
|
|
|
# ── Send data blocks ──
|
|
block = 1
|
|
offset = 0
|
|
while offset < total:
|
|
chunk = data[offset:offset + block_size]
|
|
offset += len(chunk)
|
|
data_pkt = struct.pack("!HH", _TFTP_DATA, block) + chunk
|
|
sock.sendto(data_pkt, server_addr)
|
|
|
|
if block % 100 == 0 or block == -(-total // block_size):
|
|
pct = offset * 100 // total
|
|
print(f"[TFTP] Block {block}/{-(-total // block_size)} ({pct}%) sent to {srv_ip}:{srv_port}",
|
|
file=sys.stderr)
|
|
if callback:
|
|
callback("progress", f"{pct}%")
|
|
|
|
# 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} (expected {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_get(host: str, remote_name: str, local_dir: 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.
|
|
|
|
Args:
|
|
host: TFTP server IP address.
|
|
remote_name: Remote filename.
|
|
local_dir: Local directory to save the downloaded file.
|
|
timeout: Socket timeout in seconds.
|
|
callback: Optional progress callback (status, message).
|
|
expected_size: Expected file size in bytes (for progress percentage).
|
|
|
|
Returns:
|
|
(success, message, local_path).
|
|
"""
|
|
local_dir = Path(local_dir)
|
|
local_dir.mkdir(parents=True, exist_ok=True)
|
|
local_path = local_dir / remote_name
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.settimeout(timeout)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
|
try:
|
|
# ── Send RRQ to port 69 ──
|
|
rrq = struct.pack("!H", _TFTP_RRQ)
|
|
rrq += remote_name.encode("ascii") + b"\x00"
|
|
rrq += b"octet\x00"
|
|
sock.sendto(rrq, (host, 69))
|
|
|
|
# ── Wait for first DATA block — discover server's data port ──
|
|
data_pkt, server_addr = sock.recvfrom(516)
|
|
if len(data_pkt) < 4:
|
|
return False, "Invalid DATA from server", local_path
|
|
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:
|
|
return False, f"Expected DATA, got opcode {opcode} from {server_addr}", local_path
|
|
|
|
srv_ip, srv_port = server_addr
|
|
block = struct.unpack("!H", data_pkt[2:4])[0]
|
|
chunk = data_pkt[4:]
|
|
import sys
|
|
print(f"[TFTP] DATA block {block} received from {srv_ip}:{srv_port} "
|
|
f"({len(chunk)} bytes)", file=sys.stderr)
|
|
|
|
# ── ACK this block with the actual block number ──
|
|
file_data = bytearray(chunk)
|
|
ack = struct.pack("!HH", _TFTP_ACK, block)
|
|
sock.sendto(ack, server_addr)
|
|
|
|
if callback:
|
|
pct = len(file_data) * 100 // expected_size if expected_size > 0 else -1
|
|
callback("progress", f"{pct}%")
|
|
|
|
# ── Receive subsequent blocks while last chunk was full-size ──
|
|
while len(chunk) == _TFTP_BLOCK_SIZE:
|
|
try:
|
|
data_pkt, _ = sock.recvfrom(516)
|
|
if len(data_pkt) < 4:
|
|
return False, "Incomplete DATA packet", local_path
|
|
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:
|
|
return False, f"Expected DATA, got opcode {opcode}", local_path
|
|
|
|
new_block = struct.unpack("!H", data_pkt[2:4])[0]
|
|
chunk = data_pkt[4:]
|
|
file_data += chunk
|
|
block = new_block
|
|
|
|
if block % 100 == 0:
|
|
print(f"[TFTP] Block {block} ({len(file_data)} bytes) "
|
|
f"received from {srv_ip}:{srv_port}", file=sys.stderr)
|
|
|
|
# ACK this block
|
|
ack = struct.pack("!HH", _TFTP_ACK, block)
|
|
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:
|
|
return False, f"Timeout waiting for DATA block {block + 1}", local_path
|
|
|
|
# ── Write file ──
|
|
total = len(file_data)
|
|
local_path.write_bytes(bytes(file_data))
|
|
return True, f"Downloaded {total} bytes in {block} blocks to {local_path}", local_path
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def tftp_upload(
|
|
config: Config,
|
|
file_path: str | Path,
|
|
remote_name: str | None = None,
|
|
callback: ProgressCallback = None,
|
|
) -> TftpResult:
|
|
"""Upload a file to the Zynq device via TFTP (pure Python).
|
|
|
|
Args:
|
|
config: Application configuration.
|
|
file_path: Path to the local file to upload.
|
|
remote_name: Remote filename (defaults to config's tftp_upload_name).
|
|
callback: Optional progress callback.
|
|
|
|
Returns:
|
|
TftpResult with upload status.
|
|
"""
|
|
remote_name = remote_name or config.tftp_upload_name
|
|
file_path = Path(file_path).resolve()
|
|
|
|
if not file_path.exists():
|
|
return TftpResult(
|
|
step="upload", success=False,
|
|
message=f"File not found: {file_path}",
|
|
local_path=str(file_path), remote_path=remote_name,
|
|
)
|
|
|
|
if callback:
|
|
callback("start",
|
|
f"Uploading {file_path.name} -> {remote_name}")
|
|
|
|
# Verify device is reachable
|
|
if not ping_ip(config.zynq_ip, config.ping_count, config.ping_timeout):
|
|
return TftpResult(
|
|
step="upload", success=False,
|
|
message=f"Device {config.zynq_ip} not reachable",
|
|
local_path=str(file_path), remote_path=remote_name,
|
|
)
|
|
|
|
try:
|
|
ok, msg = _tftp_put(config.zynq_ip, str(file_path), remote_name,
|
|
callback=callback)
|
|
except OSError as e:
|
|
return TftpResult(
|
|
step="upload", success=False,
|
|
message=f"Socket error: {e}",
|
|
local_path=str(file_path), remote_path=remote_name,
|
|
)
|
|
|
|
if callback:
|
|
callback("complete" if ok else "error",
|
|
"Upload " + ("succeeded" if ok else "failed"))
|
|
|
|
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(
|
|
config: Config,
|
|
remote_name: str,
|
|
local_dir: str | Path | None = None,
|
|
callback: ProgressCallback = None,
|
|
expected_size: int = 0,
|
|
) -> TftpResult:
|
|
"""Download a file from the Zynq device via TFTP (pure Python).
|
|
|
|
Args:
|
|
config: Application configuration.
|
|
remote_name: Remote filename on the TFTP server.
|
|
local_dir: Local directory to save the file (default: project .tmp/tftp/).
|
|
callback: Optional progress callback.
|
|
expected_size: Expected file size in bytes (for progress percentage).
|
|
|
|
Returns:
|
|
TftpResult with download status and CRC.
|
|
"""
|
|
if local_dir is None:
|
|
# Use project-local temp dir for cross-platform compatibility
|
|
local_dir = Path(__file__).resolve().parent.parent / ".tmp" / "tftp"
|
|
local_dir = Path(local_dir)
|
|
|
|
if callback:
|
|
callback("start", f"Downloading {remote_name} from {config.zynq_ip}")
|
|
|
|
# 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",
|
|
remote_path=remote_name,
|
|
)
|
|
|
|
try:
|
|
ok, msg, local_path = _tftp_get(config.zynq_ip, remote_name, local_dir,
|
|
callback=callback, expected_size=expected_size)
|
|
except OSError as e:
|
|
return TftpResult(
|
|
step="download", success=False,
|
|
message=f"Socket error: {e}",
|
|
remote_path=remote_name,
|
|
)
|
|
|
|
crc = _compute_crc32(local_path) if ok else 0
|
|
|
|
if callback:
|
|
callback("complete" if ok else "error",
|
|
"Download " + ("succeeded" if ok else "failed"))
|
|
|
|
return TftpResult(
|
|
step="download", success=ok,
|
|
message=msg,
|
|
local_path=str(local_path), remote_path=remote_name,
|
|
crc_local=crc,
|
|
)
|
|
|
|
|
|
def tftp_upload_verify(
|
|
config: Config,
|
|
file_path: str | Path,
|
|
remote_name: str | None = None,
|
|
callback: ProgressCallback = None,
|
|
) -> TftpResult:
|
|
"""Upload a file via TFTP, download it back, and verify CRC.
|
|
|
|
Args:
|
|
config: Application configuration.
|
|
file_path: Path to the local file to upload.
|
|
remote_name: Remote filename (defaults to config's tftp_upload_name).
|
|
callback: Optional progress callback.
|
|
|
|
Returns:
|
|
TftpResult with upload, download, and CRC verification status.
|
|
"""
|
|
remote_name = remote_name or config.tftp_upload_name
|
|
|
|
# Step 1: Upload
|
|
upload_result = tftp_upload(config, file_path, remote_name, callback)
|
|
if not upload_result.success:
|
|
return upload_result
|
|
|
|
# Step 2: Download for verification
|
|
download_result = tftp_download(config, remote_name, callback=callback)
|
|
if not download_result.success:
|
|
return download_result
|
|
|
|
# Step 3: Compare CRC
|
|
original_crc = _compute_crc32(file_path)
|
|
downloaded_crc = download_result.crc_remote
|
|
|
|
verified = original_crc == downloaded_crc
|
|
|
|
if callback:
|
|
if verified:
|
|
callback("complete", f"CRC verified: {original_crc:#010x} == {downloaded_crc:#010x}")
|
|
else:
|
|
callback("error", f"CRC mismatch: {original_crc:#010x} != {downloaded_crc:#010x}")
|
|
|
|
# Step 4: Clean up downloaded temp file regardless of result
|
|
if download_result.local_path:
|
|
try:
|
|
Path(download_result.local_path).unlink(missing_ok=True)
|
|
if callback:
|
|
callback("complete", "Temp file cleaned up")
|
|
except OSError:
|
|
if callback:
|
|
callback("progress", f"Temp file not removed: {download_result.local_path}")
|
|
|
|
return TftpResult(
|
|
step="upload_verify",
|
|
success=verified,
|
|
message=(
|
|
f"CRC verified: {original_crc:#010x}"
|
|
if verified
|
|
else f"CRC mismatch: {original_crc:#010x} vs {downloaded_crc:#010x}"
|
|
),
|
|
local_path=str(file_path),
|
|
remote_path=remote_name,
|
|
crc_local=original_crc,
|
|
crc_remote=downloaded_crc,
|
|
)
|