Update FPGA and Vivado skills

This commit is contained in:
Myfro9
2026-07-14 18:18:17 +08:00
parent d20ec12fee
commit bceafcd58c
14 changed files with 1707 additions and 14 deletions
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Remote-update the SMOD FPGA by streaming a quickboot _update.mcs to the imx6.
This is a Python reimplementation of what ``tools/Xfpga_ru.exe`` (a .NET GUI, no CLI)
does. The device side is ``my_exciter/imx6/src/atsc_app/task/tcp_task.c`` ::
``fpga_upgrade_task``, listening on TCP 1234. It parses the MCS text we send and
pokes the bytes into the FPGA's SpiFlashProgrammer, which erases and writes the
update half of the SPI flash by itself.
This has nothing to do with the web page's "Remote Update" box -- that one runs
check_package.sh, which only accepts *.tar.gz imx6 firmware bundles and deletes
anything else it finds. See references/protocol.md.
Wire format (all values from tcp_task.c / inc/tcp_task.h)::
request AA | cmd | datalen:u16le | payload | sum | BB
response AA | ack | 01 | 00 | ack | sum | BB (7 bytes)
sum 8-bit sum of every byte from the head up to (not incl.) sum
Usage:
python fpga_ru.py --mcs .../bitstream/atsc_exciter_update.mcs
python fpga_ru.py --mcs ... --host 192.168.1.143 --yes
python fpga_ru.py --mcs ... --dry-run # parse + frame, touch no hardware
"""
import argparse
import os
import re
import socket
import sys
import time
# ------------------------------------------------------------------ protocol constants
HEAD, TAIL = 0xAA, 0xBB
UPGRADE_START, POLL_ERASER, W_DATA_BLOCK, W_DATA_BLOCK_LAST = 0x01, 0x02, 0x03, 0x04
ACK = {
0x01: "START_OK",
0x02: "ERASER_ING",
0x03: "ERASER_END",
0x04: "READY",
0x05: "NOT_READY",
0x06: "ABORT_SUCCESS",
0x07: "ABORT_ERROR",
}
START_OK, ERASER_ING, ERASER_END = 0x01, 0x02, 0x03
READY, NOT_READY, ABORT_SUCCESS, ABORT_ERROR = 0x04, 0x05, 0x06, 0x07
# rxdata[] on the device is MAX_DATA_LEN = 544 bytes and holds the *whole* frame,
# so the payload can never exceed 544 - 6 bytes of framing. Going over this silently
# truncates the frame and the tail check fails.
MAX_FRAME = 544
FRAME_OVERHEAD = 6
MAX_PAYLOAD = MAX_FRAME - FRAME_OVERHEAD # 538
# recv_upgrade_data_parse() rejects any line outside this range with -2 -> ABORT_ERROR.
LINE_MIN, LINE_MAX = 11, 50
UPDATE_START_ADDR = 0x800000
ELA_RE = re.compile(rb"^:02000004([0-9A-Fa-f]{4})")
EOF_RECORD = b":00000001FF"
# ---------------------------------------------------------------------------- framing
def build(cmd, payload=b""):
body = bytes([HEAD, cmd]) + len(payload).to_bytes(2, "little") + payload
return body + bytes([sum(body) & 0xFF, TAIL])
def parse_ack(resp):
if len(resp) != 7 or resp[0] != HEAD or resp[6] != TAIL:
raise ProtocolError(f"malformed response: {resp.hex(' ')}")
return resp[4]
class ProtocolError(RuntimeError):
pass
class Device:
def __init__(self, host, port, timeout):
self.sock = socket.create_connection((host, port), timeout=timeout)
# Every frame is a request/response round trip; Nagle would just add latency.
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
def xfer(self, cmd, payload=b"", timeout=None):
if timeout is not None:
self.sock.settimeout(timeout)
self.sock.sendall(build(cmd, payload))
try:
resp = self.sock.recv(16)
except socket.timeout:
raise ProtocolError(
f"no response to cmd 0x{cmd:02X} within {self.sock.gettimeout()}s.\n"
" The app returns an empty reply (and we block) when sys_ready==0 or the\n"
" frame was malformed. Check the app is up: ssh root@<host> 'ps | grep atsc'"
)
if not resp:
raise ProtocolError("device closed the connection")
return parse_ack(resp)
def close(self):
try:
self.sock.close()
except OSError:
pass
# ------------------------------------------------------------------------ mcs handling
def load_chunks(path, max_payload, allow_any_image):
"""Split the MCS into whole-line payloads.
Lines must stay whole: the device re-splits our payload on ':' and measures each
line's length itself, so a line straddling two frames is parsed as two malformed
lines and aborts the upgrade.
"""
with open(path, "rb") as fh:
lines = fh.readlines()
if not lines:
raise SystemExit(f"[FAIL] {path} is empty")
first_ela = None
for line in lines:
m = ELA_RE.match(line)
if m:
first_ela = int(m.group(1), 16) << 16
break
if first_ela != UPDATE_START_ADDR and not allow_any_image:
got = "none" if first_ela is None else f"0x{first_ela:X}"
raise SystemExit(
f"[FAIL] {os.path.basename(path)} starts at {got}, expected 0x{UPDATE_START_ADDR:X}.\n"
" That is not a quickboot _update.mcs. Uploading a base or _initial mcs\n"
" writes garbage into the update slot; the board then falls back to golden\n"
" on the next cold boot. Regenerate with scripts/gen_update_mcs.py.\n"
" (Override with --allow-any-image if you really mean it.)"
)
for n, line in enumerate(lines, 1):
if not line.startswith(b":"):
raise SystemExit(f"[FAIL] line {n} does not start with ':' -- not an Intel-HEX mcs")
if not (LINE_MIN <= len(line) <= LINE_MAX):
raise SystemExit(
f"[FAIL] line {n} is {len(line)} bytes (incl EOL); the device only accepts "
f"{LINE_MIN}..{LINE_MAX} and aborts otherwise"
)
if not lines[-1].startswith(EOF_RECORD):
raise SystemExit(f"[FAIL] last line is {lines[-1]!r}, expected the EOF record {EOF_RECORD.decode()}")
chunks, cur = [], []
size = 0
for line in lines:
if size + len(line) > max_payload and cur:
chunks.append(b"".join(cur))
cur, size = [], 0
cur.append(line)
size += len(line)
if cur:
chunks.append(b"".join(cur))
return chunks, len(lines)
# ------------------------------------------------------------------------------ stages
def do_erase(dev, timeout):
print("[2/4] erasing the update image region (this is the slow part)")
ack = dev.xfer(UPGRADE_START, timeout=30)
if ack != START_OK:
raise ProtocolError(f"UPGRADE_START -> {ACK.get(ack, hex(ack))}, expected START_OK")
deadline = time.monotonic() + timeout
dots = 0
while time.monotonic() < deadline:
ack = dev.xfer(POLL_ERASER, timeout=30)
if ack == ERASER_END:
print("\n erase complete")
return
if ack != ERASER_ING:
raise ProtocolError(f"POLL_ERASER -> {ACK.get(ack, hex(ack))}")
dots += 1
print(f"\r erasing... {dots * 0.5:.0f}s", end="", flush=True)
time.sleep(0.5)
raise ProtocolError(
f"flash still erasing after {timeout}s.\n"
" POLL_ERASER only ever reports ERASER_ING/ERASER_END -- the app never checks the\n"
" ERROR bit, so a programmer that aborted looks identical to one that is still busy.\n"
" Read the real status on the device:\n"
" ssh root@<host> \"grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3\"\n"
" rstate=0x7 (ERROR|DONE, EraseOK=0) means SpiFlashProgrammer aborted immediately --\n"
" the running design cannot reach the flash. See references/protocol.md.")
def do_write(dev, chunks, total_lines, retries):
print(f"[3/4] streaming {total_lines} mcs lines in {len(chunks)} frames")
sent_lines = 0
t0 = time.monotonic()
for i, chunk in enumerate(chunks):
for attempt in range(retries + 1):
ack = dev.xfer(W_DATA_BLOCK, chunk, timeout=60)
if ack == READY:
break
if ack == NOT_READY:
# Device asks us to re-send this exact block after a short pause.
time.sleep(0.001)
continue
if ack in (ABORT_SUCCESS, ABORT_ERROR):
return ack
raise ProtocolError(f"W_DATA_BLOCK -> {ACK.get(ack, hex(ack))}")
else:
raise ProtocolError(f"frame {i} still NOT_READY after {retries} retries")
sent_lines += chunk.count(b"\n")
if i % 2000 == 0 or i == len(chunks) - 1:
pct = 100.0 * sent_lines / total_lines
rate = sent_lines / max(time.monotonic() - t0, 1e-6)
eta = (total_lines - sent_lines) / max(rate, 1e-6)
print(f"\r {pct:5.1f}% {sent_lines}/{total_lines} lines ETA {eta/60:.1f} min",
end="", flush=True)
print()
return None
def do_finish(dev, timeout):
print("[4/4] waiting for the FPGA to report the write is done")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
ack = dev.xfer(W_DATA_BLOCK_LAST, timeout=30)
except (ProtocolError, ConnectionResetError, OSError) as exc:
# On success the app calls reset_fpga_system() and reboots the imx6, so the
# socket dying right here is expected -- but we can no longer read the ack.
raise ProtocolError(f"link dropped while waiting for the final ack: {exc}")
if ack in (ABORT_SUCCESS, ABORT_ERROR):
return ack
if ack != READY:
raise ProtocolError(f"W_DATA_BLOCK_LAST -> {ACK.get(ack, hex(ack))}")
time.sleep(0.2)
raise ProtocolError(f"no final verdict after {timeout}s")
# -------------------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--mcs", required=True, help="path to the quickboot <tag>_update.mcs")
ap.add_argument("--host", default="192.168.1.143")
ap.add_argument("--port", type=int, default=1234)
ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
ap.add_argument("--dry-run", action="store_true", help="validate and frame the mcs, contact nothing")
ap.add_argument("--allow-any-image", action="store_true",
help="skip the 0x800000 start-address check (you almost never want this)")
ap.add_argument("--max-payload", type=int, default=MAX_PAYLOAD)
ap.add_argument("--erase-timeout", type=float, default=600)
ap.add_argument("--finish-timeout", type=float, default=120)
ap.add_argument("--retries", type=int, default=200)
args = ap.parse_args()
if not 0 < args.max_payload <= MAX_PAYLOAD:
raise SystemExit(f"[FAIL] --max-payload must be 1..{MAX_PAYLOAD} (device rxdata is {MAX_FRAME} bytes)")
print(f"[1/4] validating {args.mcs}")
chunks, total_lines = load_chunks(args.mcs, args.max_payload, args.allow_any_image)
mb = os.path.getsize(args.mcs) / 1e6
print(f" {mb:.1f} MB, {total_lines} lines, {len(chunks)} frames, starts at 0x{UPDATE_START_ADDR:X}")
if args.dry_run:
print("[dry-run] mcs is well-formed and would stream cleanly. Nothing was sent.")
return
if not args.yes:
print(f"\nAbout to erase and rewrite the update image on {args.host}.")
print("The golden image is untouched, so a failure here is recoverable by cold boot.")
print("Do NOT power the board off while this runs.")
print("\nMake sure DPD is OFF. The exciter's DPD sample&load writes to the same SPI1 bus\n"
"we stream the image over, and the FPGA's spi_slave32 will happily fold those words\n"
"into the flash image. See references/protocol.md.")
if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
raise SystemExit("aborted")
dev = Device(args.host, args.port, timeout=30)
try:
do_erase(dev, args.erase_timeout)
verdict = do_write(dev, chunks, total_lines, args.retries)
if verdict is None:
verdict = do_finish(dev, args.finish_timeout)
finally:
dev.close()
if verdict == ABORT_SUCCESS:
print("\n[OK] FPGA reports the update image was written and verified.")
print("The app has reset the FPGA and is rebooting the imx6.")
print("Now cold-boot the board (observe the SMOD power-up sequence) to load the new image.")
return
raise SystemExit(
f"\n[FAIL] device returned {ACK.get(verdict, hex(verdict))}.\n"
"The update slot is now half-written, but the switch word was erased at the start,\n"
"so the board still cold-boots golden. Nothing is bricked.\n\n"
"If this was ABORT_ERROR late in the stream, suspect SPI1 contention: the exciter's\n"
"DPD sample&load shares fpga_spi_fd with this upload, and the FPGA folds any SPI1 word\n"
"into the image. Turn DPD off and retry. references/protocol.md has the full story.")
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Build the quickboot _update.mcs for the SMOD ATSC exciter.
Runs exactly the three scripts the project has always used, in order:
1. tcl_1/post_bitstream.tcl impl_1/*.bit,.bin,.ltx -> bitstream/
2. tcl_1/runScript_GenRu.tcl write_cfgmem -size 64 -interface SPIx1
-> bitstream/atsc_exciter.mcs
3. tcl_1/MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64 atsc_exciter.mcs
-> bitstream/atsc_exciter_update.mcs
Step 2 does ``glob *.bit`` inside bitstream/, so every stale .bit sitting there gets
concatenated into one giant image. bitstream/ accumulates iteration bits, so this
script moves the old ones aside for the duration and puts them back afterwards.
The resulting _update.mcs is ONLY for the network remote update (scripts/fpga_ru.py).
It cannot be JTAG-flashed -- see references/quickboot.md.
"""
import argparse
import glob
import os
import re
import shutil
import subprocess
import sys
import tempfile
REPO = r"E:\Xilinx_design\smod\SMOD_ATSC\my_exciter"
TCL_DIR = os.path.join(REPO, "my_exciter.srcs", "tcl_1")
BITSTREAM = os.path.join(REPO, "bitstream")
IMPL = os.path.join(REPO, "my_exciter.runs", "impl_1")
POST_BITSTREAM = os.path.join(TCL_DIR, "post_bitstream.tcl")
GEN_RU = os.path.join(TCL_DIR, "runScript_GenRu.tcl")
PERL_SCRIPT = os.path.join(TCL_DIR, "MakeSpiFlashProgrammerMcsFiles_backup.pl")
DEFAULT_VIVADO = r"D:\Xilinx\Vivado\2018.1\bin\vivado.bat"
# runScript_GenRu.tcl hardcodes this name.
BASE_MCS = "atsc_exciter.mcs"
UPDATE_MCS = "atsc_exciter_update.mcs"
# The perl script emits these alongside the one we want.
BYPRODUCTS = ("atsc_exciter_initial.mcs", "atsc_exciter_golden.mcs", "atsc_exciter_total.mcs",
"atsc_exciter_update2.mcs", "atsc_exciter_simple.mcs")
# ":02000004HHHH" is the Intel-HEX extended-linear-address record.
ELA_RE = re.compile(rb"^:02000004([0-9A-Fa-f]{4})")
EXPECT_ELA = 0x800000
def sh(cmd, cwd=None):
print(f" $ {' '.join(cmd)}", flush=True)
if subprocess.run(cmd, cwd=cwd).returncode != 0:
sys.exit("[FAIL] command failed; see output above")
def first_ela(path):
with open(path, "rb") as fh:
for line in fh:
m = ELA_RE.match(line)
if m:
return int(m.group(1), 16) << 16
return None
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--vivado", default=DEFAULT_VIVADO)
ap.add_argument("--tag", help="rename the result to <tag>_update.mcs so it survives the next run")
ap.add_argument("--keep-byproducts", action="store_true")
args = ap.parse_args()
for path, what in ((args.vivado, "vivado"), (POST_BITSTREAM, "post_bitstream.tcl"),
(GEN_RU, "runScript_GenRu.tcl"), (PERL_SCRIPT, "perl script")):
if not os.path.exists(path):
sys.exit(f"[FAIL] {what} not found: {path}")
impl_bits = glob.glob(os.path.join(IMPL, "*.bit"))
if len(impl_bits) != 1:
sys.exit(f"[FAIL] expected exactly one .bit in {IMPL}, found {len(impl_bits)}: "
f"{[os.path.basename(b) for b in impl_bits]}")
print(f"[1/4] implementation bit: {os.path.basename(impl_bits[0])}")
# Park every stale .bit so runScript_GenRu.tcl's `glob *.bit` can only see the fresh one.
stash = tempfile.mkdtemp(prefix="smod_bit_stash_")
parked = glob.glob(os.path.join(BITSTREAM, "*.bit"))
for b in parked:
shutil.move(b, os.path.join(stash, os.path.basename(b)))
if parked:
print(f"[2/4] parked {len(parked)} stale .bit files from bitstream/ (restored at the end)")
try:
# One vivado launch, both tcl scripts: each re-derives its own path from [info script].
wrapper = os.path.join(stash, "run_both.tcl")
with open(wrapper, "w") as fh:
fh.write(f'source {{{POST_BITSTREAM}}}\n')
fh.write(f'source {{{GEN_RU}}}\n')
print(f"[3/4] vivado: post_bitstream.tcl + runScript_GenRu.tcl -> {BASE_MCS}")
sh([args.vivado, "-mode", "batch", "-nojournal", "-notrace", "-source", wrapper], cwd=stash)
base = os.path.join(BITSTREAM, BASE_MCS)
if not os.path.exists(base):
sys.exit(f"[FAIL] {base} was not produced")
print(f"[4/4] perl MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64 {BASE_MCS}")
sh(["perl", PERL_SCRIPT, "-imagesize", "64", BASE_MCS], cwd=BITSTREAM)
finally:
for name in os.listdir(stash):
if not name.endswith(".bit"):
continue
dest = os.path.join(BITSTREAM, name)
# post_bitstream.tcl just copied the fresh impl_1 bit here under this same
# name; putting the parked (stale) one back would silently undo that.
if not os.path.exists(dest):
shutil.move(os.path.join(stash, name), dest)
shutil.rmtree(stash, ignore_errors=True)
update = os.path.join(BITSTREAM, UPDATE_MCS)
if not os.path.exists(update):
sys.exit(f"[FAIL] {UPDATE_MCS} was not produced")
# -imagesize 64 must place the image at 0x800000. If it doesn't, the payload is not
# 8 MB and the FPGA's CRC check over 0x400000..0xC00000 will fail after a long upload.
ela = first_ela(update)
if ela != EXPECT_ELA:
got = "none" if ela is None else f"0x{ela:X}"
sys.exit(f"[FAIL] {UPDATE_MCS} starts at {got}, expected 0x{EXPECT_ELA:X}. Do not upload it.")
if not args.keep_byproducts:
for name in BYPRODUCTS:
p = os.path.join(BITSTREAM, name)
if os.path.exists(p):
os.remove(p)
if args.tag:
tagged = os.path.join(BITSTREAM, f"{args.tag}_update.mcs")
shutil.move(update, tagged)
update = tagged
print(f"\n[OK] {update} ({os.path.getsize(update)/1e6:.1f} MB)")
print(f" base mcs kept at {os.path.join(BITSTREAM, BASE_MCS)}")
print("\nNext: python scripts/fpga_ru.py --mcs \"%s\"" % update)
print(" Turn DPD off first -- see references/protocol.md.")
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""A fake SMOD imx6 that speaks the FPGA remote-update protocol on TCP 1234.
Mirrors ``fpga_upgrade_process()`` in tcp_task.c as literally as possible, including
the parts that bite: the ':'-based re-splitting of our payload, the 11..50 byte line
limit, and the READY-then-ABORT_SUCCESS handshake at the end.
The point is to be able to exercise fpga_ru.py end-to-end without erasing a real
flash. If a change to fpga_ru.py passes against this and against the real board,
the two agree; if it passes here but not there, this file is the thing that's wrong.
python mock_device.py --port 12340
python fpga_ru.py --mcs some_update.mcs --host 127.0.0.1 --port 12340 --yes
Fault injection, to prove the client's retry/abort paths actually work:
--not-ready-every N answer NOT_READY to every Nth data frame
--erase-polls N answer ERASER_ING to the first N POLL_ERASER frames
--abort-at-frame N answer ABORT_ERROR on the Nth data frame
"""
import argparse
import socket
import sys
HEAD, TAIL = 0xAA, 0xBB
UPGRADE_START, POLL_ERASER, W_DATA_BLOCK, W_DATA_BLOCK_LAST = 0x01, 0x02, 0x03, 0x04
START_OK, ERASER_ING, ERASER_END = 0x01, 0x02, 0x03
READY, NOT_READY, ABORT_SUCCESS, ABORT_ERROR = 0x04, 0x05, 0x06, 0x07
LINE_MIN, LINE_MAX = 11, 50
MAX_DATA_LEN = 544
def reply(ack):
tx = bytes([HEAD, ack, 1, 0, ack])
return tx + bytes([sum(tx) & 0xFF, TAIL])
def split_lines_like_c(payload):
"""Reproduce the strchr(':') walk in W_DATA_BLOCK.
The device never looks at newlines: it finds the first ':', then each following
':' ends the previous line. The final line runs to the NUL terminator, which is
why a payload that ends mid-line silently mis-measures that line.
"""
out = []
start = payload.find(b":")
if start < 0:
return None
while True:
nxt = payload.find(b":", start + 1)
out.append(payload[start:] if nxt < 0 else payload[start:nxt])
if nxt < 0:
return out
start = nxt
def parse_line(line):
"""recv_upgrade_data_parse(): -2 bad length, -1 EOF record, 16 data bytes, 0 control."""
if not (LINE_MIN <= len(line) <= LINE_MAX):
return -2
length = int(line[1:3], 16)
rectype = int(line[7:9], 16)
if length == 0x10 and rectype == 0x00:
return 16
if length == 0x00 and rectype == 0x01:
return -1
return 0
class Mock:
def __init__(self, args):
self.args = args
self.bytes_written = 0
self.data_frames = 0
self.erase_polls = 0
self.file_end = False
self.done_polls = 0
def on_frame(self, cmd, payload):
if cmd == UPGRADE_START:
self.bytes_written = 0
self.erase_polls = 0
self.file_end = False
return START_OK
if cmd == POLL_ERASER:
self.erase_polls += 1
return ERASER_ING if self.erase_polls <= self.args.erase_polls else ERASER_END
if cmd == W_DATA_BLOCK:
self.data_frames += 1
if self.args.abort_at_frame and self.data_frames == self.args.abort_at_frame:
return ABORT_ERROR
if self.args.not_ready_every and self.data_frames % self.args.not_ready_every == 0:
return NOT_READY # client must re-send this exact frame
lines = split_lines_like_c(payload)
if lines is None:
return ABORT_ERROR
ack = READY
for line in lines:
n = parse_line(line)
if n == -2:
print(f" [mock] ABORT: bad line ({len(line)} bytes) {line[:20]!r}", file=sys.stderr)
return ABORT_ERROR
if n == -1:
self.file_end = True
ack = READY # check_fpga_upgrade_done() is not DONE yet
elif n > 0:
self.bytes_written += n
return ack
if cmd == W_DATA_BLOCK_LAST:
self.done_polls += 1
if self.done_polls < self.args.done_polls:
return READY
return ABORT_SUCCESS if self.file_end else ABORT_ERROR
return ABORT_ERROR
def serve_one(self, conn):
buf = b""
while True:
data = conn.recv(MAX_DATA_LEN)
if not data:
return
buf += data
# The real device does a single recv() per frame; we tolerate splits so a
# client bug shows up as a protocol error rather than a hang.
while len(buf) >= 6:
datalen = int.from_bytes(buf[2:4], "little")
total = datalen + 6
if len(buf) < total:
break
frame, buf = buf[:total], buf[total:]
if frame[0] != HEAD or frame[-1] != TAIL:
print(f" [mock] bad framing {frame[:4].hex(' ')}", file=sys.stderr)
return
if sum(frame[:datalen + 4]) & 0xFF != frame[datalen + 4]:
conn.sendall(reply(NOT_READY))
continue
conn.sendall(reply(self.on_frame(frame[1], frame[4:4 + datalen])))
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", type=int, default=12340)
ap.add_argument("--erase-polls", type=int, default=2)
ap.add_argument("--done-polls", type=int, default=1)
ap.add_argument("--not-ready-every", type=int, default=0)
ap.add_argument("--abort-at-frame", type=int, default=0)
args = ap.parse_args()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", args.port))
srv.listen(1)
print(f"[mock] listening on 127.0.0.1:{args.port}", flush=True)
conn, _ = srv.accept()
mock = Mock(args)
try:
mock.serve_one(conn)
finally:
conn.close()
mib = mock.bytes_written / (1 << 20)
print(f"[mock] {mock.data_frames} data frames, {mock.bytes_written} bytes ({mib:.3f} MiB), "
f"file_end={mock.file_end}")
# A correct run streams exactly the 8 MiB update image.
sys.exit(0 if mock.file_end else 1)
if __name__ == "__main__":
main()