Skip to content
Build GuidesNovember 2, 2026Intermediate

micro-ROS on ESP32: First Publisher and Subscriber

RTJ Editor
RoboTechJournal Contributor
micro-ROS on ESP32: First Publisher and Subscriber
ContentsScope and Prerequisites

A useful first micro-ROS application proves both directions: the ESP32 receives a value from a ROS 2 host and publishes that value back. This guide uses two std_msgs/msg/Int32 topics over serial. It is a communication exercise, not a sensor driver or motor-control example.

For the client/agent architecture, STM32 considerations, memory and timestamps, read the micro-ROS ESP32 and STM32 telemetry integration guide. If nodes and topics are unfamiliar, start with our ROS 2 beginner guide to nodes, topics and workspaces.

Scope and Prerequisites

  • A board genuinely represented by PlatformIO's esp32dev target, an appropriate data-capable USB cable and a Linux host with ROS 2 Jazzy installed. Do not infer ESP32-S3, C3 or other variant support from the ESP32 family name.
  • PlatformIO Core 6.1 or later, plus Git, CMake and pip3 as required by the official micro-ROS PlatformIO README and supported-board table.
  • A Jazzy micro-ROS Agent: the container command below is one option. The host examples use the standard /opt/ros/jazzy installation path; source your own workspace if installed differently.

The upstream support matrix documents esp32dev, Arduino, serial and Wi-Fi. It is not a guarantee for every platform/core version combination. The configuration below follows that documented path; after validation, pin the library commit, platform packages and agent image digest rather than relying on moving defaults. Keep the host, agent and firmware ROS distribution aligned.

1. Create a Serial PlatformIO Project

Create a PlatformIO Arduino project with platformio.ini at its root and the sketch below in src/main.cpp. Explicitly select Jazzy: the library's default distribution can change independently of your host.

text
[env:esp32dev]platform = espressif32board = esp32devframework = arduinomonitor_speed = 115200board_microros_distro = jazzyboard_microros_transport = seriallib_deps =    https://github.com/micro-ROS/micro_ros_platformio.git

monitor_speed configures PlatformIO's monitor; it does not set the firmware UART or the agent. All three settings must agree. Do not leave the monitor attached while the agent owns this port.

2. Add a Publisher, Subscriber and Two Executor Handles

The subscription stores the most recent input. A timer copies that value to a telemetry message. Both callbacks run through one executor; its capacity is two handles: one subscription and one timer. The publisher itself is not another executor handle. Fixed-size Int32 messages avoid the additional string/sequence ownership needed by more complex types.

cpp
#include <Arduino.h>#include <micro_ros_platformio.h>#include <rcl/rcl.h>#include <rclc/rclc.h>#include <rclc/executor.h>#include <rmw_microros/rmw_microros.h>#include <std_msgs/msg/int32.h> rclc_support_t support;rcl_allocator_t allocator;rcl_node_t node;rcl_publisher_t publisher;rcl_subscription_t subscriber;rcl_timer_t timer;rclc_executor_t executor;std_msgs__msg__Int32 incoming{};std_msgs__msg__Int32 outgoing{};int32_t latest_input = 0; void fault_wait() {  // Intentionally no Serial.print: this UART carries XRCE-DDS.  // This minimal example requires a reset after a fault.  while (true) { delay(100); }} void require_ok(rcl_ret_t rc) {  if (rc != RCL_RET_OK) { fault_wait(); }} void on_input(const void * message) {  const auto * value =    static_cast<const std_msgs__msg__Int32 *>(message);  latest_input = value->data;} void on_timer(rcl_timer_t * timer_handle, int64_t) {  if (timer_handle == nullptr) { return; }  outgoing.data = latest_input;  require_ok(rcl_publish(&publisher, &outgoing, nullptr));} void setup() {  Serial.begin(115200);  set_microros_serial_transports(Serial);  delay(2000);  if (rmw_uros_ping_agent(1000, 1) != RMW_RET_OK) {    fault_wait();  }   allocator = rcl_get_default_allocator();  node = rcl_get_zero_initialized_node();  publisher = rcl_get_zero_initialized_publisher();  subscriber = rcl_get_zero_initialized_subscription();  timer = rcl_get_zero_initialized_timer();  executor = rclc_executor_get_zero_initialized_executor();   require_ok(rclc_support_init(&support, 0, nullptr, &allocator));  require_ok(rclc_node_init_default(    &node, "esp32_pubsub", "", &support));  require_ok(rclc_publisher_init_default(    &publisher, &node,    ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32),    "esp32/telemetry"));  require_ok(rclc_subscription_init_default(    &subscriber, &node,    ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32),    "esp32/input"));  require_ok(rclc_timer_init_default(    &timer, &support, RCL_MS_TO_NS(1000), on_timer));  require_ok(rclc_executor_init(    &executor, &support.context, 2, &allocator));  require_ok(rclc_executor_add_subscription(    &executor, &subscriber, &incoming, on_input, ON_NEW_DATA));  require_ok(rclc_executor_add_timer(&executor, &timer));} void loop() {  const auto rc =    rclc_executor_spin_some(&executor, RCL_MS_TO_NS(10));  if (rc != RCL_RET_OK && rc != RCL_RET_TIMEOUT) {    fault_wait();  }  delay(1);}

This combines the documented rclc publisher and subscription APIs, executor/timer APIs and agent ping utility. Handles, message buffers and the allocator object have static storage duration here so they remain alive after setup(). The upstream executor definition retains an allocator pointer. This is an illustrative integration, not a tested firmware release. The one-second timer requests a publication interval; it does not establish measured throughput or latency.

3. Build, Flash and Start the Matching Agent

bash
pio run -e esp32devpio run -e esp32dev --target upload# Do not start a serial monitor on the agent's device.

Choose the correct upload port if multiple boards are connected. On the Linux host, identify the actual serial device; /dev/ttyUSB0 is an example, not a universal name. Start the agent, then reset the board so its startup ping and entity creation can reach an already-running agent.

bash
docker run -it --rm --net=host \  --device=/dev/ttyUSB0 \  microros/micro-ros-agent:jazzy \  serial --dev /dev/ttyUSB0 -b 115200 -v6

This command is for Linux host networking and maps one serial device. Native-agent permissions and container device permissions are separate concerns. No ROS installation is required inside the MCU; the host CLI does need a sourced ROS 2 environment.

4. Verify Both Directions Without a Sensor

In each host terminal, source Jazzy and use domain 0 for this default-domain example. Inspect the endpoints before sending a value:

bash
source /opt/ros/jazzy/setup.bashexport ROS_DOMAIN_ID=0ros2 node listros2 topic list -tros2 topic info /esp32/input --verboseros2 topic info /esp32/telemetry --verboseros2 topic echo /esp32/telemetry

In a second sourced terminal, publish repeatedly so a single message sent before discovery does not make the exercise appear broken:

bash
ros2 topic pub --rate 1 /esp32/input std_msgs/msg/Int32 "{data: 42}"

The intended behavior is an initial value of 0, followed by 42 on /esp32/telemetry after the subscription receives it and the timer runs. Stop the publisher with Ctrl+C; the example retains the last value. This describes the code's behavior, not captured output from a physical test. An echoed value proves neither sensor accuracy nor a safety-rated command channel.

First Bring-up Problems

  • Build fails: check the supported board/framework pair and required build tools. When changing distribution, transport or library configuration, use pio run -e esp32dev --target clean_microros before rebuilding, as documented upstream.
  • No node after reset: verify the device path, permissions, exclusive port access and the matching 115200 baud values. A successful upload is not proof that the runtime agent has opened the right port.
  • Node exists but input has no effect: inspect topic names, Int32 type, endpoint QoS and domain ID. Confirm the executor has both handles and continues spinning. The example uses reliable default publisher/subscriber initialization.
  • It stops after an outage: that is a limitation of the minimal fault handler. Do not mistake an infinite error loop for automatic reconnection.

Limitations and Next Steps

No sensor, actuator, clock synchronization, watchdog or reconnect state machine is implemented. Do not attach the input directly to a motor. Add bounded recovery, stale-command handling and an independently safe control path before integrating a robot.

Use the serial, Wi-Fi and Agent debugging guide to diagnose failures by layer. Return to the ESP32/STM32 telemetry architecture and timestamp guide before replacing the scalar with a real sensor message. For integration into localization, the ROS 2 dual-EKF IMU/GPS guide needs appropriate sensor messages, frames, timestamps and covariance—not this Int32 echo topic.

More from Build Guides

Micro-ROS on ESP32 and STM32: Publishing Real-Time Sensor Telemetry over XRCE-DDS
RoboticsOct 3, 2026Advanced

Micro-ROS on ESP32 and STM32: Publishing Real-Time Sensor Telemetry over XRCE-DDS

Connect ESP32 and STM32 firmware to ROS 2 with micro-ROS. Understand XRCE-DDS, serial transport, memory ownership, time sync, and telemetry validation.

ROS 2 Telemetry on ESP32: Debugging Serial, Wi-Fi and Agent Connections
Build GuidesNov 2, 2026Intermediate

ROS 2 Telemetry on ESP32: Debugging Serial, Wi-Fi and Agent Connections

Diagnose ESP32 micro-ROS telemetry layer by layer: firmware build, serial ownership, UDP reachability, agent sessions, ROS 2 graph discovery and QoS.

ROS 2 for Beginners: Setting Up Your First Project
RoboticsApr 29, 2026Foundational

ROS 2 for Beginners: Setting Up Your First Project

A gentle introduction to ROS 2 concepts, installation, and getting a first publisher/subscriber project running before diving into Nav2 and SLAM.