ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
cdc_echo_test.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""USB CDC ACM round-trip echo verifier for the EK-RA8D2 USB demos.
5
6Verification contract:
7 Open the target tty in raw mode using a non-blocking POSIX file descriptor
8 (NOT pyserial's Serial wrapper -- pyserial toggles DTR which can hang on
9 the USB bridge during enumeration), write a fixed payload, then read back
10 for ``--timeout`` seconds. The round-trip succeeds iff the bytes read
11 exactly equal the bytes written.
12
13Exit status:
14 0 -- payload round-tripped intact
15 1 -- no data received within --timeout seconds
16 2 -- data received but does not match the payload
17 3 -- usage / configuration / I/O error (no device, etc.)
18
19Example:
20 python3 scripts/hil/usb/cdc_echo_test.py --tty /dev/cu.usbmodem000000011
21
22Auto-detect example (FS demo):
23 python3 scripts/hil/usb/cdc_echo_test.py --auto fs
24"""
25
26from __future__ import annotations
27
28import argparse
29import contextlib
30import errno
31import os
32import select
33import subprocess
34import sys
35import termios
36import time
37from pathlib import Path
38
39# Default payload chosen to be ASCII-only and short enough to fit in a single
40# CDC bulk-OUT packet on FS (max 64 B) and HS (max 512 B).
41DEFAULT_PAYLOAD = b"PING-RA8D2"
42DEFAULT_TIMEOUT_S = 4.0
43
44# Per-speed serial-number suffix programmed in the device descriptor.
45# FS firmware reports iSerial "00000001"; HS reports "00000002".
46SERIAL_FS = "00000001"
47SERIAL_HS = "00000002"
48
49PRODUCT_SUBSTR_FS = "EK-RA8D2 CDC Echo"
50PRODUCT_SUBSTR_HS = "EK-RA8D2 HS CDC Echo"
51
52
53def _ioreg_lookup(product_substr: str) -> str | None:
54 """Return the /dev/cu.usbmodem* path whose USB Product string matches.
55
56 Uses macOS ioreg to map the USB product description back to the BSD
57 device node. Returns None if no matching device is found or ioreg is
58 unavailable (e.g. on Linux).
59 """
60 try:
61 out = subprocess.check_output(
62 ["ioreg", "-p", "IOUSB", "-l", "-w", "0"], # noqa: S607 # trusted: fixed ioreg argv
63 stderr=subprocess.DEVNULL,
64 timeout=5,
65 ).decode("utf-8", errors="replace")
66 except (FileNotFoundError, subprocess.SubprocessError):
67 return None
68
69 # Find every block that mentions the product substring; within each, look
70 # for the BSD name. ioreg output is hierarchical; a coarse line scan is
71 # sufficient because Product/BSD Name appear close together.
72 lines = out.splitlines()
73 for idx, line in enumerate(lines):
74 if product_substr in line:
75 window = lines[max(0, idx - 40) : idx + 40]
76 for w in window:
77 if "IOCalloutDevice" in w and "/dev/cu.usbmodem" in w:
78 # Extract the path between quotes.
79 start = w.find('"/dev/cu.')
80 if start == -1:
81 continue
82 end = w.find('"', start + 1)
83 if end == -1:
84 continue
85 return w[start + 1 : end]
86 return None
87
88
89def auto_detect(speed: str) -> str | None:
90 """Resolve the EK-RA8D2 tty for the given speed ('fs' or 'hs').
91
92 Strategy:
93 1. ioreg lookup by USB Product string (most reliable).
94 2. Fallback: scan /dev/cu.usbmodem* and pick the one whose serial
95 suffix matches the speed-specific iSerial pattern.
96 """
97 if speed == "fs":
98 prod, serial = PRODUCT_SUBSTR_FS, SERIAL_FS
99 elif speed == "hs":
100 prod, serial = PRODUCT_SUBSTR_HS, SERIAL_HS
101 else:
102 return None
103
104 path = _ioreg_lookup(prod)
105 if path:
106 return path
107
108 # Fallback: serial-suffix match. Apple-style nodes look like
109 # /dev/cu.usbmodem000000011 (FS) or .../000000021 (HS), where the trailing
110 # digit is the interface index appended by the kernel.
111 for cand in sorted(Path("/dev").glob("cu.usbmodem*")):
112 if serial in cand.name:
113 return str(cand)
114 return None
115
116
117def _open_raw(tty_path: str) -> int:
118 """Open the tty as a raw, non-blocking fd suitable for binary I/O."""
119 fd = os.open(tty_path, os.O_RDWR | os.O_NONBLOCK | os.O_NOCTTY)
120 # Configure as 8N1 raw: drop ICANON/ECHO/ISIG/etc so the kernel does not
121 # cook the byte stream. CDC ACM ignores baud anyway.
122 attrs = termios.tcgetattr(fd)
123 iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs
124 iflag = 0
125 oflag = 0
126 lflag = 0
127 cflag = (cflag & ~termios.CSIZE) | termios.CS8 | termios.CREAD | termios.CLOCAL
128 cc[termios.VMIN] = 0
129 cc[termios.VTIME] = 0
130 termios.tcsetattr(fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
131 return fd
132
133
134def _drain_stale(fd: int, window_s: float = 0.2) -> None:
135 """Discard anything already in the RX buffer.
136
137 Leftover boot chatter would otherwise contaminate the echo comparison.
138 """
139 deadline = time.monotonic() + window_s
140 while time.monotonic() < deadline:
141 r, _, _ = select.select([fd], [], [], 0.05)
142 if not r:
143 return
144 try:
145 _ = os.read(fd, 4096)
146 except OSError as exc:
147 if exc.errno not in (errno.EAGAIN, errno.EWOULDBLOCK):
148 raise
149
150
151def _write_all(fd: int, payload: bytes) -> None:
152 """Write the whole payload, retrying on a would-block."""
153 written = 0
154 while written < len(payload):
155 try:
156 n = os.write(fd, payload[written:])
157 if n <= 0:
158 return
159 written += n
160 except OSError as exc:
161 if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
162 time.sleep(0.01)
163 continue
164 raise
165
166
167def _read_until(fd: int, want: int, timeout_s: float) -> bytes:
168 """Read until ``want`` bytes have arrived or ``timeout_s`` expires."""
169 rx = bytearray()
170 deadline = time.monotonic() + timeout_s
171 while len(rx) < want and time.monotonic() < deadline:
172 remaining = deadline - time.monotonic()
173 r, _, _ = select.select([fd], [], [], min(0.25, max(0.0, remaining)))
174 if not r:
175 continue
176 try:
177 chunk = os.read(fd, 4096)
178 except OSError as exc:
179 if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
180 continue
181 raise
182 if chunk:
183 rx.extend(chunk)
184 return bytes(rx)
185
186
187def round_trip(tty_path: str, payload: bytes, timeout_s: float) -> tuple[int, bytes]:
188 """Write payload, read back for timeout_s, return (exit_code, received).
189
190 The function does not raise on I/O errors -- it converts them into the
191 documented exit codes so the CLI surface stays predictable.
192 """
193 fd = _open_raw(tty_path)
194 try:
195 _drain_stale(fd)
196 _write_all(fd, payload)
197 received = _read_until(fd, len(payload), timeout_s)
198 finally:
199 with contextlib.suppress(OSError):
200 os.close(fd)
201
202 if not received:
203 return 1, received
204 if received[: len(payload)] != payload:
205 return 2, received
206 return 0, received
207
208
209def _build_parser() -> argparse.ArgumentParser:
210 """Build the command-line parser for this HIL probe."""
211 parser = argparse.ArgumentParser(
212 description="Round-trip echo test for the EK-RA8D2 USB CDC demos.",
213 )
214 parser.add_argument(
215 "--tty",
216 help="Path to the CDC tty (e.g. /dev/cu.usbmodem000000011).",
217 )
218 parser.add_argument(
219 "--auto",
220 choices=("fs", "hs"),
221 help="Auto-detect the EK-RA8D2 tty for the given USB speed.",
222 )
223 parser.add_argument(
224 "--payload",
225 default=DEFAULT_PAYLOAD.decode("ascii"),
226 help="ASCII payload to send (default: %(default)s).",
227 )
228 parser.add_argument(
229 "--timeout",
230 type=float,
231 default=DEFAULT_TIMEOUT_S,
232 help="Seconds to wait for the echo (default: %(default)s).",
233 )
234 return parser
235
236
237def _resolve_tty(args: argparse.Namespace) -> str | None:
238 """Resolve the tty to test, or None (having said why) if it cannot be.
239
240 Every failure here is a RIG problem, not a firmware one, which is why the
241 caller maps them all to exit 3 rather than to a test failure.
242 """
243 tty_path = args.tty
244 if not tty_path and args.auto:
245 tty_path = auto_detect(args.auto)
246 if not tty_path:
247 print(f"ERROR: could not auto-detect EK-RA8D2 {args.auto.upper()} tty", file=sys.stderr)
248 return None
249 print(f"auto-detected: {tty_path}")
250 if not tty_path:
251 print("ERROR: must pass --tty or --auto {fs,hs}", file=sys.stderr)
252 return None
253 if not Path(tty_path).exists():
254 print(f"ERROR: tty does not exist: {tty_path}", file=sys.stderr)
255 return None
256 return tty_path
257
258
259def main(argv: list[str]) -> int:
260 """Echo-test one CDC tty and map the outcome onto a three-way exit code.
261
262 The exit codes are the interface the HIL suite consumes, and they separate
263 the two failures that get confused: 3 means the test could not RUN (no
264 tty given, auto-detect failed, path absent, non-ASCII payload, I/O error)
265 while 1 and 2 mean it ran and the device failed it -- 1 for silence within
266 the timeout, 2 for data that came back different. A rig problem therefore
267 never reads as a firmware problem.
268
269 Returns 0 on an exact round-trip, 1 on timeout, 2 on mismatch, 3 on any
270 setup failure.
271 """
272 args = _build_parser().parse_args(argv)
273
274 tty_path = _resolve_tty(args)
275 if tty_path is None:
276 return 3
277
278 try:
279 payload_bytes = args.payload.encode("ascii")
280 except UnicodeEncodeError:
281 print("ERROR: payload must be ASCII-only", file=sys.stderr)
282 return 3
283
284 try:
285 code, received = round_trip(tty_path, payload_bytes, args.timeout)
286 except OSError as exc:
287 print(f"ERROR: I/O failure on {tty_path}: {exc}", file=sys.stderr)
288 return 3
289
290 if code == 0:
291 print(f"OK: round-tripped {len(payload_bytes)} bytes")
292 return 0
293 if code == 1:
294 print(
295 f"FAIL: no data received within {args.timeout:.1f}s on {tty_path}",
296 file=sys.stderr,
297 )
298 return 1
299 print(
300 f"FAIL: data mismatch on {tty_path}\n"
301 f" expected: {payload_bytes!r}\n"
302 f" received: {received!r}",
303 file=sys.stderr,
304 )
305 return 2
306
307
308if __name__ == "__main__":
309 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157