Native ROS 2 node functionality for ESP32, as a Raft SysMod.
RaftROS makes a Raft ESP32 application a first-class ROS 2 node - no
micro-ROS, no agent process. It talks to ROS 2 over Zenoh (through an
rmw_zenoh router; the default) or directly over RTPS/DDS (chosen at
build time), publishes every bus device that Raft's DeviceManager finds as
a typed ROS 2 topic, and lets application code subscribe to topics, serve
ROS 2 services and expose ROS 2 parameters.
The story of the project, with the measurements, is in the blog post Agent-free ROS 2 on an ESP32.
Start with the example: examples/ExampleDiscoverable
is a complete application that uses every feature, with the exact ros2
commands to try each one. The rest of this page is about using RaftROS in
your own application.
Verified on an ESP32-S3 against ROS 2 Jazzy (measured results). The same Zenoh firmware also passes the full functional, robustness, load and hot-plug tests against Kilted and Lyrical (distribution support):
| Zenoh (default) | RTPS/DDS | |
|---|---|---|
Node in the graph, /chatter, string subscriptions |
yes | yes |
Every DeviceManager bus device auto-published as a typed topic |
yes | yes |
QoS profiles (qosProfiles) for publishers and subscriptions |
yes | yes |
| Device hot-plug (topic withdrawn and re-announced) | yes | yes |
ROS 2 services (std_srvs Trigger / SetBool / Empty, deferred replies) |
yes | no |
ROS 2 parameters (ros2 param list/get/set/describe/dump) |
yes | no |
| Needs on the ROS side | an rmw_zenohd router |
nothing (FastDDS, CycloneDDS) |
| Worst main-loop pass, unattended | ~13 ms | ~15 ms |
Against the Raft SysMod contract of 50 ms worst case. Two 12-hour soaks on Zenoh (the second with a service or parameter call every few seconds) ran with one session throughout, no failed calls and free heap flat. Image ~1.28 MB (28% of a 1.7 MB app partition free); free heap ~147 kB with services and parameters. Host tests: the unit suite and the Zenoh session (1298), codec (2223) and firmware-piece (141) suites.
RaftROS is an ESP-IDF component (components/RaftROS). In a Raft project
(raft new from the Raft CLI), make it
visible to the build and depend on it - as the example does:
# systypes/Common/features.cmake
set(RAFT_COMPONENTS
RaftCore@main # RaftCore 78781c0 or later (see Dependencies)
RaftSysMods@main
RaftWebServer@main # for /api/rosstat and settings
RaftI2C@main # if you have I2C devices
)
list(APPEND EXTRA_COMPONENT_DIRS "<path-to>/RaftROS") # or add it as a git submodule# main/CMakeLists.txt
idf_component_register(SRCS "main.cpp" INCLUDE_DIRS "."
REQUIRES RaftCore RaftSysMods RaftWebServer RaftI2C RaftROS)Kconfig option RAFTROS_BACKEND - Zenoh by default. For RTPS, put this in
your sdkconfig.defaults (and delete the build's sdkconfig):
CONFIG_RAFTROS_BACKEND_RTPS=y
For Zenoh the router's address is configuration, not part of the image:
routerHost in SysTypes, or a setting posted to the running device (see
step 4). CONFIG_RAFTROS_ZENOH_ROUTER_HOST (and ..._PORT, default 7447)
exists for a build that wants a baked-in default; it is empty as shipped.
Two sdkconfig settings the example uses and you probably want:
CONFIG_ESP_MAIN_TASK_AFFINITY_CPU1=y (keeps the SysMod loop off the WiFi
core; without it WiFi reconnects stall the loop ~275 ms) and
CONFIG_ESP_MAIN_TASK_STACK_SIZE=10000.
#include "RaftCoreApp.h"
#include "RegisterSysMods.h"
#include "RaftROS.h"
#include "BusI2C.h"
RaftCoreApp raftCoreApp;
extern "C" void app_main(void)
{
RegisterSysMods::registerSysMods(raftCoreApp.getSysManager());
RegisterSysMods::registerWebServer(raftCoreApp.getSysManager());
raftBusSystem.registerBus("I2C", BusI2C::createFn);
raftCoreApp.registerSysMod("RaftROS", RaftROS::create, true);
// ... your own SysMods
while (1)
raftCoreApp.loop();
}In the RaftROS block of your SysTypes JSON (with a DevMan block declaring
your buses, for auto-publishing):
"RaftROS": {
"active": 1,
"domainId": 0,
"nodeName": "my_robot",
"nodeNamespace": "/",
"routerHost": "192.168.1.50",
"qosProfiles": { "classDefaults": {} }
}active says whether the node runs; the SysMod itself is always there (it
was registered with alwaysEnable), so a product can ship with active off
and no router address, and be switched on and pointed at a router from the
network, with nothing rebuilt or reflashed:
curl 'http://<device-ip>/api/ros/set?active=1&routerHost=192.168.1.50&persist=1'
curl http://<device-ip>/api/ros # status: active, router, session state, counters
curl http://<device-ip>/api/ros/clear # drop the persisted settings; SysTypes applies againros/set takes active, routerHost (IPv4), routerPort and persist;
the change is applied on the next loop pass, and with persist=1 it is also
written to NVS for later boots. The other keys can be changed with
POST /api/postsettings/reboot as before. On Zenoh an active node with no
router address says so once in the log and waits; one that cannot reach its
router reports the cause and the fix. The full list of keys is in the
example's Configuration section.
At this point, with no application code, the node appears in ROS 2, publishes
/chatter and auto-publishes every bus device.
Get the SysMod from any other SysMod's setup():
RaftROS* pRos = static_cast<RaftROS*>(getSysManager()->getSysMod("RaftROS"));Subscribe to a topic (both transports; std_msgs/String):
pRos->addStringSubscription("rt/cmd", "std_msgs::msg::dds_::String_",
[](const uint8_t* writerEID, const uint8_t* srcGuid, const char* text, uint32_t len) {
LOG_I("App", "cmd: %s", text);
});Serve a service (Zenoh). A handler runs on the main loop and must not
block; one that needs a bus reading returns Deferred and finishes later with
completeService(token, reply):
pRos->addService("/my_robot/reset", "std_srvs::srv::dds_::Trigger_",
[](const RaftROS::ServiceRequest& req, RaftROS::ServiceReply& reply) {
reply.fields.success = true;
reply.fields.message = "done";
return RaftROS::ServiceOutcome::Replied;
});Declare a parameter (Zenoh) - ros2 param set reaches it; the optional
callback can refuse a value:
pRos->declareParameter("gain", 1.5, "Controller gain", false,
[](const RaftROS::ParamValue& v, const char*& reason) {
if (v.doubleValue < 0) { reason = "gain must be >= 0"; return false; }
return true;
});
double gain = pRos->parameter("gain")->doubleValue;On the RTPS build these compile to stubs that log a warning, so one application builds for both transports.
Read a device's latest value without disturbing auto-publishing:
subscribe with DeviceManager::registerForDeviceData (RaftCore fans samples
out to every subscriber) and decode each sample with the device type record's
pollResultDecodeFn, reading fields by name from its pollFieldDescs.
For an occasional read, RaftBusDevicesIF::peekLatestDecodedPollResponse
returns the latest result without consuming it. getLatestDecodedPollResponse
is not a peek: it returns a result only if one has arrived since its previous
call (from any caller), so two readers of it starve each other.
The example's MainSysMod.cpp
does all of these.
Zenoh:
ros2 run rmw_zenoh_cpp rmw_zenohd # once, on a host the device can reach
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 node list && ros2 topic list && ros2 param dump /my_robotRTPS: any ROS 2 host on the same subnet with FastDDS or CycloneDDS - see Host Setup Notes if discovery doesn't work.
- Nothing blocks the main loop. Handlers, parameter callbacks and subscription callbacks run on it; keep them short.
- Logging costs loop time. With nothing reading a USB-Serial-JTAG console
each log line stalls ~10 ms; RaftROS writes at most one per loop pass. Read
loop figures from
GET /api/rosstatwith no terminal attached. routerHostis an IPv4 address - a DNS lookup would block the loop.GET /api/rosstathas the session, counters, heap, stack and per-phase loop maxima; it is the thing to watch in a long run.
- Zenoh transport (default): an
rmw_zenoh-compatible session with liveliness tokens and interests, reconnect that re-declares everything, a layered router address and an actionable warning when it is unreachable. - RTPS transport: a clean-room RTPS 2.2 participant (MIT licensed) - SPDP/SEDP discovery, reliable QoS with HEARTBEAT/ACKNACK retransmit, VOLATILE and TRANSIENT_LOCAL.
- Automatic ROS 2 publishing of every
DeviceManagerbus device -clas[]-driven message types, REP-103 SI units, composite devices on several topics, per-class QoS profiles with SysTypes override, hot-plug. - ROS 2 services (Zenoh):
std_srvsTrigger / SetBool / Empty servers; deferred replies; busy, bad-request and timeout errors instead of hangs. - ROS 2 parameters (Zenoh): the six
rcl_interfacesservices; the node's own settings (chatterEnable,chatterPeriodMs,routerHost- persisted) and any the application declares. - CDR serialisation for the standard ROS 2 message types.
- Planned: subscriptions for actuator classes (SRVO/MOTR/PUMP/PIX) wired to
DeviceManager, so ROS 2 can drive them (they already publish their state asJointState); ROS 2 actions; parameter events.
The quickest route on a Linux host with Docker is docker/distros/demo.sh <distro>: it builds nothing on the host, starts the Zenoh router and
foxglove_bridge in a container for Jazzy, Kilted or Lyrical, and with the
dashboard argument runs DemoSimple too. The steps below are the same thing
done by hand. They are written for the Zenoh build (the default); for the
RTPS build use RMW_IMPLEMENTATION=rmw_fastrtps_cpp, no router, and read the
host setup notes.
examples/ExampleDiscoverable is the ESP32 firmware demo. When flashed to an
ESP32 with WiFi configured, it appears as a native ROS 2 participant and
auto-publishes every supported I2C device detected by DeviceManager.
examples/DemoSimple is the host-side terminal demo. It watches the ROS graph
for RaftROS topics, dynamically subscribes using the discovered message type,
and prints live sensor values without needing to know which I2C devices are
attached ahead of time.
From examples/ExampleDiscoverable:
raft runIf WiFi has not been configured yet, use the serial console:
w/<SSID>/<password>
Attach one or more supported I2C devices. Typical demo devices include:
| Device | Expected topic | ROS 2 type |
|---|---|---|
| VL6180 distance sensor | /raft/range_1_29 |
sensor_msgs/msg/Range |
| VEML7700 ambient light sensor | /raft/illuminance_1_10 |
sensor_msgs/msg/Illuminance |
| AS5600 magnetic angle sensor | /raft/angle_1_36 |
std_msgs/msg/Float32 |
| LSM6DS IMU | /raft/imu_1_6a |
sensor_msgs/msg/Imu |
From the repository root on a ROS 2 host, with a router running
(ros2 run rmw_zenoh_cpp rmw_zenohd):
RMW_IMPLEMENTATION=rmw_zenoh_cpp examples/DemoSimple/run_dashboard.shThe dashboard should show rows appearing and disappearing as I2C devices are plugged and unplugged. For example, with a VL6180 attached:
topic type age count latest
/raft/range_1_29 sensor_msgs/msg/Range 0.1s 42 range=0.184 m min=0.000 max=2.000 frame=raft
Useful options:
examples/DemoSimple/run_dashboard.sh --namespace /raft
examples/DemoSimple/run_dashboard.sh --all-raft-topics --include-chatter
examples/DemoSimple/run_dashboard.sh --no-clearThe wrapper sources /opt/ros/${ROS_DISTRO:-jazzy}/setup.bash, honours an
explicit RMW_IMPLEMENTATION (and otherwise selects Fast DDS over UDPv4 for
the RTPS build), and uses a local log directory under examples/DemoSimple/logs.
If you already have a ROS environment sourced, you can run the Python script
directly:
python3 -u examples/DemoSimple/raftros_dynamic_dashboard.pyFoxglove Studio connects to ROS 2 through foxglove_bridge. Install the bridge
in the ROS environment that can already see the RaftROS topics:
sudo apt update
sudo apt install ros-${ROS_DISTRO:-jazzy}-foxglove-bridgeStart the bridge (Zenoh build; the router must already be running):
source /opt/ros/${ROS_DISTRO:-jazzy}/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 launch foxglove_bridge foxglove_bridge_launch.xmlFor the RTPS build instead:
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
export FASTDDS_BUILTIN_TRANSPORTS=UDPv4
unset FASTRTPS_DEFAULT_PROFILES_FILE FASTDDS_DEFAULT_PROFILES_FILE ROS_LOCALHOST_ONLY
ros2 launch foxglove_bridge foxglove_bridge_launch.xmlA ready-made layout for the VL6180 demo is in
docker/distros/demo/raftros-foxglove-layout.json (layout menu → Import).
Then open Foxglove Studio and connect to:
ws://localhost:8765
Suggested Foxglove panels:
- Plot
/raft/range_1_29.rangefor the VL6180. - Plot
/raft/illuminance_1_10.illuminancefor the VEML7700. - Plot
/raft/angle_1_36.datafor the AS5600. - Inspect
/raft/imu_1_6aas asensor_msgs/msg/Imustream.
This setup works well: run ROS 2 and foxglove_bridge inside WSL, then run the
Foxglove Studio desktop app on Windows.
- Install Foxglove Studio on Windows from https://foxglove.dev/download.
- In WSL, confirm
examples/DemoSimple/run_dashboard.shcan see the RaftROS topics. - In WSL, start
foxglove_bridgewith the command above. - In Windows Foxglove Studio, connect to
ws://localhost:8765.
If localhost does not connect, get the WSL IP:
hostname -IThen connect Foxglove Studio to:
ws://<WSL_IP>:8765
For ESP32 discovery through WSL2, mirrored networking and permissive inbound UDP firewall rules are usually required; see the host setup notes below.
On native Linux, run both foxglove_bridge and Foxglove Studio on the same
machine. Install Foxglove Studio from https://foxglove.dev/download, start
the bridge with the command above, and connect to:
ws://localhost:8765
Native Linux avoids the WSL multicast and firewall issues, but the machine
still needs to be on the same subnet and ROS_DOMAIN_ID as the ESP32.
RaftROS fast sensor streams use BEST_EFFORT / VOLATILE QoS by default. The
DemoSimple dashboard subscribes with compatible QoS. If Foxglove shows topics
but does not show samples, check whether foxglove_bridge is subscribing with
compatible QoS for the topic.
These apply to the RTPS build, which depends on UDP multicast; the Zenoh build needs only a TCP connection to the router.
Resolved: on native Linux (2026-09-27)
ros2 node info /<node>andros2 topic info -vattribute the node correctly over both FastDDS and CycloneDDS. The note below is kept for WSL setups that still show it.
Applies to the RTPS build only. On the Windows 11 + WSL2 + ROS 2 Jazzy test setup of 2026-04, ros2 topic info -v
may show the ESP32 publisher with Node name: _NODE_NAME_UNKNOWN_, and
ros2 node info /<node> may fail even while typed subscriptions receive
valid samples. This is currently treated as a host/CLI graph-attribution issue,
not a firmware data-path failure:
rclpysubscribers receive and deserialize RaftROS samples correctly.examples/DemoSimple/run_dashboard.shdiscovers/raft/...topics and displays live values.foxglove_bridgerunning in WSL works with Foxglove Studio on Windows when WSL networking/firewall setup allows ROS 2 discovery.
Assumption as of 2026-04-27: the remaining _NODE_NAME_UNKNOWN_ symptom is
specific to ros2cli/rmw graph introspection on this WSL/Jazzy/FastDDS setup
until reproduced on native Linux. Native Linux avoids the WSL multicast and
firewall layer and should be used to confirm whether any protocol-side fix is
still required. See devdocs/RaftROS-development-status.md for the detailed
wire-level investigation and mitigations.
Discovery silently fails on some host setups. These are the gotchas we hit and the fixes — verified on Windows 11 + WSL2 Ubuntu 24.04 + ROS 2 Jazzy (Fast DDS 3.x) and on native Linux + ROS 2 Humble (Fast DDS 2.6.x).
The default WSL2 NAT mode does NOT forward inbound multicast from the LAN
into WSL, so SPDP (239.255.0.1:7400) from the ESP32 never arrives.
Create/edit %UserProfile%\.wslconfig on Windows:
[wsl2]
networkingMode=mirrored
firewall=trueThen from an elevated PowerShell: wsl --shutdown and reopen WSL. In
mirrored mode WSL shares the Windows LAN IP, and multicast works.
Even with mirrored networking, Windows Defender Firewall drops inbound RTPS UDP to the WSL side by default. This is the most common "discovery works one-way only" symptom (ESP32 sees the host; host never sees the ESP32). Add a permissive rule from an elevated PowerShell:
New-NetFirewallRule -DisplayName "ROS2 RTPS" -Direction Inbound `
-Protocol UDP -LocalPort 7400-7500 -Action Allow -Profile AnyAlternative: set firewall=false in .wslconfig (less strict, fine for
a dev workstation). Verify with sudo tcpdump -i any -nn udp port 7400
inside WSL — if you see zero packets from the ESP32 IP but
sudo nmap -sU -p 7400 <ESP32_IP> works outbound, it's the firewall.
On Jazzy the semantics inverted; leaving ROS_LOCALHOST_ONLY=0 in your
shell actually restricts discovery. Use:
unset ROS_LOCALHOST_ONLY
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
export ROS_DOMAIN_ID=0On Humble, ROS_LOCALHOST_ONLY=0 is still correct.
ESP32 and host must be on the same L2 broadcast domain (guest WiFi
networks often isolate clients and will block SPDP). ROS_DOMAIN_ID
must match on both sides (RaftROS default is 0).
# In WSL / Linux host
sudo tcpdump -i any -w ~/raft_rtps.pcap 'udp and (port 7400 or portrange 7410-7500)'
sudo chown $USER ~/raft_rtps.pcap
# Open in Wireshark, filter on 'rtps'. Packets from the ESP32 IP confirm
# the firewall / mirroring path is OK; only host packets means Steps 1–2
# are not yet correct.components/RaftROS/- the SysMod (RaftROS.hselects the backend).Zenoh/- the Zenoh backend: session, wire messages, ROS key and token codec.RTPS/- the RTPS backend and its runtime (runtime/{discovery,reliability,announce,receive,schedule,wire,core}).AutoPub/- shared by both: device auto-publishing, message mapping and QoS, the service registry and codecs, the parameter store.CDR/- CDR encoder/decoder.
examples/ExampleDiscoverable/- the reference application (above).examples/DemoSimple/- host-side dashboard for hot-plugged device topics.tools/- a Zenoh router stub and a Zenoh subscriber, for testing without ROS.docker/distros/- a ROS 2 test container per distribution (Jazzy, Kilted, Lyrical) with the functional, hot-plug and soak harness, anddemo.sh.scripts/- RTPS discovery and traffic diagnostics (tshark/pcap based), from the RTPS bring-up.unit_tests/- the on-target (ESP32) unit test application.linux_unit_tests/- host test suites (maketargets:all,zenoh-test,zenoh-session-test,zenoh-autopub-test), with captured-traffic fixtures.devdocs/- start with RaftROS-zenoh-milestone-results.md (measured results) and RaftROS-development-status.md (the project log); the services and parameters plans record the wire contracts.
- RaftCore -
78781c0or later (device-data fan-out,RaftJsonescape fix, NVS init order) - RaftSysMods - networking
- RaftWebServer -
/api/rosstatand settings endpoints - RaftI2C - for I2C devices
- ESP-IDF 6.0 (tested with 6.0.2); ROS 2 Jazzy, Kilted or Lyrical on the host
(
ros-<distro>-rmw-zenoh-cppfor Zenoh;docker/distros/builds a test container for each)
MIT — see LICENSE