Detailed content
🧬 What micro-ROS is
The ESP32 has little memory and doesn't run Linux, so it can't fit the full ROS 2 stack. micro-ROS micro-ROS is a lightweight version made for microcontrollers: the ESP32 creates nodes, publishers, and subscribers with the rclc library (the C API), and a program on the PC or Pi, the micro-ROS Agent, connects it to the rest of the graph. To other nodes, the ESP32 appears as another ROS 2 node, with topics you can read using ros2 topic echo.
🌱 New here?
The ESP32 doesn't speak DDS directly: it speaks XRCE-DDS (DDS for extremely resource-constrained devices), a lightweight protocol in which the ESP32 is the client and the agent is the server that translates everything to DDS. That's why the agent needs to be running first: without it, the ESP32 has no one to talk to. The executor is the firmware loop that calls your callbacks when a message arrives or the timer expires.
How to read this map: the path has two segments. On the left, the ESP32 and agent communicate over the lightweight protocol (cable or Wi-Fi). On the right, the agent passes everything to DDS, and regular nodes don't even know there's a microcontroller. If the agent (the glowing box) goes down, the ESP32 is isolated.
✓ Keep on the ESP32
- ✓Motor PWM and encoder readings.
- ✓The safety stop when the command disappears.
- ✓Fast readings from the IMU and simple sensors.
✗ Don't put on the ESP32
- ✗SLAM, maps, or route planning.
- ✗Large messages (images, point clouds).
- ✗Dozens of topics: each one uses memory.
Why learn this
It’s the same division of labor as in Track 3 (the Pi thinks, the ESP32 acts), but now there’s no need to invent a protocol: the ESP32 speaks ROS. What works with the Gazebo robot (teleop, SLAM, Nav2) also works with the real robot by changing only who publishes and subscribes to the topics.
Key concepts
🛰️ Install the micro-ROS Agent
The simplest way to run the agent is with the official Docker image microros/micro-ros-agent:<distro>: nothing to compile. The agent, ESP32 library, and your ROS 2 installation must all use the same distro. The agent listens on a transport: serial (the ESP32’s USB cable) or UDP (Wi-Fi, on the port you choose, conventionally 8888). Start with the cable: it’s easier to diagnose.
# find the ESP32 port (DevKit: ttyUSB0; S3 with native USB: ttyACM0)
ls /dev/ttyUSB* /dev/ttyACM*
# agent over the serial port (USB cable)
docker run -it --rm -v /dev:/dev --privileged --net=host \
microros/micro-ros-agent:<distro> serial --dev /dev/ttyUSB0 -b 115200
# OR agent over Wi-Fi (UDP), for the ESP32 without a cable
docker run -it --rm --net=host microros/micro-ros-agent:<distro> udp4 --port 8888
How to check
- ✓The agent prints "TermiosAgentLinux.cpp | init | running..." (serial) or "UDPv4AgentLinux.cpp | init | running..." (UDP), then waits.
- ✓When the ESP32 connects, lines like "create_client", "create_topic", and "create_publisher" appear.
- ✗"Unable to find image"? The tag for your distro hasn’t been published yet. Check Docker Hub.
How to read this map: The amber column is the cable; the cyan one is Wi-Fi. For the robot in Track 3, the best of both worlds is a USB cable between the ESP32 and Raspberry Pi, with the agent running on the Pi: it’s stable, and the robot stays free to move. Wi-Fi makes sense when there’s no Pi on board.
Why learn this
Almost every case of “the ESP32 doesn’t show up in ROS” comes down to the agent being off, the wrong port, or a different distro. Knowing how to start the agent and read its messages solves most problems before you open the firmware.
Key concepts
📤 ESP32 publishing data (encoders, IMU)
The firmware below is for the whole robot: this topic explains the part that publishes, and the next one explains the part that subscribes. Every 50 ms, a timer copies the counts from both encoders into a std_msgs/msg/Int32MultiArray message and publishes it on /ticks. The position calculation happens on the PC (topic 5): the ESP32 just measures, quickly and without missing pulses.
Library setup, step by step
Download the release for your distro
On the repository’s releases page micro-ROS/micro_ros_arduino on GitHub, download the .zip marked with your distro (there are releases for Jazzy; check whether one is available for Lyrical).
Install it in Arduino IDE 2.x
Sketch → Include Library → Add .ZIP Library. The release README specifies which versions of the esp32 package it supports; if it doesn’t compile with the 3.x package you have installed, follow its instructions or use the ESP-IDF component micro_ros_espidf_component (Jazzy = 24.x, Lyrical = 26.x).
Connect and upload
Wire the TB6612FNG as in Track 1 (pins are at the top of the code), connect the encoders to GPIO 18 and 19, and use a common GND. Upload with the agent OFF (it occupies the port), then start the agent.
Goal: ESP32 as a node /esp32_base, publishing /ticks and following /cmd_vel, with automatic stopping. PWM uses the 3.x API (ledcAttach e ledcWrite takes the pin).
// Robot base with micro-ROS (micro_ros_arduino library for YOUR distro, esp32 package 3.x)
// Publishes /ticks (encoders) every 50 ms and subscribes to /cmd_vel (geometry_msgs/Twist).
#include <micro_ros_arduino.h>
#include <rcl/rcl.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <std_msgs/msg/int32_multi_array.h>
#include <geometry_msgs/msg/twist.h>
const int PINO_LED = 2;
const int AIN1 = 26, AIN2 = 27, PWMA = 25; // left motor (TB6612FNG)
const int BIN1 = 16, BIN2 = 17, PWMB = 4; // right motor
const int STBY = 23;
const int ENC_ESQ = 18, ENC_DIR = 19; // channel A of each encoder
const float SEPARACAO = 0.15; // distance between wheels in m: MEASURE your robot
const float V_MAX = 0.40; // m/s at PWM 255: measure with a tape measure
const unsigned long TIMEOUT_MS = 500; // no command for 0.5 s = stop
volatile int32_t ticksEsq = 0, ticksDir = 0;
volatile int sentidoEsq = 1, sentidoDir = 1;
unsigned long ultimoCmd = 0;
rcl_publisher_t pubTicks;
rcl_subscription_t subCmd;
std_msgs__msg__Int32MultiArray msgTicks;
int32_t bufTicks[2];
geometry_msgs__msg__Twist msgCmd;
rclc_executor_t executor;
rclc_support_t support;
rcl_allocator_t allocator;
rcl_node_t node;
rcl_timer_t timer;
#define RCCHECK(fn) { rcl_ret_t rc = fn; if (rc != RCL_RET_OK) erro(); }
#define RCSOFT(fn) { rcl_ret_t rc = fn; (void)rc; }
void erro() { // rapidly blinking LED = failed to create something
ledcWrite(PWMA, 0); ledcWrite(PWMB, 0);
while (true) { digitalWrite(PINO_LED, !digitalRead(PINO_LED)); delay(100); }
}
void IRAM_ATTR isrEsq() { ticksEsq += sentidoEsq; }
void IRAM_ATTR isrDir() { ticksDir += sentidoDir; }
// v in m/s (negative = reverse) becomes direction + PWM from 0 to 255
void motor(int in1, int in2, int pwm, float v, volatile int &sentido) {
int duty = constrain((int)(fabs(v) / V_MAX * 255.0), 0, 255);
digitalWrite(in1, v >= 0 ? HIGH : LOW);
digitalWrite(in2, v >= 0 ? LOW : HIGH);
ledcWrite(pwm, duty);
sentido = (v >= 0) ? 1 : -1; // single-channel encoder: direction comes from the command
}
// Topic 4: a Twist arrives on /cmd_vel
void aoReceberCmd(const void *msgin) {
const geometry_msgs__msg__Twist *cmd = (const geometry_msgs__msg__Twist *)msgin;
float v = cmd->linear.x, w = cmd->angular.z;
motor(AIN1, AIN2, PWMA, v - w * SEPARACAO / 2.0, sentidoEsq);
motor(BIN1, BIN2, PWMB, v + w * SEPARACAO / 2.0, sentidoDir);
ultimoCmd = millis();
}
// Topic 3: publishes ticks every 50 ms and checks the watchdog
void aoTimer(rcl_timer_t *t, int64_t ultimo) {
(void)ultimo; if (t == NULL) return;
bufTicks[0] = ticksEsq; bufTicks[1] = ticksDir;
RCSOFT(rcl_publish(&pubTicks, &msgTicks, NULL));
if (millis() - ultimoCmd > TIMEOUT_MS) { ledcWrite(PWMA, 0); ledcWrite(PWMB, 0); }
}
void setup() {
pinMode(PINO_LED, OUTPUT);
set_microros_transports(); // USB serial at 115200 (do not use Serial.print!)
int saidas[] = {AIN1, AIN2, BIN1, BIN2, STBY};
for (int p : saidas) pinMode(p, OUTPUT);
digitalWrite(STBY, HIGH); // take the driver out of standby mode
ledcAttach(PWMA, 20000, 8); // 20 kHz, 8 bits (API 3.x)
ledcAttach(PWMB, 20000, 8);
pinMode(ENC_ESQ, INPUT_PULLUP); pinMode(ENC_DIR, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_ESQ), isrEsq, RISING);
attachInterrupt(digitalPinToInterrupt(ENC_DIR), isrDir, RISING);
delay(2000); // time for the agent to respond
allocator = rcl_get_default_allocator();
RCCHECK(rclc_support_init(&support, 0, NULL, &allocator));
RCCHECK(rclc_node_init_default(&node, "esp32_base", "", &support));
RCCHECK(rclc_publisher_init_default(&pubTicks, &node,
ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32MultiArray), "ticks"));
RCCHECK(rclc_subscription_init_default(&subCmd, &node,
ROSIDL_GET_MSG_TYPE_SUPPORT(geometry_msgs, msg, Twist), "cmd_vel"));
// Jazzy and Lyrical use timer version "2" (older distros: rclc_timer_init_default)
RCCHECK(rclc_timer_init_default2(&timer, &support, RCL_MS_TO_NS(50), aoTimer, true));
RCCHECK(rclc_executor_init(&executor, &support.context, 2, &allocator));
RCCHECK(rclc_executor_add_subscription(&executor, &subCmd, &msgCmd, &aoReceberCmd, ON_NEW_DATA));
RCCHECK(rclc_executor_add_timer(&executor, &timer));
msgTicks.data.data = bufTicks; // the message points to the fixed array of 2
msgTicks.data.size = 2;
msgTicks.data.capacity = 2;
digitalWrite(PINO_LED, HIGH); // solid LED = connected
}
void loop() {
RCSOFT(rclc_executor_spin_some(&executor, RCL_MS_TO_NS(10)));
}
How to check
- ✓GPIO 2 LED stays on, and the agent shows "create_publisher" and "create_subscriber".
- ✓ros2 node list terminal /esp32_base; ros2 topic hz /ticks shows about 20 Hz.
- ✓ros2 topic echo /ticks: turn a wheel by hand and the corresponding number in data changes.
- ✗Rapidly blinking LED: the ESP32 couldn’t find the agent. Start the agent and press EN (reset) on the ESP32.
💡 What about the IMU?
To publish the MPU-6050 from Track 2, use the type sensor_msgs/msg/Imu (linear acceleration in m/s², angular velocity in rad/s) in a second publisher on the same timer, with header.frame_id pointing to a link imu_link that you add to the URDF. Start with the encoders alone: they’re enough to map.
Why learn this
The pattern “timer copies the data, executor publishes” works for any sensor: distance, battery, emergency stop button. Counting pulses on the ESP32 using interrupts is what ensures no wheel rotation is missed while the Pi is busy with the map.
Key concepts
📥 ESP32 listening to /cmd_vel and turning the motors
O /cmd_vel says how the entire robot should move: v (meters per second forward) and ω (radians per second of rotation). The ESP32 needs to convert this into the speed of each wheel. This is differential kinematics: to turn left, the right wheel moves faster than the left. In the firmware function aoReceberCmd that's two lines.
How to read this map: top view, facing right; the top wheel is the robot's left wheel. The cyan arrows are proportional in length to each wheel's speed: the bottom one (right wheel) is longer, so it turns left, upward in the figure (amber arrow). L is the distance between the wheels, the firmware constant SEPARACAO in the firmware.
🌱 New here?
Command watchdog means "if no new command arrives within 0.5 s, stop." Teleop and Nav2 publish several times per second; if Wi-Fi drops or the PC freezes, messages stop, and without a watchdog the robot would keep moving at its last speed until it hits something. In the firmware, it runs at the end of aoTimer.
Goal: test the subscription with the robot SUSPENDED (wheels off the ground), first with a fixed command and then with the keyboard.
# 1) both wheels forward at 0.15 m/s for 3 seconds (10 messages/s)
timeout 3 ros2 topic pub --rate 10 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.15}}"
# 2) spin in place: wheels in opposite directions
timeout 3 ros2 topic pub --rate 10 /cmd_vel geometry_msgs/msg/Twist "{angular: {z: 1.0}}"
# 3) drive with the keyboard, as in Gazebo 4.2
ros2 run teleop_twist_keyboard teleop_twist_keyboard
How to check
- ✓Command 1: both wheels turn forward and STOP on their own about half a second after the timeout publish ends.
- ✓Command 2: the right wheel moves forward, the left moves backward.
- ✗One wheel turns the wrong way? Swap that motor's wires on the driver (or the IN1/IN2 pins in the code).
✓ First-test safety
- ✓Robot on a stand, with the wheels clear of the table.
- ✓Low speeds (0.1 to 0.2 m/s) until you are confident.
- ✓Keep your hand near the motor battery switch.
✗ Never
- ✗Remove the watchdog "because it's in the way".
- ✗Test it on the floor, near the stairs, the first time.
- ✗Power the motors through the ESP32's USB port.
Why learn this
This is when the real robot starts obeying ROS 2. From here on, any node that publishes to /cmd_vel, from the keyboard to Nav2, drives your robot without adding a single line to the firmware.
Key concepts
📍 Real robot odometry in ROS 2
In Gazebo, the DiffDrive plugin provided /odom and the TF odom → base_link for free. On the real robot, you do it: a Python node on the Pi or PC subscribes to /ticks, converts pulses to meters, and accumulates the position (x, y, θ)—the same odometry from Track 2, now published in the format SLAM and Nav2 expect.
How to read this map: pulses enter on the left; in the middle, three calculations convert pulses into displacement and displacement into accumulated position; on the right are the two outputs Gazebo provided. That's why the slam_toolbox from 4.4 works the same in simulation and on the real robot.
Goal: add the node odometry to the package meu_robo from 4.1. The three parameters come from your robot: pulses per wheel revolution (check the motor datasheet, or count by turning it one full revolution by hand), wheel radius, and distance between the wheels.
# Converts /ticks (ESP32) into /odom and the odom -> base_link TF
import math
import rclpy
from rclpy.node import Node
from rclpy.executors import ExternalShutdownException
from std_msgs.msg import Int32MultiArray
from nav_msgs.msg import Odometry
from geometry_msgs.msg import TransformStamped
from tf2_ros import TransformBroadcaster
class Odometria(Node):
def __init__(self):
super().__init__('odometria')
self.tpv = self.declare_parameter('ticks_por_volta', 360).value
self.raio = self.declare_parameter('raio', 0.033).value # m
self.sep = self.declare_parameter('separacao', 0.15).value # m, same as the firmware
self.x = self.y = self.th = 0.0
self.anterior, self.t_anterior = None, None
self.pub = self.create_publisher(Odometry, 'odom', 10)
self.tf = TransformBroadcaster(self)
self.create_subscription(Int32MultiArray, 'ticks', self.ao_receber, 10)
def ao_receber(self, msg):
if len(msg.data) < 2:
return
agora, ticks = self.get_clock().now(), list(msg.data[:2])
if self.anterior is None: # first message: just store it
self.anterior, self.t_anterior = ticks, agora
return
m_por_tick = 2 * math.pi * self.raio / self.tpv
d_esq = (ticks[0] - self.anterior[0]) * m_por_tick
d_dir = (ticks[1] - self.anterior[1]) * m_por_tick
dt = (agora - self.t_anterior).nanoseconds / 1e9
self.anterior, self.t_anterior = ticks, agora
d = (d_esq + d_dir) / 2 # how far the center moved
dth = (d_dir - d_esq) / self.sep # how much it turned
self.x += d * math.cos(self.th + dth / 2)
self.y += d * math.sin(self.th + dth / 2)
self.th += dth
qz, qw = math.sin(self.th / 2), math.cos(self.th / 2) # angle -> quaternion
stamp = agora.to_msg()
odom = Odometry()
odom.header.stamp, odom.header.frame_id, odom.child_frame_id = stamp, 'odom', 'base_link'
odom.pose.pose.position.x, odom.pose.pose.position.y = self.x, self.y
odom.pose.pose.orientation.z, odom.pose.pose.orientation.w = qz, qw
if dt > 0:
odom.twist.twist.linear.x, odom.twist.twist.angular.z = d / dt, dth / dt
self.pub.publish(odom)
t = TransformStamped()
t.header.stamp, t.header.frame_id, t.child_frame_id = stamp, 'odom', 'base_link'
t.transform.translation.x, t.transform.translation.y = self.x, self.y
t.transform.rotation.z, t.transform.rotation.w = qz, qw
self.tf.sendTransform(t)
def main(args=None):
rclpy.init(args=args)
no = Odometria()
try:
rclpy.spin(no)
except (KeyboardInterrupt, ExternalShutdownException):
pass
finally:
no.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()
# 1) in meu_robo's setup.py, add to console_scripts:
# 'odometria = meu_robo.odometria:main',
# 2) in package.xml, add exec_depend for nav_msgs, geometry_msgs, and tf2_ros
cd ~/ros2_ws && colcon build --symlink-install --packages-select meu_robo
source install/setup.bash
ros2 run meu_robo odometria --ros-args -p ticks_por_volta:=<pulsos> -p raio:=0.033 -p separacao:=0.15
How to check
- ✓Push the robot 1 m straight across the floor: ros2 topic echo /odom --field pose.pose.position should show x close to 1.0. If it reads 0.9 or 1.1, adjust radius.
- ✓Turn the robot one full revolution in place: θ should return close to its starting value. If it doesn't, adjust separacao.
- ✓In RViz, set Fixed Frame odom and add a TF display: the base_link frame moves with the robot.
Why learn this
Calibrated odometry is half of a good map. SLAM from 4.4 gradually corrects errors, but it starts from where odometry says the robot is: if odometry is off by 10% every meter, the map bends before any correction can happen.
Key concepts
🩺 Common issues (Wi-Fi vs. serial, time synchronization)
Almost every micro-ROS issue falls into four groups: connection (agent, port, Wi-Fi), version (different distributions across the three ends), memory (too many topics on the ESP32), and time (the ESP32 clock differs from the PC clock). Work down the table from top to bottom before changing the code.
| Symptom | Likely cause and fix |
|---|---|
| Upload fails: "port busy" | The agent is holding the port. Stop the agent, upload, then start it again. |
| LED flashing rapidly | Agent turned off, wrong port, or a baud rate other than 115200. After starting the agent, press EN. |
| Node appears, but no topics | The library distro differs from the agent and ROS. Use the same distro for all three. |
| Stops responding after restarting the agent | The firmware does not reconnect on its own. Press EN, or implement reconnection (below). |
| "create_publisher" fails on the 5th topic | Memory limit in the precompiled library. Combine data into a single message (like /ticks). |
| Wi-Fi: commands arrive in bursts | Weak signal or congested network. Test with the cable; preferably run the agent on the onboard Pi. |
Switching to Wi-Fi: in the firmware, replace the serial transport line with this one, filling in the three values between the angle brackets; on the PC, use the agent udp4 --port 8888 from topic 2. The IP is the address of the computer running the agent (hostname -I).
char rede[] = "<sua_rede_wifi>";
char senha[] = "<sua_senha>";
char ip_agente[] = "<ip_do_pc_ou_pi>";
set_microros_wifi_transports(rede, senha, ip_agente, 8888); // instead of set_microros_transports()
How to check: the UDP agent shows "create_client" with the ESP32's IP; ros2 topic hz /ticks it stays close to 20 Hz with the robot far from the PC.
🌱 New here?
Synchronizing time: the ESP32 starts up with its clock at zero, while the PC uses real time. If the ESP32 timestamps messages with its own clock, ROS thinks they are from 1970 and TF discards them. So, in this module, the odometry node on the PC timestamps messages when they arrive. If you need to timestamp on the ESP32 (for a fast IMU, for example), call rmw_uros_sync_session(1000) after connecting and use rmw_uros_epoch_nanos() as the time, with #include <rmw_microros/rmw_microros.h>.
💡 Automatic reconnection
To keep the robot running when an agent restarts, use a state machine: waiting for agent (check with rmw_uros_ping_agent(100, 1) every half second), create entities, connected (spin; if the ping fails, set the motors to zero) and destroy entities, then return to the beginning. The library's "micro-ros_reconnection_example" shows the complete skeleton; add the parts from this firmware to it.
✓ Diagnostic sequence
- ✓1. Is the agent running and showing "running"?
- ✓2. ros2 node list does /esp32_base appear?
- ✓3. ros2 topic hz on the ESP32 topics?
✗ Time waster
- ✗Changing the firmware before checking the agent.
- ✗Debugging with Serial.print: the serial port is used by micro-ROS.
- ✗Starting with Wi-Fi before getting it to work over the cable.
Why learn this
In project 4.6, the robot will run far from the PC, connected for hours. Knowing how to diagnose the connection, version, and time is what separates a robot that works on the bench from one that works in the room.
Key concepts
✅ Module summary
To go further
- micro.ros.org — overview, concepts, and official tutorials
- micro_ros_arduino — releases by distro and examples (including reconnection)
- micro_ros_espidf_component — the ESP-IDF route
- Docker Hub agent image — check the tag for your distro
- docs.ros.org — nav_msgs, geometry_msgs, and tf2 messages
Next module
4.4 · LiDAR and maps (SLAM): the laser goes on the robot, and slam_toolbox draws the map while figuring out where the robot is.