In this guide you’ll turn an ESP32 into a real ROS 2 node using micro-ROS, connect it to your PC with nothing but a USB cable, and drive a DC motor through an L298N by publishing to a ROS 2 topic. The firmware reconnects on its own if the agent dies, and stops the motor if commands stop arriving.
Motivation
Most robots have a computer running ROS 2 and a microcontroller doing the low-level work: PWM, encoders, sensors. The classic way to glue them together is a custom serial protocol and a bridge node you have to write and maintain yourself. micro-ROS removes that layer: the microcontroller is a ROS 2 node, with its own topics, and shows up in ros2 node list like any other.
How micro-ROS Works
A microcontroller can’t run a full DDS stack, so micro-ROS splits the work in two:
ESP32 (micro-ROS client) ⇄ USB serial ⇄ micro-ROS Agent (PC) ⇄ DDS ⇄ ROS 2 nodes
- The client runs on the ESP32 and talks a lightweight protocol (XRCE-DDS) over a transport: serial, UDP, etc.
- The agent runs on the PC, receives that traffic and creates the real DDS entities on the ESP32’s behalf.
To the rest of ROS 2, the ESP32 looks like a normal node.
Required Materials
- ESP32 DevKit (any ESP32-WROOM board with a USB-serial chip).
- L298N motor driver module.
- DC motor (a small 6–12 V gear motor is perfect).
- External power supply for the motor (7–12 V; batteries or a bench supply).
- USB data cable (not a charge-only one).
- Dupont wires.
- A PC with Ubuntu 22.04 and ROS 2 Humble installed.
- Arduino IDE 2.x.
1. Wiring
| L298N | Connect to |
|---|---|
| IN1 | ESP32 GPIO2 |
| IN2 | ESP32 GPIO4 |
| ENA | Leave the jumper on (default) |
| OUT1 / OUT2 | Motor terminals |
| 12V | Motor supply + |
| GND | Motor supply – and ESP32 GND |
The ground must be shared. Without a common GND between the ESP32 and the L298N, the logic signals have no reference and the motor won’t move (or will move erratically).
Two ways to control speed, selected with USE_ENA in the sketch:
USE_ENA 0(jumper on ENA): ENA is always enabled and PWM goes directly to IN1 or IN2. Only two wires needed.USE_ENA 1(jumper removed): IN1/IN2 set the direction and the PWM goes to ENA, wired to GPIO25.
GPIO2 is a strapping pin and also drives the onboard LED on most DevKits. It works fine here, but if uploads fail, see Troubleshooting.
2. Arduino IDE Setup
- Install the ESP32 board package: Boards Manager → search
esp32(by Espressif) → install. The sketch compiles on core 2.x and 3.x. - Download the micro_ros_arduino library for Humble. It’s not in the Library Manager: go to the releases page and download the ZIP of the latest
-humblerelease. - In Arduino IDE: Sketch → Include Library → Add .ZIP Library… and select the ZIP.
- Select your board (ESP32 Dev Module) and port.
3. The Firmware, Piece by Piece
Let’s take the sketch apart block by block. The complete file, ready to copy, is at the end of this section.
3.1 Headers
#include <micro_ros_arduino.h>
#include <rcl/rcl.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <rmw_microros/rmw_microros.h>
#include <std_msgs/msg/float32.h>
micro_ros_arduino.h: the transport glue for Arduino; providesset_microros_transports().rcl/rcl.h: the ROS 2 client library core (the same C layerrclcppandrclpysit on).rclc/rclc.handrclc/executor.h: rclc, a thin C convenience layer for microcontrollers. It saves you most of therclboilerplate and gives you an executor to dispatch callbacks.rmw_microros/rmw_microros.h: micro-ROS-specific extras, herermw_uros_ping_agent()to check whether the agent is alive.std_msgs/msg/float32.h: the C type of the message we’ll receive,std_msgs__msg__Float32. Every ROS message type has a header like this one.
3.2 Configuration
#define PIN_IN1 2 // D2 -> IN1
#define PIN_IN2 4 // D4 -> IN2
#define USE_ENA 0
#define PIN_ENA 25
#define PWM_FREQ 1000 // the L298N is slow; 1 kHz works well
#define PWM_RES 8 // 0-255
#define CH_A 0 // channels (core 2.x only)
#define CH_B 1
#define TIMEOUT_MS 500 // no commands for this long -> motor stopped
Everything you might want to change lives here:
- Pins: IN1/IN2 on GPIO2/GPIO4, and ENA on GPIO25 if you use it.
USE_ENA: picks one of the two control modes from the Wiring section. Because it’s a#define, the unused mode isn’t even compiled.PWM_FREQ: the L298N uses bipolar transistors that switch slowly. At tens of kHz they spend too much time half-on and get hot; 1 kHz is a safe default.PWM_RES: 8 bits, so duty goes from 0 to 255.CH_A/CH_B: LEDC channels, only used by core 2.x (more on that below).TIMEOUT_MS: how long the motor keeps going without new commands.
3.3 Global State
rclc_support_t support;
rcl_allocator_t allocator;
rcl_node_t node;
rcl_subscription_t sub;
rclc_executor_t executor;
std_msgs__msg__Float32 msg;
unsigned long last_cmd = 0;
enum AgentState { WAITING_AGENT, AGENT_AVAILABLE, AGENT_CONNECTED, AGENT_DISCONNECTED };
AgentState state = WAITING_AGENT;
These are the ROS objects. They’re global because they must outlive setup() and be reachable from loop():
support: holds the micro-ROS context (the session with the agent).allocator: how micro-ROS gets memory. The default one usesmalloc/free.node,sub,executor: the node, the subscription and the callback dispatcher.msg: the buffer where incoming messages are written. The executor fills it in before calling the callback, so it must be global, not a local variable.last_cmd: themillis()of the last command, for the safety timeout.state: the current state of the connection state machine.
3.4 Timing Helper
#define EXECUTE_EVERY_N_MS(MS, X) do { \
static int64_t t_init = -1; \
if (t_init == -1) t_init = uxr_millis(); \
if ((int64_t)uxr_millis() - t_init > MS) { X; t_init = uxr_millis(); } \
} while (0)
A non-blocking “run X every MS milliseconds”, taken from the official micro-ROS examples. The trick is the static variable: each place where the macro is used gets its own t_init, so the 500 ms and 200 ms pings in loop() keep independent timers. The do { ... } while (0) wrapper lets the macro behave like a single statement after an if or inside a case. uxr_millis() is micro-ROS’s own millisecond clock.
3.5 PWM on Core 2.x and 3.x
void pwm_attach(int pin, int ch) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
ledcAttach(pin, PWM_FREQ, PWM_RES);
#else
ledcSetup(ch, PWM_FREQ, PWM_RES);
ledcAttachPin(pin, ch);
#endif
}
void pwm_write(int pin, int ch, int duty) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
ledcWrite(pin, duty);
#else
ledcWrite(ch, duty);
#endif
}
The ESP32 generates PWM with its LEDC peripheral, and core 3.x changed its API:
| Core 2.x | Core 3.x | |
|---|---|---|
| Setup | ledcSetup(channel, freq, res) + ledcAttachPin(pin, channel) | ledcAttach(pin, freq, res) |
| Write | ledcWrite(channel, duty) | ledcWrite(pin, duty) |
The two helpers take both the pin and the channel and use whichever one the installed core needs. ESP_ARDUINO_VERSION_MAJOR is defined by the core itself, so the choice is made at compile time.
3.6 Driving the Motor
void set_motor(float v) {
v = constrain(v, -1.0f, 1.0f);
int duty = (int)(fabs(v) * 255);
#if USE_ENA
digitalWrite(PIN_IN1, v > 0);
digitalWrite(PIN_IN2, v < 0);
pwm_write(PIN_ENA, CH_A, duty);
#else
// Jumper on ENA: PWM on one side, 0 on the other
if (v > 0) { pwm_write(PIN_IN1, CH_A, duty); pwm_write(PIN_IN2, CH_B, 0); }
else if (v < 0) { pwm_write(PIN_IN1, CH_A, 0); pwm_write(PIN_IN2, CH_B, duty); }
else { pwm_write(PIN_IN1, CH_A, 0); pwm_write(PIN_IN2, CH_B, 0); }
#endif
}
The only function that touches the hardware:
constrainclamps the command to [-1, 1], so a bad message like{data: 7.0}can’t produce an invalid duty.fabs(v) * 255turns the magnitude into a duty cycle; the sign is the direction.- Then, depending on the mode:
USE_ENA 1: IN1/IN2 are plain digital pins.v > 0gives IN1=1, IN2=0 (forward);v < 0the opposite;v == 0both 0 (free stop). PWM goes on ENA.USE_ENA 0: ENA is always on, so speed comes from PWM-ing one input while the other stays at 0. Which input gets the PWM sets the direction.
Both inputs are never high at the same time, which on the L298N would be an active brake.
3.7 The Subscription Callback
void cmd_callback(const void *msgin) {
const std_msgs__msg__Float32 *m = (const std_msgs__msg__Float32 *)msgin;
set_motor(m->data);
last_cmd = millis();
}
rclc callbacks are plain C, so the message arrives as const void * and you cast it to the real type. Then it applies the speed and refreshes last_cmd, which is what keeps the safety timeout from firing. Keep callbacks short like this one: they run inside rclc_executor_spin_some(), in the middle of loop().
3.8 Creating the ROS Entities
bool create_entities() {
allocator = rcl_get_default_allocator();
if (rclc_support_init(&support, 0, NULL, &allocator) != RCL_RET_OK) return false;
if (rclc_node_init_default(&node, "esp32_motor", "", &support) != RCL_RET_OK) return false;
if (rclc_subscription_init_default(
&sub, &node,
ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Float32),
"motor_cmd") != RCL_RET_OK) return false;
executor = rclc_executor_get_zero_initialized_executor();
if (rclc_executor_init(&executor, &support.context, 1, &allocator) != RCL_RET_OK) return false;
if (rclc_executor_add_subscription(&executor, &sub, &msg, &cmd_callback, ON_NEW_DATA) != RCL_RET_OK) return false;
return true;
}
The micro-ROS equivalent of rclcpp::init() + creating a node + create_subscription(), step by step:
rclc_support_init: opens the session with the agent. The0, NULLareargc/argv, unused here. This is the step that fails if the agent isn’t there.rclc_node_init_default: creates the nodeesp32_motorwith an empty namespace ("").rclc_subscription_init_default: subscribes tomotor_cmd.ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Float32)tells micro-ROS how to deserialize the type. The_defaultvariant uses reliable QoS. The topic name has no leading/, so it resolves relative to the node’s namespace; with an empty namespace that’s/motor_cmd.rclc_executor_init: creates an executor with room for 1 handle. Every subscription, timer or service you add later needs a slot, so if you add a publisher timer, this becomes 2. Forgetting this is the most common micro-ROS bug.rclc_executor_add_subscription: links subscription, message buffer and callback.ON_NEW_DATAmeans “only call the callback when a message actually arrived”.
Each step returns false on failure instead of crashing, so the state machine can retry later.
3.9 Destroying the ROS Entities
void destroy_entities() {
rmw_context_t *rmw_context = rcl_context_get_rmw_context(&support.context);
(void)rmw_uros_set_context_entity_destroy_session_timeout(rmw_context, 0);
rcl_subscription_fini(&sub, &node);
rclc_executor_fini(&executor);
rcl_node_fini(&node);
rclc_support_fini(&support);
}
Frees everything in reverse order of creation. The first two lines matter: normally, destroying an entity sends a message to the agent and waits for its reply. If the agent is gone, every fini would block until it times out. Setting the destroy timeout to 0 makes cleanup instant and purely local, which is exactly what you want after a disconnection.
3.10 setup()
void setup() {
#if USE_ENA
pinMode(PIN_IN1, OUTPUT);
pinMode(PIN_IN2, OUTPUT);
pwm_attach(PIN_ENA, CH_A);
#else
pwm_attach(PIN_IN1, CH_A);
pwm_attach(PIN_IN2, CH_B);
#endif
set_motor(0);
set_microros_transports(); // serial over USB at 115200
}
Configures the pins for the chosen mode, makes sure the motor starts stopped, and calls set_microros_transports(), which sets up Serial at 115200 as the micro-ROS transport.
Notice what’s not here: no ROS entities are created in setup(). The agent might not be running yet, so that’s left to the state machine. That’s also why this sketch has no Serial.print() anywhere: the port carries micro-ROS frames, and any stray text would corrupt them.
3.11 loop(): the State Machine
void loop() {
switch (state) {
case WAITING_AGENT:
EXECUTE_EVERY_N_MS(500,
state = (RMW_RET_OK == rmw_uros_ping_agent(100, 1)) ? AGENT_AVAILABLE : WAITING_AGENT;);
break;
case AGENT_AVAILABLE:
state = create_entities() ? AGENT_CONNECTED : WAITING_AGENT;
if (state == WAITING_AGENT) destroy_entities();
break;
case AGENT_CONNECTED:
EXECUTE_EVERY_N_MS(200,
state = (RMW_RET_OK == rmw_uros_ping_agent(100, 1)) ? AGENT_CONNECTED : AGENT_DISCONNECTED;);
if (state == AGENT_CONNECTED) {
rclc_executor_spin_some(&executor, RCL_MS_TO_NS(10));
}
break;
case AGENT_DISCONNECTED:
set_motor(0);
destroy_entities();
state = WAITING_AGENT;
break;
}
// Safety: no recent commands or no agent -> motor stopped
if (state != AGENT_CONNECTED || millis() - last_cmd > TIMEOUT_MS) set_motor(0);
}
This is what makes the firmware survive the agent coming and going:
WAITING_AGENT --ping OK--> AGENT_AVAILABLE --entities created--> AGENT_CONNECTED
^ | |
+------ creation failed ---+ ping fails
| v
+------------------------------------------------------ AGENT_DISCONNECTED
WAITING_AGENT: every 500 ms,rmw_uros_ping_agent(100, 1)sends one ping and waits up to 100 ms for the answer.AGENT_AVAILABLE: the agent answered, so create the entities. If that fails halfway, clean up whatever got created and go back to waiting.AGENT_CONNECTED: normal operation.rclc_executor_spin_somewaits up to 10 ms for incoming data and runscmd_callbackif a message arrived. Every 200 ms it pings again to detect a dead agent.AGENT_DISCONNECTED: stop the motor, free everything, start over.
Result: it doesn’t matter whether you start the agent before or after the ESP32, or restart it midway; the board reconnects by itself.
The last line is the safety net. It runs every iteration, whatever the state: if we’re not connected, or more than TIMEOUT_MS passed since the last command, the motor stops. If the node publishing commands crashes or you unplug the PC, the motor doesn’t keep running. That’s why the test below publishes at 10 Hz (-r 10) instead of once. millis() - last_cmd uses unsigned arithmetic, so it stays correct even when millis() wraps around after ~49 days.
Full Sketch
Show the full sketch
// micro-ROS on ESP32 over SERIAL (USB): control a DC motor with an L298N from ROS 2
// Topic: /motor_cmd (std_msgs/Float32) -> from -1.0 (reverse) to 1.0 (forward)
// Reconnects on its own if the agent dies or starts after the ESP32.
// Requires: micro_ros_arduino library (humble branch). Builds with ESP32 Arduino core 2.x and 3.x
//
// Agent on the PC:
// ros2 run micro_ros_agent micro_ros_agent serial --dev /dev/ttyUSB0 -b 115200
// Test:
// ros2 topic pub -r 10 /motor_cmd std_msgs/msg/Float32 "{data: 0.5}"
//
// IMPORTANT: do not use Serial.print() in this sketch. micro-ROS owns the serial port.
#include <micro_ros_arduino.h>
#include <rcl/rcl.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <rmw_microros/rmw_microros.h>
#include <std_msgs/msg/float32.h>
// ---- L298N wiring ----
#define PIN_IN1 2 // D2 -> IN1
#define PIN_IN2 4 // D4 -> IN2
// USE_ENA = 0 -> ENA jumper in place: speed is controlled with PWM on IN1/IN2
// USE_ENA = 1 -> jumper removed and ENA wired to PIN_ENA
#define USE_ENA 0
#define PIN_ENA 25
#define PWM_FREQ 1000 // the L298N is slow; 1 kHz works well
#define PWM_RES 8 // 0-255
#define CH_A 0 // channels (core 2.x only)
#define CH_B 1
#define TIMEOUT_MS 500 // no commands for this long -> motor stopped
// ---------------------------
rclc_support_t support;
rcl_allocator_t allocator;
rcl_node_t node;
rcl_subscription_t sub;
rclc_executor_t executor;
std_msgs__msg__Float32 msg;
unsigned long last_cmd = 0;
enum AgentState { WAITING_AGENT, AGENT_AVAILABLE, AGENT_CONNECTED, AGENT_DISCONNECTED };
AgentState state = WAITING_AGENT;
#define EXECUTE_EVERY_N_MS(MS, X) do { \
static int64_t t_init = -1; \
if (t_init == -1) t_init = uxr_millis(); \
if ((int64_t)uxr_millis() - t_init > MS) { X; t_init = uxr_millis(); } \
} while (0)
// ---- PWM compatible with core 2.x and 3.x ----
void pwm_attach(int pin, int ch) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
ledcAttach(pin, PWM_FREQ, PWM_RES);
#else
ledcSetup(ch, PWM_FREQ, PWM_RES);
ledcAttachPin(pin, ch);
#endif
}
void pwm_write(int pin, int ch, int duty) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
ledcWrite(pin, duty);
#else
ledcWrite(ch, duty);
#endif
}
void set_motor(float v) {
v = constrain(v, -1.0f, 1.0f);
int duty = (int)(fabs(v) * 255);
#if USE_ENA
digitalWrite(PIN_IN1, v > 0);
digitalWrite(PIN_IN2, v < 0);
pwm_write(PIN_ENA, CH_A, duty);
#else
// Jumper on ENA: PWM on one side, 0 on the other
if (v > 0) { pwm_write(PIN_IN1, CH_A, duty); pwm_write(PIN_IN2, CH_B, 0); }
else if (v < 0) { pwm_write(PIN_IN1, CH_A, 0); pwm_write(PIN_IN2, CH_B, duty); }
else { pwm_write(PIN_IN1, CH_A, 0); pwm_write(PIN_IN2, CH_B, 0); }
#endif
}
void cmd_callback(const void *msgin) {
const std_msgs__msg__Float32 *m = (const std_msgs__msg__Float32 *)msgin;
set_motor(m->data);
last_cmd = millis();
}
bool create_entities() {
allocator = rcl_get_default_allocator();
if (rclc_support_init(&support, 0, NULL, &allocator) != RCL_RET_OK) return false;
if (rclc_node_init_default(&node, "esp32_motor", "", &support) != RCL_RET_OK) return false;
if (rclc_subscription_init_default(
&sub, &node,
ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Float32),
"motor_cmd") != RCL_RET_OK) return false;
executor = rclc_executor_get_zero_initialized_executor();
if (rclc_executor_init(&executor, &support.context, 1, &allocator) != RCL_RET_OK) return false;
if (rclc_executor_add_subscription(&executor, &sub, &msg, &cmd_callback, ON_NEW_DATA) != RCL_RET_OK) return false;
return true;
}
void destroy_entities() {
rmw_context_t *rmw_context = rcl_context_get_rmw_context(&support.context);
(void)rmw_uros_set_context_entity_destroy_session_timeout(rmw_context, 0);
rcl_subscription_fini(&sub, &node);
rclc_executor_fini(&executor);
rcl_node_fini(&node);
rclc_support_fini(&support);
}
void setup() {
#if USE_ENA
pinMode(PIN_IN1, OUTPUT);
pinMode(PIN_IN2, OUTPUT);
pwm_attach(PIN_ENA, CH_A);
#else
pwm_attach(PIN_IN1, CH_A);
pwm_attach(PIN_IN2, CH_B);
#endif
set_motor(0);
set_microros_transports(); // serial over USB at 115200
}
void loop() {
switch (state) {
case WAITING_AGENT:
EXECUTE_EVERY_N_MS(500,
state = (RMW_RET_OK == rmw_uros_ping_agent(100, 1)) ? AGENT_AVAILABLE : WAITING_AGENT;);
break;
case AGENT_AVAILABLE:
state = create_entities() ? AGENT_CONNECTED : WAITING_AGENT;
if (state == WAITING_AGENT) destroy_entities();
break;
case AGENT_CONNECTED:
EXECUTE_EVERY_N_MS(200,
state = (RMW_RET_OK == rmw_uros_ping_agent(100, 1)) ? AGENT_CONNECTED : AGENT_DISCONNECTED;);
if (state == AGENT_CONNECTED) {
rclc_executor_spin_some(&executor, RCL_MS_TO_NS(10));
}
break;
case AGENT_DISCONNECTED:
set_motor(0);
destroy_entities();
state = WAITING_AGENT;
break;
}
// Safety: no recent commands or no agent -> motor stopped
if (state != AGENT_CONNECTED || millis() - last_cmd > TIMEOUT_MS) set_motor(0);
}
Upload it. Close the Serial Monitor if it’s open: from now on the port belongs to micro-ROS.
4. Running the micro-ROS Agent
Option A: Build the agent (native)
source /opt/ros/humble/setup.bash
mkdir -p ~/microros_ws/src && cd ~/microros_ws
git clone -b humble https://github.com/micro-ROS/micro_ros_setup.git src/micro_ros_setup
rosdep update && rosdep install --from-paths src --ignore-src -y
colcon build
source install/local_setup.bash
ros2 run micro_ros_setup create_agent_ws.sh
ros2 run micro_ros_setup build_agent.sh
source install/local_setup.bash
Then start it:
ros2 run micro_ros_agent micro_ros_agent serial --dev /dev/ttyUSB0 -b 115200
Option B: Docker
If you don’t want to build anything:
docker run -it --rm -v /dev:/dev --privileged --net=host microros/micro-ros-agent:humble serial --dev /dev/ttyUSB0 -b 115200
When the ESP32 connects you’ll see the agent log session established and the creation of the participant, topic and subscriber. If nothing shows up after a few seconds, press the board’s EN button.
5. Testing from ROS 2
In another terminal (with source /opt/ros/humble/setup.bash):
ros2 node list
/esp32_motor should be listed. Now send it commands:
ros2 topic pub -r 10 /motor_cmd std_msgs/msg/Float32 "{data: 0.5}"
The motor spins forward at half speed. Try it in reverse:
ros2 topic pub -r 10 /motor_cmd std_msgs/msg/Float32 "{data: -1.0}"
Things to check:
- Timeout: stop the
ros2 topic pubwith Ctrl+C. The motor stops within 500 ms. - Reconnection: stop the agent with Ctrl+C and start it again. The motor stops, and a few seconds later
/esp32_motorreappears inros2 node listwithout touching the board.
6. Tuning
- The motor doesn’t start at low values. The L298N drops ~2 V and every motor has static friction, so below a certain duty it just hums. You can add a minimum duty in
set_motor(e.g. map 0–1 to 80–255) or just avoid small values. - Audible whine. Try raising
PWM_FREQ. The L298N loses efficiency above a few kHz, so don’t push it too far. - Slower or faster stop. Adjust
TIMEOUT_MSto your command publication rate (at least 2–3 command periods).
7. Troubleshooting
Error opening /dev/ttyUSB0/ permission denied. Add your user to thedialoutgroup and log out and back in:
sudo usermod -aG dialout $USER
- The port is busy. Close the Arduino IDE Serial Monitor (or any other program using the port).
- The port isn’t
/dev/ttyUSB0. Boards with native USB or CH9102/CDC chips may show up as/dev/ttyACM0. Check withls /dev/tty{USB,ACM}*. - The agent doesn’t show
session established. Press EN on the ESP32. Check that the baud rate is 115200 and that there’s noSerial.print()left in the sketch. - The node appears but the motor doesn’t move. Check the common GND, the motor supply, and that
USE_ENAmatches your ENA jumper. - Upload fails (“Failed to connect” / “Wrong boot mode”). GPIO2 is a strapping pin and must be low at boot to enter download mode. Disconnect the IN1 wire while flashing, or move IN1 to another pin (e.g. GPIO26) and update
PIN_IN1. - Build errors with core 3.x. If the precompiled micro-ROS library gives linker errors with your core version, install ESP32 core 2.0.17 from the Boards Manager.
- The node doesn’t show up in
ros2 node listbut the agent is connected. Make sure the agent and your terminal use the sameROS_DOMAIN_ID(micro-ROS uses domain 0 by default).
Next Steps
- Drive the motor with
teleop_twist_keyboard, using a small node that convertsgeometry_msgs/TwistintoFloat32. - Add an encoder and publish the speed from the ESP32 as feedback.
- Switch to the WiFi transport (
set_microros_wifi_transports) and lose the cable.