Skip to content

ESP32-C6 OpenThread RCP on Jetson Orin Nano - Project Guide

Goal: Bring up a second ESP32-C6 as an OpenThread Radio Co-Processor (RCP) for the Jetson Orin Nano 8GB Developer Kit, so the Jetson can run OpenThread Border Router (OTBR) or OpenThread Daemon while keeping your first ESP32-C6 dedicated to ESP-Hosted Wi-Fi/BLE.

Hub: Network and Connectivity
Related local guides: ESP32-C6 ESP-Hosted over SPI on Jetson Orin Nano · Peripheral Access


1. Why this project matters

Once your Jetson already has:

  • wlan0 from ESP-Hosted over SPI
  • hci0 from the same ESP32-C6 for BLE

the clean next step for Thread is not to overload that same chip. The cleaner architecture is:

  • keep the first ESP32-C6 as the Jetson's Wi-Fi/BLE coprocessor
  • add a second ESP32-C6 dedicated to 802.15.4 / Thread
  • let the Jetson run the host-side OpenThread stack

This follows the standard RCP design from OpenThread:

  • the host processor runs the OpenThread stack
  • the RCP only handles the Thread radio / MAC layer
  • host and RCP talk over Spinel

That model is a good fit for Jetson because the Linux host is always on and already powerful enough to run OTBR and other edge services.


2. Target architecture

Jetson Orin Nano
  |
  |-- SPI -> ESP32-C6 #1 -> ESP-Hosted -> wlan0 + hci0
  |
  |-- UART (recommended first) -> ESP32-C6 #2 -> OpenThread RCP
  |                                 ^
  |                                 |
  |                             Spinel protocol
  |
  +--> Linux host side
        |-- otbr-agent   (for Thread Border Router)
        |-- or ot-daemon (for lighter RCP host use)
        |
        +--> wpan0

Target outcome

  • Jetson keeps using wlan0 from the first ESP32-C6 as its normal Wi-Fi interface
  • Jetson gains a Thread radio path through wpan0
  • otbr-agent or ot-daemon can talk to the second ESP32-C6 over Spinel
  • the second ESP32-C6 is dedicated to Thread RCP use and is not shared with ESP-Hosted

3. Why use a second ESP32-C6

This guide intentionally avoids the single-chip “do everything on one ESP32-C6” path.

Why:

  • ESP-Hosted is a Linux Wi-Fi/BLE coprocessor solution
  • OpenThread RCP is a different host/co-processor model
  • both want control of the same radio and host transport path
  • the integration effort to merge them into one stable firmware is much higher than using two chips

Espressif officially documents:

  • ESP32-C6 supports RCP mode
  • the RCP transport can be SPI or UART
  • for Wi-Fi-based Thread Border Router products, a dual-SoC architecture is recommended for better coexistence behavior

So this guide uses the safer engineering split:

  • ESP32-C6 #1 for Jetson Wi-Fi/BLE
  • ESP32-C6 #2 for Thread RCP

4. Hardware and software prerequisites

Hardware

  • Jetson Orin Nano 8GB Developer Kit
  • your existing ESP32-C6 #1 already working with ESP-Hosted over SPI
  • a second ESP32-C6 dev board
  • jumper wires for a direct UART link between Jetson and the second ESP32-C6
  • USB cable for flashing and monitoring the second ESP32-C6
  • optionally, one more Thread-capable device for validation:
  • another ESP32-C6
  • ESP32-H2
  • a Matter-over-Thread end device

Software

  • JetPack 6.x / L4T 36.x on Jetson
  • ESP-IDF installed on a Linux build/flash machine
  • OpenThread Border Router host software on Jetson:
  • ot-br-posix if you want a real Thread Border Router
  • or OpenThread POSIX ot-daemon if you want a lighter RCP host setup

Although ESP-IDF says RCP can use SPI or UART, this guide uses the Jetson's 40-pin header UART1 as the first real host transport.

Why:

  • less risk of colliding with the SPI bus already used by ESP-Hosted
  • it gives you a stable Jetson device path on this validated setup: /dev/ttyTHS1
  • it reflects the real host/RCP wiring model instead of hiding it behind a USB-UART bridge

You can still use the ESP board's USB connection for:

  • flashing
  • serial monitor logs
  • power during bring-up

You can optimize to SPI later if you need lower latency.


5. The official model you are implementing

OpenThread's RCP design means:

  • the OpenThread core lives on the host processor
  • the radio chip runs a minimal controller firmware
  • host and controller talk through Spinel

On Jetson, that means:

  • Jetson runs otbr-agent or ot-daemon
  • ESP32-C6 #2 runs the ot_rcp firmware from ESP-IDF

OpenThread's own coprocessor docs describe this as an RCP design, and OT Daemon is explicitly documented as the POSIX-side component for RCP setups.

For the backbone / infrastructure side, you have multiple valid choices on Jetson:

  • l4tbr0 if you want to use the Jetson USB device networking path to a host PC
  • wlan0 if the Jetson reaches the network through your first ESP32-C6 and ESP-Hosted
  • enP8p1s0 if you later use Ethernet as the OTBR backbone

On your current setup, l4tbr0 is the right choice for USB networking, not usb0 or usb1, because those interfaces are members of the l4tbr0 bridge.


6. Build and flash the ESP32-C6 RCP firmware

Use the ESP-IDF ot_rcp example for the second ESP32-C6.

On your Linux build host:

cd $IDF_PATH/examples/openthread/ot_rcp

# Select the chip once
idf.py set-target esp32c6

# Optional: inspect settings
idf.py menuconfig

# Build
idf.py build

Flash it to the second ESP32-C6:

# Use the actual serial port for your board
# On many ESP32-C6 dev boards with CP210x this is /dev/ttyUSB0
idf.py -p /dev/ttyUSB0 flash monitor

Important menuconfig choices for this guide

If the ot_rcp menu shows:

[*] Configure RCP UART pin manually
(4)     The number of RX pin
(5)     The number of TX pin

that matches the direct Jetson UART wiring documented below.

For this guide:

  • keep Configure RCP UART pin manually enabled
  • set RCP RX pin = 4
  • set RCP TX pin = 5
  • leave external coexist wire disabled for first bring-up

That means:

  • Jetson TX must go to ESP GPIO4 (RCP RX)
  • Jetson RX must come from ESP GPIO5 (RCP TX)

Port naming note

Do not hardcode /dev/ttyACM0 just because some upstream OTBR examples show it.

On Espressif dev boards:

  • one board may show up as /dev/ttyUSB0
  • another may show up as /dev/ttyACM0

Use the actual port that belongs to ESP32-C6 #2 on your machine.

If you are using an Espressif board with a CP210x bridge, /dev/ttyUSB0 is common.

This USB serial port is for:

  • flashing the ot_rcp image
  • reading ESP boot and log output

It is not the same thing as the Jetson's real UART transport used later by otbr-agent.


7. Enable UART1 on the Jetson and wire it to the RCP

The Jetson Orin Nano 40-pin header exposes UART1 as:

Function Jetson pin Linux device Direction
UART1_TXD 8 /dev/ttyTHS1 on your current image Jetson -> ESP
UART1_RXD 10 /dev/ttyTHS1 on your current image ESP -> Jetson
Ground 6 or 9 or 14 -- common reference

For the ESP32-C6 RCP side in this guide:

Function ESP32-C6 pin Direction
RCP RX GPIO4 Jetson TX -> ESP RX
RCP TX GPIO5 ESP TX -> Jetson RX
Ground GND common reference

So the exact wiring is:

Jetson pin 8  (UART1_TXD) -> ESP32-C6 GPIO4
Jetson pin 10 (UART1_RXD) <- ESP32-C6 GPIO5
Jetson GND                -> ESP32-C6 GND

Jetson UART prerequisites

On Jetson, the main thing that usually blocks use of the header UART is nvgetty, which can claim the user UART device.

Check the device first:

ls -l /dev/ttyTHS1

Then disable the serial getty if it is active:

sudo systemctl stop nvgetty
sudo systemctl disable nvgetty

Verify it is no longer active:

systemctl status nvgetty

Basic Jetson UART configuration

Configure the port to the baud rate you intend to use with the RCP host link:

sudo stty -F /dev/ttyTHS1 460800 cs8 -cstopb -parenb raw -echo

This guide uses 460800 because that is a common ot_rcp / OTBR pairing on Espressif examples. If you later choose a different baud in your OpenThread configuration, keep the Jetson and ESP sides aligned.

Electrical warning

The Jetson 40-pin UART is 3.3 V logic only.

Do not connect it to:

  • RS-232 voltage levels
  • 5 V UART
  • any external adapter that drives outside 3.3 V logic levels

For an ESP32-C6 dev board, direct 3.3 V UART wiring is fine.

Optional sanity test

Before involving the ESP board, you can do a Jetson loopback test:

  1. temporarily short pin 8 to pin 10
  2. run:
sudo cat /dev/ttyTHS1 &
echo "JETSON_UART_OK" | sudo tee /dev/ttyTHS1

If the text echoes back, the Jetson side UART path is alive.


8. Install OTBR on the Jetson

If you want a real Thread Border Router on the Jetson, use ot-br-posix.

sudo apt update
sudo apt install -y git

git clone --depth=1 https://github.com/openthread/ot-br-posix
cd ot-br-posix

./script/bootstrap
INFRA_IF_NAME=l4tbr0 ./script/setup

Why INFRA_IF_NAME=l4tbr0 on your current Jetson:

  • OTBR needs a backbone / infrastructure interface
  • your Jetson currently exposes the USB networking path as the bridge l4tbr0
  • usb0 and usb1 are members of that bridge, so l4tbr0 is the real backbone interface

If you later want the backbone to be:

  • ESP-Hosted Wi-Fi, use INFRA_IF_NAME=wlan0
  • Ethernet, use INFRA_IF_NAME=enP8p1s0 once that link is active

After installation:

sudo service otbr-agent status

The official OTBR native install guide shows the agent running like this at a high level:

/usr/sbin/otbr-agent -I wpan0 -B wlan0 spinel+hdlc+uart:///dev/ttyACM0

For your setup, the important part is the -B <backbone> choice. On your current Jetson USB networking path, that backbone should be l4tbr0.


9. Point OTBR at the real ESP32-C6 RCP port

OTBR uses /etc/default/otbr-agent to define the Radio URL.

Edit it:

sudoedit /etc/default/otbr-agent

Set the RCP path and baud rate. For the direct Jetson header UART path in this guide:

OTBR_AGENT_OPTS="-I wpan0 -B l4tbr0 spinel+hdlc+uart:///dev/ttyTHS1?uart-baudrate=460800"

If you intentionally choose a USB-serial path instead, replace /dev/ttyTHS1 with the real USB serial device such as /dev/ttyUSB0 or /dev/ttyACM0.

If you later move the OTBR backbone to Wi-Fi, change -B l4tbr0 to -B wlan0.

Then restart the service:

sudo systemctl restart otbr-agent
sudo systemctl status otbr-agent
sudo journalctl -u otbr-agent -n 100 --no-pager

Why 460800:

  • Espressif's Thread BR FAQ notes that OTBR often defaults to 115200
  • but the Espressif ot_rcp example commonly uses 460800
  • if the baud rate is wrong, host/RCP communication will fail even though the serial device exists

Before forming a Thread network, prove that the Jetson can talk to the RCP reliably.

Check that the expected Jetson UART device exists:

ls -l /dev/ttyTHS1

Check OTBR service health:

sudo service otbr-agent status
sudo journalctl -u otbr-agent -n 100 --no-pager
ip link show wpan0

What success looks like:

  • otbr-agent is active (running)
  • wpan0 exists
  • logs do not show repeated Spinel timeouts

If wpan0 does not appear, stop there and fix the RCP link before trying to form a network.


11. Form a Thread network on the Jetson

Once OTBR is healthy, use ot-ctl on the Jetson.

sudo ot-ctl state
sudo ot-ctl dataset init new
sudo ot-ctl dataset commit active
sudo ot-ctl ifconfig up
sudo ot-ctl thread start
sudo ot-ctl state

Typical progression:

  • first disabled
  • then detached
  • finally leader if this is the first Thread node in the new network

Useful follow-up commands:

sudo ot-ctl dataset active
sudo ot-ctl ipaddr
sudo ot-ctl netdata show

At this point, the Jetson is no longer just “connected to an RCP.” It is actively running the OpenThread host stack.

How to read the real OTBR and ot-ctl output

The most useful habit in this project is to read the host-side state in layers instead of looking for one magic success line.

For this validated Jetson setup, the first important line is:

Radio Co-processor version: openthread-esp32/... esp32c6

That line means the Linux host successfully talked to the ESP32-C6 over:

  • spinel+hdlc+uart
  • /dev/ttyTHS1
  • 460800 baud

If that line is missing and wpan0 never appears, the problem is still in the transport layer: wrong UART device, wrong baud, wrong wiring, bad RCP image, or an RCP reset.

Once the link is healthy, the next useful snapshot is usually:

sudo ot-ctl extaddr
sudo ot-ctl rloc16
sudo ot-ctl ipaddr
sudo ot-ctl state

For example, a real session may show:

extaddr: fac5eb4acbada19e
rloc16: a400
ipaddr:
  fd3f:d825:5faf:9782:0:ff:fe00:a400
  fd3f:d825:5faf:9782:8b89:772a:39cd:737a
  fe80::f8c5:eb4a:cbad:a19e
state: detached

Read that output like this:

  • extaddr is the 64-bit IEEE 802.15.4 radio identity.
  • rloc16 is the Thread mesh locator assigned inside the partition logic.
  • fd...ff:fe00:a400 is the RLOC IPv6 address derived from the mesh-local prefix and rloc16.
  • fd...8b89:... is the Mesh-Local EID, a more stable mesh-local identity.
  • fe80::... is the normal IPv6 link-local address.

This is the key nuance: you can have valid Thread addresses and still be detached. That means the dataset is present and the host/RCP stack is alive, but the node has not yet completed attachment to a partition.

What the common OTBR log lines mean

These log lines are the most important ones to recognize:

Mle-----------: Send Link Request (ff02::2)
MeshForwarder-: Sent IPv6 UDP msg ... dst:[ff02::2]:19788
Settings------: Read NetworkInfo {rloc:0xa400, extaddr:..., role:leader, ...}
BorderAgent---: Registering service OpenThread BorderRouter #A19E _meshcop._udp

How to interpret them:

  • Mle----------- means the Thread control plane is actively trying to discover or attach.
  • MeshForwarder- proves the radio path is transmitting real Thread traffic.
  • Settings------ shows persisted OpenThread state being read or written.
  • BorderAgent--- ... _meshcop._udp shows the commissioning-facing Border Agent service being registered by OTBR.

One subtle line confuses many people:

Settings------: Read NetworkInfo { ... role:leader, ... }

This does not prove the node is currently leader. It only means the stack read previously saved network state that remembered a leader role. The live role still comes from sudo ot-ctl state.

Why detached can still be a healthy intermediate state

In a fresh lab setup, the expected state progression is:

  • disabled
  • detached
  • leader for a one-node partition, or router / child when joining an existing mesh

So detached is not the same as "the UART is broken." In your actual logs, it appeared together with:

  • a real Radio Co-processor version line
  • wpan0 creation
  • valid extaddr, rloc16, and ipaddr output
  • MLE attach attempts in the logs

That combination means the host/RCP link is already working. The remaining work is in Thread attachment and partition formation, not low-level serial bring-up.

How to read the attach-attempt logs

Lines like these:

Attach attempt 8, AnyPartition
Send Parent Request to routers
Send Parent Request to routers and REEDs
Attach attempt 8 unsuccessful, will try again in 32.128 seconds

mean the node is alive and behaving like a Thread node, but it has not yet completed the attach process. This is a protocol-state problem, not automatically a transport problem.

If you instead see:

Wait for response timeout
no response from RCP during initialization

that points back to the host/RCP path and you should debug the UART, baud, RCP image, or board stability first.

Session-socket and restart messages

Two more lines are easy to over-interpret:

P-Daemon------: Session socket is ready
P-Daemon------: Daemon read: Connection reset by peer

In normal use, those often just mean that ot-ctl connected to OTBR's control socket and then exited. They are not automatically a radio failure.

Similarly, after restarting otbr-agent, it is normal to see:

  • wpan0 exist but still be state DOWN
  • sudo ot-ctl state return disabled

until you run:

sudo ot-ctl dataset init new
sudo ot-ctl dataset commit active
sudo ot-ctl ifconfig up
sudo ot-ctl thread start

For this Jetson project, the known-good host-side baseline is:

  • RCP transport: spinel+hdlc+uart
  • serial device: /dev/ttyTHS1
  • baud: 460800
  • OTBR backbone: l4tbr0

Current validated status on this Jetson image

At this point, the current validated status of the project is:

  • the ESP32-C6 ot_rcp image is working
  • Jetson can talk to the RCP over Spinel + UART
  • the validated host path is:
  • /dev/ttyTHS1
  • 460800
  • l4tbr0
  • OTBR creates wpan0
  • ot-ctl can manage the Thread dataset and interface
  • the node can progress from disabled to detached
  • in a foreground OTBR run, the node eventually becomes leader

The most important leader-formation lines from the real run were:

Allocate router id 41
RLOC16 fffe -> a400
Role detached -> leader
Partition ID 0x1a1d09c0
Route table ... me - leader

Those lines prove that the Thread stack itself is functioning correctly on this hardware split. In other words, the RCP link, dataset handling, MLE attach logic, and one-node partition formation all work on the current system.

However, the same foreground run later hit:

InitMulticastRouterSock() at multicast_routing.cpp:227: Protocol not available

That is the key current limitation. It means OTBR was able to bring up the Thread node and even reach leader, but then failed while enabling Linux-side border-routing / multicast-routing features.

The kernel check confirmed the reason:

# CONFIG_IP_MROUTE is not set
# CONFIG_IPV6_MROUTE is not set

So the correct current conclusion is:

  • Thread-over-RCP on Jetson is working
  • full OTBR border-router functionality is blocked on this Jetson image
  • the blocker is missing kernel multicast-routing support, not UART, not Spinel, and not the ESP32-C6 RCP firmware

This is exactly the kind of mixed-software boundary problem that this project is meant to teach: a system can be successful as a Thread node and still be incomplete as a full border-router product because the Linux kernel is missing one feature OTBR expects.


12. Optional lighter path: OT Daemon instead of OTBR

If you want an RCP host path without the full border-router stack, use OpenThread Daemon.

According to OpenThread's official coprocessor docs:

  • ot-daemon is the POSIX service for RCP designs
  • it uses a Spinel Radio URL just like OTBR

Build it:

git clone --depth=1 https://github.com/openthread/openthread
cd openthread

./script/bootstrap
./script/cmake-build posix -DOT_DAEMON=ON

Run it against the real RCP:

./build/posix/src/posix/ot-daemon \
  'spinel+hdlc+uart:///dev/ttyTHS1?uart-baudrate=460800'

In another terminal:

./build/posix/src/posix/ot-ctl state

Use this path if:

  • you want to validate host/RCP behavior first
  • you do not yet need a full Thread Border Router
  • you want a smaller debugging surface than OTBR

On your current Jetson image, this path is especially useful because the kernel currently lacks:

  • CONFIG_IP_MROUTE
  • CONFIG_IPV6_MROUTE

That means ot-daemon is the cleaner current host path for Thread/RCP work while you defer a JetPack or kernel rebuild for full OTBR border-routing support.

If you choose the USB-serial path instead of Jetson header UART, substitute the real USB device path.


13. Common failure modes

otbr-agent is running, but wpan0 is missing

Usually means:

  • wrong serial device
  • wrong baud rate
  • RCP firmware is not actually ot_rcp
  • nvgetty is still owning the user UART device

Check:

sudo journalctl -u otbr-agent -n 100 --no-pager

Spinel timeout warnings

Typical causes:

  • wrong UART baud rate
  • unstable USB serial path
  • wrong serial port after replug or reflashing
  • Jetson UART1 is not actually free for application use

Espressif's FAQ shows this family of symptom as:

Wait for response timeout

/dev/ttyTHS1 exists, but OTBR still cannot talk to the RCP

Usually means one of:

  • Jetson pin 8/10 are not actually cross-wired to ESP GPIO4/5
  • TX/RX are swapped incorrectly
  • nvgetty still owns the port
  • the ESP side UART pins in menuconfig do not match the actual wiring

For this guide, the correct direct wiring is:

Jetson pin 8  -> ESP GPIO4
Jetson pin 10 <- ESP GPIO5

Confusing the two ESP32-C6 boards

Keep them clearly separated:

  • ESP32-C6 #1 = ESP-Hosted Wi-Fi/BLE over SPI
  • ESP32-C6 #2 = Thread RCP over UART

Do not flash ot_rcp onto the board currently providing wlan0.

OTBR installs correctly but routing does not work

If OTBR can talk to the RCP, forms wpan0, and even reaches leader, but then exits with a message like:

InitMulticastRouterSock() ... Protocol not available

the problem is no longer the ESP32-C6 or the UART link. It is usually a host-kernel limitation.

On the validated Jetson run for this project, the exact cause was:

# CONFIG_IP_MROUTE is not set
# CONFIG_IPV6_MROUTE is not set

Check on the Jetson with:

zcat /proc/config.gz | grep -E 'CONFIG_IPV6_MROUTE|CONFIG_IP_MROUTE'

or, if /proc/config.gz is unavailable:

grep -E 'CONFIG_IPV6_MROUTE|CONFIG_IP_MROUTE' /boot/config-$(uname -r)

If those options are missing, the practical choices are:

  • use ot-daemon for current Thread/RCP work
  • rebuild or replace the kernel / JetPack image later with multicast-routing support enabled

Also make sure the backbone interface is right:

  • wlan0 if Jetson reaches the network through ESP-Hosted
  • eth0 only if you intentionally use Ethernet as the Thread BR backbone

14. Good next steps after first bring-up

  • keep this current image for RCP and Thread learning, using ot-daemon when you do not need full OTBR border routing
  • rebuild JetPack or the Jetson kernel later with CONFIG_IP_MROUTE and CONFIG_IPV6_MROUTE enabled
  • add a second Thread device and have it join the network
  • validate ipaddr, ping, and service discovery over Thread
  • keep the RCP on UART first, then evaluate SPI later only if needed
  • if you want Matter later, keep this Jetson + RCP split and build on top of it

15. References

Official upstream references

Local roadmap references