Proof-of-concept for bidirectional communication between a Mac laptop and a VEX V5 brain, over either:
- Brain USB direct — laptop → brain user port
- Controller USB tether — laptop → controller → VEXnet → brain
The brain runs one PROS program for both paths. The host CLI uses different serial protocols depending on which USB device is connected.
VEX exposes different USB serial interfaces depending on what you plug in. This is the main reason the host CLI has two code paths.
A V5 brain presents two CDC serial ports over one USB cable:
| Port role | Purpose | Use with this POC? |
|---|---|---|
| System port | PROS upload, V5 protocol, file transfer | No — use for pros upload only |
| User port | Program stdin/stdout (debug terminal) | Yes — usb_test.py brain-direct mode |
On macOS, port names are usually /dev/cu.usbmodemNNN. The user port often ends in 3 (e.g. …103); the system port is the other number (e.g. …101). Linux may show /dev/ttyACM0 and /dev/ttyACM1 instead — check descriptions with --list-ports.
Example with only the brain connected over USB:
$ python usb_test.py --list-ports
Available serial ports:
/dev/cu.usbmodem101 [brain_system]
description : VEX V5 Brain
manufacturer: VEX Robotics
product : VEX V5 Brain
/dev/cu.usbmodem103 [brain_user]
description : VEX V5 Brain
manufacturer: VEX Robotics
product : VEX V5 Brain
Run the client on the user port:
python usb_test.py --port /dev/cu.usbmodem103Data flow:
Host ──USB──► Brain user port ──► program stdin/stdout (COBS, sout topic)
(fast, stable)
The system port (…101) is for PROS tooling (pros upload, system commands). This POC rejects it if you pass it by mistake.
A V5 controller presents one CDC serial port — the system port only. There is no separate user/debug port on the controller.
The controller must be paired to the brain over VEXnet (or tethered). Program I/O does not stay on the controller; it is forwarded to the brain over the radio link using UserFifo packets (CDC2 ext command 0x27).
Example with only the controller connected over USB (brain linked via VEXnet, not USB):
$ python usb_test.py --list-ports
Available serial ports:
/dev/cu.usbmodem102 [controller]
description : VEX V5 Controller
manufacturer: VEX Robotics
product : VEX V5 Controller
Run the client on that port:
python usb_test.py --port /dev/cu.usbmodem102Data flow:
Host ──USB──► Controller system port ──VEXnet──► Brain ──► program stdin/stdout
(slower; UserFifo protocol; download radio channel)
| Brain USB direct | Controller USB tether | |
|---|---|---|
| USB devices shown | 2 (system + user) | 1 (system only) |
| Port to use | User port (often …103) |
Controller port (e.g. …102) |
| Path to brain program | Direct | Controller → VEXnet → brain |
| Host protocol | PROS stream on user port | V5 system port + UserFifo 0x27 |
| Speed / stability | Best | Slower; sensitive to VEXnet link |
| Brain USB required? | Yes | No (brain can be wireless) |
| Controller paired? | Optional (for KEY: lines) |
Required |
If the brain and controller are both on USB, --list-ports may show all three:
Available serial ports:
/dev/cu.usbmodem101 [brain_system]
description : VEX V5 Brain
...
/dev/cu.usbmodem102 [controller]
description : VEX V5 Controller
...
/dev/cu.usbmodem103 [brain_user]
description : VEX V5 Brain
...
Pick one path explicitly with --port:
- Brain direct:
--port /dev/cu.usbmodem103 - Controller tether:
--port /dev/cu.usbmodem102
Only one process can open a given port. Do not run usb_test.py and PROS terminal on the same port at the same time.
Note: Port numbers (101, 102, 103) are examples from test hardware. Yours may differ — always use --list-ports and the [brain_user] / [controller] labels to choose.
- Send text from the laptop to the brain (
stdin) - Read text from the brain (
stdout): echo, controller key presses, link status - Understand the V5 UserFifo protocol well enough for reliable controller-tether I/O
- Document protocol pitfalls for VEX / PROS engineers
test-usb/
├── src/main.cpp # Brain program (PROS 4.2.1)
├── include/main.h
├── usb_test_cli/
│ ├── usb_test.py # Host-side test client
│ └── requirements.txt
└── README.md
Minimal PROS project (no drivetrain):
serial_task: readsstdinviagetchar(), echoes tostdout, shows received line on LCD line 2 (RX: …)opcontrol: polls master controller; printsKEY:<name>on button press andCTRL:connected|disconnectedon link changes- Uses default PROS stream multiplexing (COBS +
souttopic on stdout)
LCD lines:
| Line | Content |
|---|---|
| 0 | USB Echo POC |
| 1 | Controller link status |
| 2 | Last received message (RX: …) |
| 4 | Last key pressed |
Optional stdin guards in main.cpp (for controller tether artifacts):
- Ignore
@on stdin (see Protocol findings) - Reset RX buffer when
Hfollows junk containing"or digits
usb_test_cli/usb_test.py auto-detects the port type (see Brain USB vs controller USB) and picks a code path.
| Port (macOS example) | [kind] label |
Handler |
|---|---|---|
/dev/cu.usbmodem103 |
brain_user |
PROS V5UserDevice on user port |
/dev/cu.usbmodem102 |
controller |
Custom UserFifo (0x27) over download radio channel |
/dev/cu.usbmodem101 |
brain_system |
Rejected — use user port for this POC |
# Brain firmware
pros upload
# Host CLI
cd usb_test_cli
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtPROS 4.2.1 requires GCC 14 (arm-gcc-bin@14 on Homebrew). Ensure pros --version and arm-none-eabi-g++ --version work before building.
cd usb_test_cli
source .venv/bin/activate
# List ports
python usb_test.py --list-ports
# Brain USB direct (recommended for development)
python usb_test.py --port /dev/cu.usbmodem103
# Controller tether (VEXnet)
python usb_test.py --port /dev/cu.usbmodem102
# Return controller to pit radio channel on exit (may drop VEXnet link)
python usb_test.py --port /dev/cu.usbmodem102 --restore-pitOn start the client sends Hello World\n and then prints brain stdout until Ctrl+C.
Expected output examples:
Hello World
KEY:A
CTRL:connected
--mode |
Description |
|---|---|
auto (default) |
Pick handler from port type |
pros |
Force PROS/V5 protocol |
raw |
Raw serial (brain user port only; requires COBS disabled on brain) |
Only one process may open a given serial port at a time. Close PROS terminal or other tools before running usb_test.py.
Laptop ──USB──► Brain user port (/dev/cu.usbmodem…3)
└── PROS stream device (COBS, sout/serr topics)
- Fast, stable
V5UserDeviceread/write maps cleanly to program stdin/stdout- Same code path PROS terminal uses for brain user port
Laptop ──USB──► Controller system port (/dev/cu.usbmodem…2)
└── VEXnet ──► Brain
UserFifo CDC2 ext command 0x27
- Single USB serial port on the controller (system port only)
- Stdio must go through UserFifo packets, not the user debug port
- Requires download radio channel for usable throughput
- Much slower than brain-direct USB
See UserFifo + VEXnet (controller tether) for a full explanation.
flowchart LR
subgraph direct [Brain direct]
L1[Laptop] -->|USB user port| B1[Brain]
end
subgraph tether [Controller tether]
L2[Laptop] -->|USB system port| C[Controller]
C -->|VEXnet| B2[Brain]
end
The brain program (src/main.cpp) is identical for both paths — it only uses getchar() / printf(). It does not detect how the host is connected. All controller-tether complexity lives in the host CLI (usb_test_cli/usb_test.py).
This section explains what happens when USB goes to the controller instead of the brain user port. See Protocol findings below for byte-level details and bugs discovered during testing.
Host (usb_test.py)
│ USB serial — V5 system protocol (CDC / CDC2 packets)
▼
Controller
│ VEXnet radio link
▼
Brain
│ PROS routes UserFifo data ↔ running program stdin/stdout
▼
Your program (getchar / printf)
The host never opens the brain’s user/debug port. Program I/O is forwarded over VEXnet using the UserFifo mechanism on the controller’s system port.
The controller and brain stay paired over VEXnet (wireless). For this POC the controller must be linked to the brain; the brain does not need its own USB cable.
| Concept | Role |
|---|---|
| Link state | Controller must be connected to brain (CTRL:connected in program output, or PROS ControllerFlags.CONNECTED) |
| Pit channel | Normal VEXnet mode — everyday radio use |
| Download channel | Higher-bandwidth mode PROS uses for upload/terminal over controller |
UserFifo I/O over controller USB is practical only on the download VEXnet channel. PROS switches with ft_transfer_channel('download') (CDC2 ext 0x10). That switch can briefly disturb the link — the CLI avoids switching back to pit on every exit for stability.
On brain user USB, stdin/stdout use a dedicated serial pipe. On controller USB there is only the system port — no separate debug terminal. Program stdio is one service inside the V5 protocol, exposed as UserFifo (CDC2 extended command 0x27).
The same command handles read and write; the payload determines behavior.
Packet shape (host → device):
| Byte | Read (stdout) | Write (stdin) |
|---|---|---|
| 0 | Channel (1 = stdio read side) | Channel (2 per spec; 1 on tested hardware) |
| 1 | Read length | Payload length |
| 2+ | (none) | Payload bytes |
The host wraps this in a V5 CDC2 packet (0x56 + ext id 0x27). The device responds with stdout bytes (reads) or ACK/NACK (writes).
Read stdout — host polls the brain for buffered program output:
Host sends: [channel=1][read_len=0]
Brain returns: COBS-framed data (sout topic + payload)
Each poll is a round-trip over VEXnet, so controller tether is much slower than brain user USB. The host accumulates bytes until a COBS frame ends with \0, then decodes:
COBS frame → "sout" (4 bytes) + payload (e.g. KEY:A, Hello World)
Write stdin — host sends bytes to the program:
Host sends: [channel=1][len=N][N bytes] (works on tested hardware)
[channel=2][len=N][data…] (spec-correct; NACK on tested hardware)
Bytes reach brain stdin → getchar() in the brain program.
Read stdout:
- Host opens controller system port (e.g.
/dev/cu.usbmodem102) - If needed, switch to download VEXnet channel
- Send UserFifo read:
[1][0] - Controller forwards request over VEXnet to brain
- Brain returns buffered stdout in the response
- Response travels VEXnet → controller → USB → host
- Host COBS-decodes
soutframes and prints
Write stdin (then see echo):
- Host sends UserFifo write:
[1][len][Hello World\n] - Controller → VEXnet → brain → program stdin
- Brain echoes to stdout (same
main.cppas brain-direct path) - Host must poll (read cycle above) to receive the echo
Controller tether stacks several layers; brain-direct user USB only needs the last one:
| Layer | What it is | Brain user USB | Controller tether |
|---|---|---|---|
| 1 | VEXnet radio channel (pit vs download) | Not used | Required for UserFifo I/O |
| 2 | UserFifo channel byte (read stdout vs write stdin) | Not used | Required |
| 3 | COBS stream topics (sout / serr) |
Yes | Yes (inside UserFifo payload) |
This is why the brain-direct client approach fails on the controller port: you are on a system-only path that multiplexes many services, with stdio as one of them — not a dedicated stdin/stdout pipe.
run_controller_session() in usb_test.py:
_controller_download_channel— switch to download VEXnet channel if needed; stay on it on exit_controller_user_fifo_read—[ch1][read_len=0](not PROS-cli’s0x40)_controller_user_fifo_write—ch1-lenwith fallbacks; retries on timeout_controller_read_sout— buffer bytes, COBS decode, acceptsoutframes- Link waits — do not write until controller reports connected to brain
| Brain user USB | Controller USB | |
|---|---|---|
| USB interfaces | 2 (system + user) | 1 (system only) |
| Stdio transport | Direct serial to program | UserFifo 0x27 over system port |
| VEXnet in path? | No | Yes |
| Download channel switch? | No | Yes (for reliable I/O) |
| Host implementation | V5UserDevice |
Custom UserFifo + COBS parser |
These were the main discoveries from reverse-engineering PROS-cli, vex-v5-serial, and testing on real hardware. For background on UserFifo and VEXnet, see the section above.
One command handles both stdin write and stdout read. Layout:
| Byte | Read (stdout) | Write (stdin) |
|---|---|---|
| 0 | Channel (1 = download/stdio) | Channel (2 per spec; 1 on our hardware) |
| 1 | Read length (see below) | Payload length |
| 2+ | (none) | Payload bytes |
Read (stdout) — vex-v5-serial uses [channel=1][read_len=0].
PROS-cli user_fifo_read() uses [channel=1][read_len=0x40]. On our hardware 0x40 leaked into brain stdin as @ (ASCII 0x40). Each read poll in the main loop re-injected that byte, which:
- Corrupted LCD (
RX: @instead ofRX: Hello World) - Polluted stdin → polluted echo → repeated garbage on stdout
Fix: custom read with read_len=0, not 0x40.
Write (stdin) — vex-v5-serial uses channel 2 with length prefix. Our controller NACKs channel 2; working formats:
| Format | Packet | Notes |
|---|---|---|
ch1-len |
[01][len][data…] |
Works on tested controller |
ch1-read0 |
[01][00][data…] |
PROS-cli intended format (disabled in pros-cli) |
ch2-len |
[02][len][data…] |
Spec-correct; NACK on tested hardware |
PROS-cli user_fifo_write() is disabled (early return); this POC implements it.
Brain stdout is COBS-framed with a 4-byte topic prefix:
sout— program stdout (what we read)serr— stderr
Controller path: accumulate bytes until \0, COBS-decode, accept only sout frames.
UserFifo I/O over controller tether needs the download VEXnet channel (high bandwidth). PROS upload/terminal switches with ft_transfer_channel('download').
Stability note: switching pit → download on every start and download → pit on every exit often drops the VEXnet link. This client:
- Probes whether UserFifo already works (already on download from a prior run)
- Switches to download only when needed
- Leaves the controller on download channel by default on exit
- Offers
--restore-pitonly when pit channel is needed (e.g. some PROS upload flows)
Symptom: LCD showed RX: @; repeated @; garbled echo.
Cause: PROS user_fifo_read() sends read_len=0x40; that byte appeared on program stdin.
Fix: [channel=1][read_len=0] per vex-v5-serial.
Symptom: Lines like 7s0qgOshYmN"LHello World every few seconds.
Cause: Polluted stdin was echoed to stdout; controller fifo re-delivered the same buffer on each read poll.
Fix: Correct read format (above) broke the pollution loop.
Symptom: Partial messages (Helo World), mux noise before payload.
Cause: Writing on channel 1 (stdout/read side) mixed COBS/sout framing into stdin.
Fix: Prefer working write format (ch1-len on tested hardware); wait for VEXnet link before writing.
Symptom: Garbage even when brain output was clean.
Cause: Manual COBS parsing mis-aligned frames; PROS V5UserDevice treats failed decode as valid topic bytes.
Fix: Custom parser: split on \0, skip decode errors, require exact sout topic.
Symptom: Controller lost brain link on start/stop; serial timeouts / crashes.
Cause: Repeated pit ↔ download transitions.
Fix: Stay on download between runs; probe before switching; retry writes; catch OSError; optional --restore-pit.
Symptom: Intermittent timeouts, access denied.
Cause: Only one process may open a port; PROS terminal and usb_test.py conflict on the same /dev/cu.usbmodem*.
Fix: Close other serial tools; brain system vs user ports are separate interfaces.
| Path | Send | Receive | Stability |
|---|---|---|---|
| Brain USB direct | ✅ | ✅ | Excellent |
| Controller tether | ✅ (ch1-len) |
✅ | Good after protocol fixes; slower; sensitive to link state |
With fixes applied and filtering removed from the host client, stdout shows raw sout data — no garbage observed on tested hardware.
- PROS-cli —
v5_device.py(user_fifo_read/user_fifo_write),v5_wireless_port.py,terminal.py - vex-v5-serial — UserDataPacket read/write channel semantics
- PROS stream multiplexing / serctl — COBS and
sout/serrtopics
POC / research code for sharing with VEX engineering. PROS kernel and tooling remain under their respective licenses.