Files
zhengxinandCursor 851e347cf5 初始提交卡包 USB 工程
纳入固件源码、屏幕组件、nand_xfer 工具和文档。忽略本机构建产物、sdkconfig,以及含本机路径的依赖锁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:54:50 +08:00

323 lines
13 KiB
Python

"""USB Serial/JTAG SPI NAND file transfer client (Python 3 + pyserial)."""
import argparse
import os
import secrets
import stat
import struct
import sys
import time
import zlib
from pathlib import Path
try:
import serial
except ImportError:
serial = None
MAGIC = b"NXFR"
HEADER = struct.Struct("<4sBBHII")
INFO, LIST, MKDIR, BEGIN, BLOCK, END, VERIFY, CANCEL, PREPARE, CONFIRM, HELLO = range(1, 12)
ERRORS = ["OK", "bad frame", "bad path", "not mounted", "I/O error",
"bad state", "CRC/size mismatch", "denied"]
ROOT = "/extflash"
def path_bytes(path, allow_root=False):
if not isinstance(path, str) or not path.startswith(ROOT):
raise ValueError("destination must start with /extflash")
if path == ROOT:
if not allow_root:
raise ValueError("the volume root is not a file/directory name")
elif not path.startswith(ROOT + "/"):
raise ValueError("path outside /extflash")
else:
parts = path[len(ROOT) + 1:].split("/")
if any(not p or p in (".", "..") or p[-1] in " ." or
any(ord(c) < 32 or c in '\\:*?"<>|' for c in p) for p in parts):
raise ValueError("invalid FAT path")
value = path.encode("utf-8")
if len(value) >= 512:
raise ValueError("UTF-8 path too long")
return value
def encode(cmd, seq, payload=b""):
if len(payload) > 4352:
raise ValueError("frame too large")
body = HEADER.pack(MAGIC, 1, cmd, 0, seq, len(payload)) + payload
return body + struct.pack("<I", zlib.crc32(body) & 0xffffffff)
class Client:
def __init__(self, port):
self.port = port
self.seq = secrets.randbits(32)
def wait_ready(self, timeout=0.4):
deadline = time.monotonic() + timeout
buf = b""
old_timeout = getattr(self.port, "timeout", None)
try:
if old_timeout is not None:
self.port.timeout = min(0.05, timeout)
while time.monotonic() < deadline:
chunk = self.port.read(1024)
if not chunk:
continue
buf = (buf + chunk)[-96:]
if b"NAND-XFER READY" in buf:
return True
finally:
if old_timeout is not None:
self.port.timeout = old_timeout
return False
def drain_rx(self):
old_timeout = getattr(self.port, "timeout", None)
try:
if old_timeout is not None:
self.port.timeout = 0
while self.port.read(4096):
pass
finally:
if old_timeout is not None:
self.port.timeout = old_timeout
def connect(self):
self.wait_ready()
self.drain_rx()
nonce = secrets.token_bytes(4)
if self.call(HELLO, nonce) != nonce:
raise ValueError("device handshake failed")
def _frame(self, deadline):
match = bytearray()
while time.monotonic() < deadline:
byte = self.port.read(1)
if not byte:
continue
match = (match + byte)[-4:]
if match == MAGIC:
break
else:
raise TimeoutError("no response magic")
header = MAGIC + self._exact(12, deadline)
magic, version, cmd, flags, seq, size = HEADER.unpack(header)
if version != 1 or flags or size > 4352:
raise ValueError("invalid response header")
payload = self._exact(size, deadline)
checksum, = struct.unpack("<I", self._exact(4, deadline))
if (zlib.crc32(header + payload) & 0xffffffff) != checksum:
raise ValueError("response CRC mismatch")
return cmd, seq, payload
def _exact(self, size, deadline):
data = bytearray()
while len(data) < size and time.monotonic() < deadline:
data.extend(self.port.read(size - len(data)))
if len(data) != size:
raise TimeoutError("incomplete response")
return bytes(data)
def call(self, cmd, payload=b"", timeout=12, retries=3):
self.seq = (self.seq + 1) & 0xffffffff
seq = self.seq
packet = encode(cmd, seq, payload)
for attempt in range(retries):
self.port.write(packet)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
reply_cmd, reply_seq, body = self._frame(deadline)
except (ValueError, TimeoutError):
break
if reply_seq != seq or reply_cmd != (cmd | 0x80):
continue
if not body:
raise RuntimeError("empty reply")
if body[0]:
code = body[0]
raise RuntimeError(ERRORS[code] if code < len(ERRORS) else f"error {code}")
return body[1:]
raise TimeoutError(f"command {cmd} unacknowledged after {retries} attempts")
def info(self):
page, pages, mounted, total, free = struct.unpack("<IIBQQ", self.call(INFO))
return page, pages, bool(mounted), total, free
def verify(self, path):
return struct.unpack("<QI", self.call(VERIFY, path_bytes(path), timeout=60))
def listdir(self, path):
encoded = path_bytes(path, allow_root=True)
cursor = 0
while True:
body = self.call(LIST, struct.pack("<H", cursor) + encoded)
if len(body) < 3:
raise ValueError("invalid directory response")
next_cursor, done = struct.unpack("<HB", body[:3])
i = 3
while i < len(body):
if i + 10 > len(body):
raise ValueError("truncated directory entry")
is_dir, name_len, size = struct.unpack_from("<BBQ", body, i)
i += 10
if i + name_len > len(body):
raise ValueError("truncated directory name")
name = body[i:i + name_len].decode("utf-8")
i += name_len
yield (is_dir, name, size)
if done:
return
if next_cursor <= cursor:
raise ValueError("directory cursor did not advance")
cursor = next_cursor
def upload(self, source, dest, log=print):
dest = path_bytes(dest).decode("utf-8")
try:
self.call(BEGIN, path_bytes(dest))
size = 0
crc = 0
with open(source, "rb") as stream:
while True:
data = stream.read(4096)
if not data:
break
if size > 0xffffffff:
raise ValueError("file exceeds 4 GiB protocol offset limit")
ack = self.call(BLOCK, struct.pack("<I", size) + data, timeout=30)
size += len(data)
crc = zlib.crc32(data, crc) & 0xffffffff
if len(ack) != 8 or struct.unpack("<Q", ack)[0] != size:
raise ValueError("unexpected device offset")
result = self.call(END, struct.pack("<QI", size, crc), timeout=90)
if len(result) != 12 or struct.unpack("<QI", result) != (size, crc):
raise ValueError("device readback differs from source")
if self.verify(dest) != (size, crc):
raise ValueError("post-close verification differs")
log(f"OK {source} -> {dest} ({size} bytes, CRC32 {crc:08x})")
except Exception:
try:
self.call(CANCEL, timeout=1, retries=1)
except Exception:
pass
raise
def directory_entries(source):
root = Path(source)
for base, dirs, files in os.walk(root, followlinks=False):
dirs.sort()
files.sort()
for name in sorted(dirs + files):
path = Path(base) / name
mode = path.lstat().st_mode
if stat.S_ISLNK(mode) or (hasattr(path, "is_junction") and path.is_junction()):
raise ValueError(f"link/junction not allowed: {path}")
if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
raise ValueError(f"not a normal file or directory: {path}")
yield path, path.relative_to(root).parts, stat.S_ISDIR(mode)
def open_port(name):
port = serial.Serial()
port.port = name
port.baudrate = 115200
port.timeout = 0.2
port.write_timeout = 15
port.dsrdtr = False
port.rtscts = False
port.open()
try:
port.setRTS(False)
port.setDTR(True)
except (AttributeError, OSError):
pass
return port
def format_volume(client):
token = client.call(PREPARE)
if len(token) != 4:
raise ValueError("invalid format challenge")
# Confirmation is acquired before PREPARE so the 30-second device token cannot expire at the prompt.
client.call(CONFIRM, token + struct.pack("<I", client.seq), timeout=180, retries=1)
def format_with_confirmation(client, input_fn=input):
print("WARNING: this erases ALL files on the external NAND volume.")
if input_fn("Type FORMAT /extflash to continue: ") != "FORMAT /extflash":
raise ValueError("format cancelled")
format_volume(client)
print("NAND formatted and remounted")
def main(argv=None):
parser = argparse.ArgumentParser(description="Transfer files to external SPI NAND over USB Serial/JTAG")
parser.add_argument("port")
parser.add_argument("source", nargs="?")
parser.add_argument("destination", nargs="?")
parser.add_argument("--recursive", action="store_true")
group = parser.add_mutually_exclusive_group()
group.add_argument("--info", action="store_true")
group.add_argument("--list", metavar="DEVICE_DIR")
group.add_argument("--mkdir", metavar="DEVICE_DIR")
group.add_argument("--verify", metavar="DEVICE_FILE")
group.add_argument("--format", action="store_true")
args = parser.parse_args(argv)
if args.recursive and not (args.source and args.destination):
parser.error("--recursive requires source and destination")
if args.source or args.destination:
if not args.source or not args.destination or any((args.info, args.list, args.mkdir, args.verify, args.format)):
parser.error("specify both source and destination, with no command option")
elif not any((args.info, args.list, args.mkdir, args.verify, args.format)):
parser.error("specify a file/directory transfer or a command")
if serial is None:
parser.error("pyserial missing: py -3 -m pip install pyserial")
try:
with open_port(args.port) as port:
client = Client(port)
client.connect()
if args.format:
format_with_confirmation(client)
elif args.info:
page, pages, mounted, total, free = client.info()
print(f"NAND: {page * pages} bytes ({pages} pages x {page}); mounted={mounted}; FAT={total} bytes, free={free}")
elif args.list:
for is_dir, name, size in client.listdir(args.list):
print(f"{'DIR ' if is_dir else 'FILE'} {name} {size if not is_dir else ''}")
elif args.mkdir:
client.call(MKDIR, path_bytes(args.mkdir))
elif args.verify:
size, crc = client.verify(args.verify)
print(f"{args.verify}: {size} bytes, CRC32 {crc:08x}")
else:
source = Path(args.source)
if source.is_symlink() or (hasattr(source, "is_junction") and source.is_junction()):
raise ValueError("links and junctions are not allowed")
if source.is_file() and not args.recursive:
client.upload(source, args.destination)
elif source.is_dir() and args.recursive:
target = path_bytes(args.destination).decode("utf-8")
entries = list(directory_entries(source))
for _, parts, _ in entries:
path_bytes(target + "/" + "/".join(parts))
client.call(MKDIR, path_bytes(target))
for path, parts, is_dir in entries:
remote = target + "/" + "/".join(parts)
if is_dir:
client.call(MKDIR, path_bytes(remote))
else:
client.upload(path, remote)
elif not source.exists():
raise ValueError(f"source not found: {source}")
else:
raise ValueError("use --recursive for a directory; omit it for a single file")
except (OSError, ValueError, RuntimeError, TimeoutError, serial.SerialException) as exc:
parser.exit(1, f"error: {exc}\n")
if __name__ == "__main__":
main()