TL;DR: In the last post an ESP32 became a genuine ROS 2 node over WiFi, talking Zenoh to the standard ROS 2 router with no micro-ROS agent. It now does the same down a plain USB cable. The router that ships with ROS 2 already speaks Zenoh’s serial protocol, so there’s still no agent, no bridge and no extra process – just one more line of router config. The link can be switched between WiFi and USB at run time from the REST API or the board’s own console, and the console comes back when you switch away.
If you want to add sensors to a system using ROS 2 then this is a super-quick and easy win.
Why a cable?
Raft has always treated WiFi, BLE and serial as interchangeable ways of reaching a device, so it was a little limiting that RaftROS only worked over WiFi. Plenty of robots have the microcontroller bolted next to a Raspberry Pi or a Jetson, joined by USB cables. And WiFi is the wrong answer surprisingly often. For instance in envionments where many devices fight over one access point and security is at the enterprise level.
That close-coupled case is where micro-ROS used to be the best answer. But it needs a serial agent – one per board, each a process on the host translating for its microcontroller. The whole point of RaftROS was not needing one of those, so going down a cable had to keep that property.
Setting down the project requirements
Here are my requirements at the start of this work:
- The stock ROS 2 Zenoh router (
rmw_zenohdfrom apt), with no agent, bridge or custom build. - The same Zenoh session code as over WiFi – services, parameters, hot-plug and all – with the change confined to the link underneath it.
- One cable for power, flashing and ROS. On an ESP32-S3 that means the chip’s built-in USB port.
- Chosen at run time, not baked into the build: the same image should run over WiFi or USB.
- The console must survive. You set up WiFi from it, and losing it to a binary protocol forever would be a poor trade.
- Nothing changes for anyone who never turns it on.
The router already speaks serial
The pleasant discovery was that Zenoh already defines a serial link (its “z-serial” protocol), and the router packaged with ROS 2’s rmw_zenoh is built with it. The Lyrical package (ros-lyrical-rmw-zenoh-cpp 0.10.6, straight from apt) has the serial link compiled into its Zenoh library, and a serial listen endpoint starts happily beside the TCP one. (The standalone zenohd release binaries, interestingly, don’t – version 1.10.1 answers a serial endpoint with “Unicast not supported for serial protocol”. If you want a standalone router on a cable you get to build it yourself with --features transport_serial.)
So the router side is one more entry in its config:
listen: { endpoints: ["tcp/[::]:7447",
"serial//dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_<MAC>-if00#baudrate=921600"] }
(Yes, two slashes: a Zenoh locator is serial/ followed by the address, and here the address is a path with its own leading slash.) Keep the TCP endpoint – the ros2 command line tools and Foxglove still reach the router that way. And use the /dev/serial/by-id/ name rather than /dev/ttyACM1: the board re-enumerates every time it resets, and the by-id name follows it.

A frame is a frame
The key thing to remember about a serial link is that it has no idea where one message ends and the next begins – it’s just a stream of bytes, and every protocol that uses one has to invent its own framing. Zenoh’s is pleasingly simple (and it just happens to be very close to Raft’s way of doing serial too). Each Zenoh batch – exactly what would have gone down the TCP connection, minus TCP’s 2-byte length prefix – becomes one frame:
| Field | Size | Notes |
|---|---|---|
| header | 1 byte | init, ack, reset flags; 0 for data |
| length | 2 bytes | little-endian |
| payload | up to 1500 bytes | one Zenoh batch |
| CRC-32 | 4 bytes | over the payload |
The whole thing is COBS-encoded, which removes every zero byte from the frame so that a single zero can mark its end. Lose your place in the stream and you resynchronise at the next zero.
The CRC deserves a footnote. It isn’t the zlib one that raft uses natively: it uses the reflected table algorithm with the unreflected polynomial, which is the kind of thing that works perfectly on both ends of a link built by the same people and on nothing else.
Before anything else, the device sends an empty frame flagged init until the router answers init + ack. After that, the INIT/OPEN handshake, the lease, the declarations, the services and the parameters are byte-for-byte what they are over TCP.

There’s one real difference from TCP. A serial link is a datagram link as far as Zenoh is concerned: each frame is a whole batch, with no length prefix to strip. So on the ESP32 the change sits entirely under the existing session – about 400 lines for the framing and the handshake, plus datagrams feeding batches into the session and taking them out. Nothing above it knows which link it’s on.
The test bench
For the test bench I used an Unexpected Maker FeatherS3, which has two Qwiic connectors, with an APDS-9960 (light, colour and proximity) on one and an MPU6050 IMU on the other. Neither sensor was in Raft’s device table yet, so each got a short JSON record – which is rather the point of I2C auto-identification: a new sensor is a few lines of description, not a driver. Plug them in and /raft/imu_1_68, /raft/illuminance_1_39 and /raft/proximity_1_39 appear on their own.
The board’s second connector gets its power from a regulator switched by a GPIO, and that turned into configuration too – that is the Raft way after all!
With no USB-serial adapter to hand, the link uses the ESP32-S3’s built-in USB port (its USB-Serial-JTAG peripheral), the same one used for flashing. One cable for power, programming and ROS. And nothing else.
And I mean nothing
The router’s serial link treats any frame it can’t decode as fatal and drops the session. That’s a perfectly reasonable choice for a link that USB already CRC-checks – but it means a single log line on the port is enough to knock the node off the ROS graph. And a Raft image writes to its console from a number of places:
| Who writes | How it gets there |
|---|---|
| Raft’s own logging | straight to the USB driver |
| ESP-IDF’s logging (WiFi, MQTT, the web server…) | a log hook that Raft’s console installs |
| Console replies | the console SysMod |
printf, stdout, stderr | the C library, through the USB driver |
| The ROM’s print routine (often from interrupt handlers) | straight to the hardware |
And the console reads the port too, so it would happily eat the router’s frames as mistyped commands.
My first attempt was to move the console to the UART pins at build time. It worked, but it meant the board could only ever be one thing, and setting up WiFi needed a second cable – which rather spoiled the “one cable” story. The better answer was to share. The console stays on the USB port as normal – you can set up WiFi from it and type commands at it – and when the serial link is switched on, RaftROS takes the port over:

Raft’s console lets go of the port, and a small new piece of RaftCore, RaftConsolePort, silences everything else that could write there: Raft’s logging, ESP-IDF’s logging, stdout (by pointing it at /dev/null, which covers every task at once) and the ROM’s own print routine. Switch back to TCP and each one is put back the way it was. Logging to other places – the network loggers, for instance – carries on regardless; only the console write is diverted.
There’s an escape hatch, too. While the link holds the port it also watches the incoming bytes for one thing a human might type: a line starting ros/set? or ros/clear. So with the router stopped, a terminal on the port – showing a screenful of binary init frames, admittedly – can always get the console back:
ros/set?link=tcp
Having found in the first post that the most expensive thing my ROS node does is tell me what it’s doing, I now had a version that couldn’t tell me anything at all. Which made debugging interesting!
Switching at run time
The link is just another RaftROS setting, layered like the rest: a default in the SysTypes config, overridable at run time and optionally saved to flash.
curl 'http://<device-ip>/api/ros/set?link=usbjtag&persist=1' # to the cable, kept across reboots
curl 'http://<device-ip>/api/ros/set?link=tcp&persist=1' # back to WiFi
curl 'http://<device-ip>/api/ros' # which link, and whether serial is available
The same requests can be typed at the console *without the api/ prefix), so a board with no WiFi at all can be moved onto the cable from its own console (ros/set?link=usbjtag). The change applies on the next loop pass: the live session closes and the node reconnects over the other link. Over HTTP, the node was ready on the cable in about 2 seconds, and back on WiFi in about 7.
Serial support is built in by default but costs nothing (save a small amount of app size) until it’s used: its 4.5 kB of buffers are only allocated the first time the link is chosen. On a board whose console is on a UART, nothing is claimed at all. And on a build with older Raft libraries that can’t share the port, RaftROS refuses the link and says why in /api/ros, rather than silently corrupting the console.
Debugging without a console
For debugging I used a small Python bridge in the RaftROS repository (tools/zenoh_serial_link.py bridge). It sits between the board and an ordinary TCP router, relays the frames, and prints anything that isn’t a frame. A setting (linkLogs=1) slips the board’s log lines onto the link between frames for it to show. A real router would drop the link on the first one, which is why this is strictly a debugging tool.
The tool earned its keep immediately. The node did connect, but only after several seconds of the link coming up and going down again. Until something on the host opens the USB port, whatever the ESP32 writes waits in its transmit buffer – and I was writing an init frame every quarter of a second. By the time the router opened the port there were dozens queued, and it dutifully answered them in turn: ack to the first, then (on seeing another init on a link it had just set up) reset, then ack, then reset… The fix is one condition – don’t send another init until the last one has actually left the buffer – and the node now appears within a couple of seconds of the router opening the port.
Measured
All of these are with the stock router from apt (ROS 2 Lyrical in a container), the FeatherS3 and its two sensors, and the board logging normally to its (silenced) console:
| Over USB serial | |
|---|---|
IMU (/raft/imu_1_68, 20 Hz poll) | 18.7 Hz |
| Light and proximity (10 Hz poll) | 9.6 Hz |
| Parameters | param set chatterPeriodMs 250 took /chatter from 1 to 4 Hz |
| 10-minute soak, a service call every 5 s | 115 of 115 answered, free heap unchanged (126.7 kB) |
| 3 minutes of parameter sets and service calls | 423 of 423 answered, no serial errors at the router |
| After switching over HTTP | 285 of 285 calls answered in 2 minutes, no CRC errors |
| Worst loop pass | 4.5 ms over serial; 4.0 ms over WiFi |
| Router restarted | node back about 1 s after the router listens again |
| Board reset before the router started | node in the graph 1.6 s after the router starts |
| Switch WiFi → USB / USB → WiFi | ~2 s / ~7 s |
The IMU’s shortfall from 20 Hz is the same as over WiFi: it’s actually the poll scheduling that’s at fault (I really must get around to fixing that one day!), not the link.
The bits that bit me
In the tradition of owning up to my mistakes:
- The init burst. Described above: a link that came up, went down and came up again for several seconds, because the router was answering a queue of stale init frames in turn.
- A batch too big. Through the TCP bridge the router negotiated a 4096-byte batch – larger than a serial frame can carry. The session now offers the batch size its link can take, and the serial link offers 1500.
- Reset by opening a port. Python scripts tend to reset ESP32 boards every time they open a port, because the default python serial connection sets the DTR and RTS lines as it opens, and the ESP32’s USB port reads that as a request to reset. RaftCLI doesn’t do this so the solution was simply to use
raft monitorinstead; Note also that esptool pulses them on purpose to generate resets. - Two buses, one controller. The FeatherS3’s two Qwiic connectors have separate power sources and leaving only one powered resulted in some phantom-power issues on the common SCL/SDA lines resulting in a lot of spurious I2C noise.
- Gravity, divided by 8192. Not a serial bug at all, but found on this bench. Raft’s generated decoders applied each attribute’s divisor while decoding, and also passed it on to whoever read the result, so anything that applied it again – the ROS publisher among them – scaled the value twice. Written as a plain record, the MPU6050 would have reported Earth’s gravity as roughly a ten-thousandth of a g. The decoders now pass on only what’s still to be applied, and the MPU6050 record is a plain one again.
Loose ends
A few things are still on the to-do list. I haven’t tried pressing reset while the router is holding the port, or run the 12-hour soak that the WiFi build got. Only the ESP32-S3’s built-in USB port has been tested: a UART with a USB adapter should work the same way, but “should” is doing some work in that sentence. The built-in port also ignores the baud rate, so I don’t yet know where the throughput limit is. And BLE, sadly, isn’t an option – Zenoh has no BLE link at all.
Conclusions
It works: the same agent-free ROS 2 node goes down a USB cable to the same stock router, with services, parameters and hot-plug intact, and it can hop between WiFi and the cable without a rebuild. The protocol part was the easy bit – the Zenoh folk had already done it, and done it neatly. The fiddly part was persuading a whole firmware image to stop talking, and then persuading it to start again.
Is a cable less exciting than WiFi? Absolutely. Is it what most robots I’ve built would actually use? Also absolutely.
As with the first post, this was written with AI assistance: Claude did most of the code and the host tests, and I set the goals, made the design calls and kept the board and sensors on my desk.
Code
- RaftROS – the serial link is in
components/RaftROS/Zenoh/, andexamples/ExampleDiscoverablehas the FeatherS3 configuration (SysTypeFeatherS3) - RaftCore –
RaftConsolePort, and the RaftROS Serial Link wiki page - RaftSysMods – the console’s release and reclaim
- RaftI2C – bus power on plain GPIOs
- The first post: Agent-free ROS 2 on an ESP32

