4"""Exhaustive correctness + throughput test for a CDC ACM echo device.
6Used by the HIL suite to validate both USBFS (J11) and USBHS (J7) on the
7EK-RA8D2 against /dev/ttyACM*.
9Every payload is drawn from a FIXED seed, so a failing run can be replayed
10byte-for-byte: the seeds are named constants rather than literals precisely
11so that a reported mismatch offset means the same thing on the next run.
14 python3 benchmark.py <device> # all tests
15 python3 benchmark.py <device> --quick # short correctness pass only
17Exit status: 0 if every test passes, non-zero otherwise.
20from __future__
import annotations
30from pathlib
import Path
31from typing
import NoReturn
34def open_raw(device: str) -> tuple[int, int]:
35 """Open a tty in fully raw mode and return ``(fd, original_flags)``.
37 Every line-discipline transformation is cleared -- canonical mode, echo,
38 signal generation, XON/XOFF, CR/LF translation and output post-processing.
39 That is not tidiness: on a byte-exact echo test, ICRNL alone silently
40 rewrites 0x0D to 0x0A and would surface as a data-corruption failure
41 attributed to the device.
43 O_NONBLOCK is used to get past a blocking open and then CLEARED, so the
44 returned fd is blocking. The original flags come back with it because the
45 read helpers toggle O_NONBLOCK on and off and need the value to restore.
47 fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
48 attr = termios.tcgetattr(fd)
49 attr[2] |= termios.CLOCAL
50 attr[2] &= ~termios.CRTSCTS
51 attr[3] &= ~(termios.ICANON | termios.ECHO | termios.ECHOE | termios.ECHONL | termios.ISIG)
61 attr[1] &= ~termios.OPOST
62 termios.tcsetattr(fd, termios.TCSANOW, attr)
63 flags = fcntl.fcntl(fd, fcntl.F_GETFL)
64 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
68def drain(fd: int, flags: int, quiet_for: float = 0.30) ->
None:
69 """Drain pending echo data until we see no new bytes for `quiet_for` seconds.
71 Also calls tcflush so any kernel-side queued bytes from a previous test
72 are discarded before the next test starts measuring.
74 termios.tcflush(fd, termios.TCIOFLUSH)
75 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
76 last_data = time.monotonic()
78 while time.monotonic() - last_data < quiet_for:
82 last_data = time.monotonic()
85 except BlockingIOError:
88 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
89 termios.tcflush(fd, termios.TCIOFLUSH)
92def echo_one(fd: int, flags: int, msg: bytes, settle: float = 0.25, timeout: float = 1.5) -> bytes:
93 """Write one message and read back whatever echoes within ``timeout``.
95 Returns the bytes received, NOT a pass/fail verdict -- comparison is the
96 caller's job, so a short or corrupted read is reported with its actual
97 content rather than collapsing to False.
99 ``settle`` is an unconditional sleep before reading. It exists because the
100 device needs time to turn the transfer around, and reading immediately
101 would return empty and then race the deadline.
105 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
107 deadline = time.monotonic() + timeout
109 while len(total) < len(msg)
and time.monotonic() < deadline:
111 d = os.read(fd, 4096)
116 except BlockingIOError:
119 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
123def test_lengths(fd: int, flags: int, max_len: int) -> bool:
124 """Echo every payload length from 1 to ``max_len`` and check each round-trips.
126 Sweeping lengths one at a time is what catches an off-by-one at a packet
127 boundary: a device that mishandles exactly the max-packet-size payload
128 passes any test that only sends round numbers.
130 Payload bytes are a counter mod 256, so a reported mismatch offset reads
131 directly as a position rather than needing a lookup.
133 Returns True only if every length round-tripped exactly.
135 print(f
"=== Test: all lengths 1..{max_len} (exhaustive correctness) ===")
137 for length
in range(1, max_len + 1):
138 msg = bytes([(i % 256)
for i
in range(length)])
139 recv = echo_one(fd, flags, msg)
144 print(f
" FAIL L={length:3d}: sent {msg.hex()[:40]}... recv {recv.hex()[:40]}...")
145 print(f
" -> {passes}/{passes + fails} pass")
149RANDOM_SEED_CORRECTNESS = 0xCAFE
150RANDOM_SEED_THROUGHPUT = 0xC0FFEE
151RANDOM_SEED_CHUNKED = 0xBEEF1234
154def test_random(fd: int, flags: int, n_iters: int) -> bool:
155 """Echo ``n_iters`` random payloads of random length and check each round-trips.
157 Complements the exhaustive length sweep by varying content as well as
158 size. The generator is seeded from a fixed constant, so "random" here means
159 unpredictable-but-reproducible: a failure can be replayed exactly.
161 Returns True only if every iteration round-tripped exactly.
163 print(f
"=== Test: {n_iters} random payloads of length 1..255 ===")
165 rng = random.Random(RANDOM_SEED_CORRECTNESS)
166 for i
in range(n_iters):
167 length = rng.randint(1, 255)
168 msg = bytes(rng.randint(0, 255)
for _
in range(length))
169 recv = echo_one(fd, flags, msg, settle=0.3)
174 print(f
" FAIL #{i} L={length}: sent {msg.hex()[:40]}... recv {recv.hex()[:40]}...")
175 print(f
" -> {passes}/{passes + fails} pass")
179def _report_corruption(
180 recv: bytes | bytearray, payload: bytes, chunk_size: int, total_bytes: int
182 """Diagnose a failed throughput transfer by locating the first bad byte.
184 Prints the nearest chunk boundary alongside the offset, which is the
185 discriminating detail: corruption landing ON a boundary points at framing
186 or a FIFO edge, while corruption in the middle of a chunk points at the
189 A transfer that is merely SHORT -- every received byte correct, but too few
190 of them -- is reported as a short read instead, since there is no
191 mismatching byte to point at and the two failures have different causes.
193 n =
min(len(recv), len(payload))
194 first_bad = next((i
for i
in range(n)
if recv[i] != payload[i]),
None)
195 if first_bad
is not None:
196 print(f
" FIRST MISMATCH at offset {first_bad}:")
197 lo = max(0, first_bad - 8)
198 hi =
min(n, first_bad + 16)
199 print(f
" expected: {payload[lo:hi].hex()}")
200 print(f
" received: {bytes(recv[lo:hi]).hex()}")
201 print(f
" chunk_boundary nearest: {(first_bad // chunk_size) * chunk_size}")
202 elif len(recv) != total_bytes:
203 print(f
" short read: got {len(recv)}/{total_bytes}")
206def test_throughput(fd: int, flags: int, total_bytes: int, chunk_size: int, label: str) -> bool:
207 """Free-running write-and-read throughput measurement with integrity check.
209 Writes and reads interleaved without waiting for each chunk to echo, which
210 measures peak rate but can overrun the device IN FIFO for payloads past the
211 bulk max-packet size -- ``test_throughput_chunked`` is the flow-controlled
212 variant used for the pass/fail gate.
214 On mismatch it locates the FIRST differing byte and prints the surrounding
215 window plus the nearest chunk boundary, because a corruption that lands on
216 a boundary points at framing while one that does not points at the data
219 The reported KB/s is per direction; the aggregate figure alongside it is
220 doubled since every byte traverses the link twice.
222 Returns True only when the full payload came back byte-identical.
224 print(f
"=== Test: throughput {label} ({total_bytes}B in {chunk_size}B chunks) ===")
229 rng = random.Random(RANDOM_SEED_THROUGHPUT)
230 payload = bytes(rng.randint(0, 255)
for _
in range(total_bytes))
233 start = time.monotonic()
234 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
235 throughput_timeout_s = 30.0
237 while sent_off < total_bytes
or len(recv) < total_bytes:
238 if sent_off < total_bytes:
239 chunk = payload[sent_off : sent_off + chunk_size]
241 n = os.write(fd, chunk)
243 except BlockingIOError:
246 d = os.read(fd, 4096)
249 except BlockingIOError:
251 if time.monotonic() - start > throughput_timeout_s:
255 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
256 elapsed = time.monotonic() - start
257 bps = total_bytes / elapsed
if elapsed > 0
else 0
258 integrity = bytes(recv) == payload[: len(recv)]
and len(recv) == total_bytes
260 f
" sent {sent_off}B, recv {len(recv)}B in {elapsed:.2f}s "
261 f
"-> {bps / 1024:.1f} KB/s (each direction, {2 * bps / 1024:.1f} KB/s aggregate)"
264 _report_corruption(recv, payload, chunk_size, total_bytes)
265 print(f
" data integrity: {'OK' if integrity else 'FAIL'}")
269def test_throughput_chunked(
270 fd: int, flags: int, total_bytes: int, chunk_bytes: int, label: str
272 """Measure sustainable echo rate one chunk at a time, verifying as it goes.
274 Writes a chunk, waits for that chunk to echo back, verifies it, and only
275 then sends the next. The single-buffering is the point: a free-running
276 "write all, then read all" overruns the device-side IN FIFO for any
277 payload larger than the bulk-IN max-packet size, so it would measure the
278 overflow rather than the link.
280 Mismatch reporting is capped at a few chunks -- once framing has slipped
281 every subsequent chunk mismatches, and printing them all buries the first
282 and most informative one.
284 Returns True only when every chunk round-tripped exactly.
286 print(f
"=== Test: chunked throughput {label} ({total_bytes}B in {chunk_bytes}B chunks) ===")
288 rng = random.Random(RANDOM_SEED_CHUNKED)
289 payload = bytes(rng.randint(0, 255)
for _
in range(total_bytes))
290 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
291 start = time.monotonic()
294 while offset < total_bytes:
295 n =
min(chunk_bytes, total_bytes - offset)
296 sent = payload[offset : offset + n]
299 deadline = time.monotonic() + 2.0
300 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
302 while len(recv) < n
and time.monotonic() < deadline:
304 d = os.read(fd, 4096)
309 except BlockingIOError:
312 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
313 chunk_report_limit = 3
316 if chunk_failures <= chunk_report_limit:
317 sent_hex = sent.hex()[:40]
318 recv_hex = recv.hex()[:40]
319 print(f
" chunk@{offset} mismatch ({n}B): sent {sent_hex}... recv {recv_hex}...")
321 elapsed = time.monotonic() - start
322 bps = total_bytes / elapsed
if elapsed > 0
else 0
324 f
" {total_bytes}B round-trip in {elapsed:.3f}s -> {bps / 1024:.1f} KB/s "
325 f
"({2 * bps / 1024:.1f} KB/s aggregate); chunk failures = {chunk_failures}"
327 return chunk_failures == 0
330def find_cdc_device() -> str | None:
331 """Return /dev/ttyACMx that maps to a 1209:xxxx (pid.codes) device."""
332 dev_dir = Path(
"/dev")
333 for entry
in sorted(dev_dir.iterdir()):
334 if not entry.name.startswith(
"ttyACM"):
337 info = subprocess.check_output(
338 [
"udevadm",
"info", str(entry)],
341 except subprocess.CalledProcessError:
343 if "ID_VENDOR_ID=1209" in info:
348def main() -> NoReturn:
349 """Run the correctness and throughput suite against one CDC ACM device.
351 Never returns -- every path exits. Exit 2 means the device could not be
352 found at all (a rig fault, distinct from a test failure), exit 1 means at
353 least one test failed, exit 0 means all passed. The HIL suite depends on
354 that distinction to tell a broken bench from broken firmware.
356 ``--quick`` runs only a short length sweep and skips the random and
357 throughput passes; it is for iterating on enumeration, not for validating
360 Only the chunked throughput test contributes to the verdict. The
361 free-running one is available but not called here, since its FIFO overrun
362 makes it a diagnostic rather than a gate.
364 parser = argparse.ArgumentParser()
365 parser.add_argument(
"device", nargs=
"?", help=
"/dev/ttyACMx (auto-detected if omitted)")
366 parser.add_argument(
"--quick", action=
"store_true", help=
"only run lengths 1..32")
367 parser.add_argument(
"--throughput-bytes", type=int, default=4096)
368 parser.add_argument(
"--throughput-chunk", type=int, default=64)
369 args = parser.parse_args()
371 device = args.device
or find_cdc_device()
373 print(
"No /dev/ttyACMx with VID 1209 found; pass --device explicitly.")
375 print(f
"Benchmarking {device}")
377 fd, flags = open_raw(device)
382 ok = test_lengths(fd, flags, 32)
384 sys.exit(0
if ok
else 1)
386 r1 = test_lengths(fd, flags, 64)
388 r2 = test_random(fd, flags, 50)
390 r3 = test_throughput_chunked(fd, flags, args.throughput_bytes, args.throughput_chunk,
"echo")
393 print(f
" Test 1 (lengths 1..64) : {'PASS' if r1 else 'FAIL'}")
394 print(f
" Test 2 (50 random 1..255B) : {'PASS' if r2 else 'FAIL'}")
395 print(f
" Test 3 (chunked throughput) : {'PASS' if r3 else 'FAIL'}")
397 overall = r1
and r2
and r3
398 print(
"OVERALL: " + (
"ALL PASS" if overall
else "FAIL"))
400 sys.exit(0
if overall
else 1)
403if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.