micro-ROS permite que un microcontrolador como el ESP32 sea un nodo más de ROS 2: publica y recibe temas como cualquier otro. Aquí lo hacemos paso a paso, partiendo de que ya tienes ROS 2, VS Code y la extensión de PlatformIO instalados, con dos ejemplos que puedes copiar: un contador que se publica cada segundo y un LED que se enciende desde un mensaje de ROS 2.

Vídeo de este tutorial: micro-ROS + ESP32: tu primer nodo de ROS 2, en YouTube. Aquí tienes el código completo y las pruebas.

Cómo encajan las piezas

micro-ROS son tres piezas:

  1. El firmware del ESP32, que se compila con PlatformIO.
  2. El agente en el PC, que traduce entre la placa y ROS 2.
  3. Tu ROS 2 de siempre, que ve los temas de la placa como los de cualquier otro nodo.
ESP32 (micro-ROS)  ──USB-serie──►  agente (Docker)  ──►  tu ROS 2 (ros2 topic …)

Para instalar micro-ROS no hace falta tocar tu instalación de ROS 2: el agente va en Docker y el firmware lo compila PlatformIO.

Qué necesitas

  • Un ESP32 de desarrollo y un cable USB de datos (algunos cables solo cargan).
  • Un LED y una resistencia de 220 Ω.
  • Un PC con Ubuntu, ROS 2, VS Code con PlatformIO y Docker.

Con qué se ha probado: Ubuntu 26.04, ROS 2 Lyrical en el PC, micro-ROS en versión Jazzy y un ESP32-D0WDQ6 con puente USB-serie CP2102. No se ha repetido desde una máquina vacía: lo que dependa de otra distribución puede variar.

1. Herramientas base y permisos

La librería de micro-ROS usa git, cmake y pip al compilar:

sudo apt install -y git cmake python3-pip python3-venv

Para el agente, Docker (en Ubuntu: sudo apt install -y docker.io) y tu usuario en el grupo docker:

sudo usermod -aG docker $USER

El puerto serie pertenece al grupo dialout. Sin este paso verás «permiso denegado» en /dev/ttyUSB0:

sudo usermod -aG dialout $USER

Cierra la sesión y vuelve a entrar para que se aplique (comprueba con id -nG).

2. El proyecto de PlatformIO

En VS Code: PlatformIO → PIO Home → New Project, con la placa Espressif ESP32 Dev Module y el framework Arduino. Abre platformio.ini y déjalo así:

[env:esp32dev]
platform = [email protected]
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps = https://github.com/micro-ROS/micro_ros_platformio
board_microros_distro = jazzy
board_microros_transport = serial

Lo que importa de cada línea:

  • platform = [email protected]: hay que fijar esta versión. Con la serie 8.x, la más reciente en noviembre de 2026, la librería de micro-ROS no compila.
  • lib_deps: la librería de micro-ROS para PlatformIO. Va en la misma línea: si la pones debajo, tiene que ir con sangría, y al copiar la sangría puede perderse.
  • board_microros_distro: la distribución con la que se construye el firmware. La librería lista humble, jazzy, kilted (por defecto) y rolling (README). Debe coincidir con la etiqueta de la imagen del agente. Aquí se usó jazzy aunque el ROS 2 del PC es Lyrical: no consta que la librería soporte Lyrical, pero el PC con Lyrical recibió los mensajes sin problema.
  • board_microros_transport = serial: la comunicación va por el cable USB.

Importante: abre VS Code desde una terminal en la que no hayas hecho source /opt/ros/<distro>/setup.bash, o desde el icono del escritorio. Si ROS está en el PATH, CMake lo mezcla con la compilación de micro-ROS y falla con No 'rosidl_typesupport_cpp' found.

3. Ejemplo 1: publicar un contador

Reemplaza src/main.cpp. El orden es: transporte → nodo → publicador → temporizador → ejecutor. Cada rclc_…_init_default crea una pieza de ROS 2, el temporizador publica cada segundo y el ejecutor reparte el trabajo.

#include <Arduino.h>
#include <micro_ros_platformio.h>
#include <rcl/rcl.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <std_msgs/msg/int32.h>

rcl_publisher_t publicador;
std_msgs__msg__Int32 mensaje;
rclc_executor_t ejecutor;
rclc_support_t soporte;
rcl_allocator_t asignador;
rcl_node_t nodo;
rcl_timer_t temporizador;

#define COMPROBAR(x) { if ((x) != RCL_RET_OK) { while (true) { delay(100); } } }

void al_temporizador(rcl_timer_t *t, int64_t) {
  if (t != NULL) {
    COMPROBAR(rcl_publish(&publicador, &mensaje, NULL));
    mensaje.data++;
  }
}

void setup() {
  Serial.begin(115200);
  set_microros_serial_transports(Serial);
  delay(2000);

  asignador = rcl_get_default_allocator();
  COMPROBAR(rclc_support_init(&soporte, 0, NULL, &asignador));
  COMPROBAR(rclc_node_init_default(&nodo, "esp32_nodo", "", &soporte));
  COMPROBAR(rclc_publisher_init_default(&publicador, &nodo,
            ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "esp32/contador"));
  COMPROBAR(rclc_timer_init_default(&temporizador, &soporte, RCL_MS_TO_NS(1000), al_temporizador));
  COMPROBAR(rclc_executor_init(&ejecutor, &soporte.context, 1, &asignador));
  COMPROBAR(rclc_executor_add_timer(&ejecutor, &temporizador));
  mensaje.data = 0;
}

void loop() {
  rclc_executor_spin_some(&ejecutor, RCL_MS_TO_NS(100));
}

Compila y carga (botones ✓ y → de PlatformIO). La primera compilación tarda de 2 a 3 minutos porque descarga las herramientas del ESP32 y compila micro-ROS. La carga debe terminar con «Hash of data verified».

4. El agente

Con la placa ya cargada, lanza el agente en una terminal (la etiqueta jazzy debe coincidir con board_microros_distro):

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

Si todo va bien verás session established, participant created y publisher created. Si no salen, pulsa el botón EN de la placa.

5. Verlo desde ROS 2

En otra terminal, con tu ROS 2 cargado:

ros2 topic list                      # aparece /esp32/contador
ros2 topic echo /esp32/contador      # data: 0, 1, 2 …  uno por segundo

6. Ejemplo 2: recibir un mensaje y encender un LED

Monta el LED: pin 17 → resistencia de 220 Ω → pata larga del LED → GND en la pata corta. Con el mismo platformio.ini, este es el programa:

#include <Arduino.h>
#include <micro_ros_platformio.h>
#include <rcl/rcl.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <std_msgs/msg/bool.h>

#define PIN_LED 17   // pin 17 → resistencia de 220 Ω → LED (pata larga) → GND (pata corta)

rcl_subscription_t suscriptor;
std_msgs__msg__Bool mensaje;
rclc_executor_t ejecutor;
rclc_support_t soporte;
rcl_allocator_t asignador;
rcl_node_t nodo;

#define COMPROBAR(x) { if ((x) != RCL_RET_OK) { while (true) { delay(100); } } }

void al_recibir(const void *entrada) {
  const std_msgs__msg__Bool *m = (const std_msgs__msg__Bool *)entrada;
  digitalWrite(PIN_LED, m->data ? HIGH : LOW);
}

void setup() {
  pinMode(PIN_LED, OUTPUT);
  Serial.begin(115200);
  set_microros_serial_transports(Serial);
  delay(2000);

  asignador = rcl_get_default_allocator();
  COMPROBAR(rclc_support_init(&soporte, 0, NULL, &asignador));
  COMPROBAR(rclc_node_init_default(&nodo, "esp32_led", "", &soporte));
  COMPROBAR(rclc_subscription_init_default(&suscriptor, &nodo,
            ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Bool), "esp32/led"));
  COMPROBAR(rclc_executor_init(&ejecutor, &soporte.context, 1, &asignador));
  COMPROBAR(rclc_executor_add_subscription(&ejecutor, &suscriptor, &mensaje, &al_recibir, ON_NEW_DATA));
}

void loop() {
  rclc_executor_spin_some(&ejecutor, RCL_MS_TO_NS(100));
}

Carga el programa, lanza el agente y, desde ROS 2:

ros2 topic pub --once /esp32/led std_msgs/msg/Bool "{data: true}"    # se enciende
ros2 topic pub --once /esp32/led std_msgs/msg/Bool "{data: false}"   # se apaga

7. Reconexión automática

Estas versiones mínimas tienen una limitación: si el agente arranca después que la placa, o se cae, la placa se queda esperando para siempre y hay que pulsar el botón EN. Las versiones siguientes usan una máquina de estados (esperando agente → agente disponible → conectado → agente perdido): hacen ping al agente cada 200 ms y, si desaparece, destruyen las entidades y vuelven a conectar cuando reaparece. En el ejemplo del LED, si se pierde el agente, el LED se apaga por seguridad.

Código completo del contador con reconexión
// Publicador de micro-ROS con reconexión automática: publica un entero que sube de uno en uno en /esp32/contador.
// Si el agente se para o se reinicia, la placa lo detecta y vuelve a conectar sola cuando el agente reaparece.
#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>

rcl_publisher_t publicador;
std_msgs__msg__Int32 mensaje;
rclc_executor_t ejecutor;
rclc_support_t soporte;
rcl_allocator_t asignador;
rcl_node_t nodo;
rcl_timer_t temporizador;

enum Estado { ESPERANDO_AGENTE, AGENTE_DISPONIBLE, AGENTE_CONECTADO, AGENTE_PERDIDO };
Estado estado = ESPERANDO_AGENTE;

#define CADA_MS(MS, ACCION) do { static uint32_t ultima = 0; if (millis() - ultima >= (MS)) { ultima = millis(); ACCION; } } while (0)

void al_temporizador(rcl_timer_t *t, int64_t) {
  if (t != NULL) {
    rcl_publish(&publicador, &mensaje, NULL);
    mensaje.data++;
  }
}

bool crear_entidades() {
  asignador = rcl_get_default_allocator();
  if (rclc_support_init(&soporte, 0, NULL, &asignador) != RCL_RET_OK) return false;
  if (rclc_node_init_default(&nodo, "esp32_nodo", "", &soporte) != RCL_RET_OK) return false;
  if (rclc_publisher_init_default(&publicador, &nodo,
        ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "esp32/contador") != RCL_RET_OK) return false;
  if (rclc_timer_init_default(&temporizador, &soporte, RCL_MS_TO_NS(1000), al_temporizador) != RCL_RET_OK) return false;
  if (rclc_executor_init(&ejecutor, &soporte.context, 1, &asignador) != RCL_RET_OK) return false;
  if (rclc_executor_add_timer(&ejecutor, &temporizador) != RCL_RET_OK) return false;
  return true;
}

void destruir_entidades() {
  rmw_context_t *contexto = rcl_context_get_rmw_context(&soporte.context);
  (void)rmw_uros_set_context_entity_destroy_session_timeout(contexto, 0);
  rcl_publisher_fini(&publicador, &nodo);
  rcl_timer_fini(&temporizador);
  rclc_executor_fini(&ejecutor);
  rcl_node_fini(&nodo);
  rclc_support_fini(&soporte);
}

void setup() {
  Serial.begin(115200);
  set_microros_serial_transports(Serial);
  mensaje.data = 0;
}

void loop() {
  switch (estado) {
    case ESPERANDO_AGENTE:
      CADA_MS(500, estado = (rmw_uros_ping_agent(100, 1) == RMW_RET_OK) ? AGENTE_DISPONIBLE : ESPERANDO_AGENTE);
      break;
    case AGENTE_DISPONIBLE:
      estado = crear_entidades() ? AGENTE_CONECTADO : ESPERANDO_AGENTE;
      if (estado == ESPERANDO_AGENTE) destruir_entidades();
      break;
    case AGENTE_CONECTADO:
      CADA_MS(200, estado = (rmw_uros_ping_agent(100, 1) == RMW_RET_OK) ? AGENTE_CONECTADO : AGENTE_PERDIDO);
      if (estado == AGENTE_CONECTADO) rclc_executor_spin_some(&ejecutor, RCL_MS_TO_NS(100));
      break;
    case AGENTE_PERDIDO:
      destruir_entidades();
      estado = ESPERANDO_AGENTE;
      break;
  }
}
Código completo del LED con reconexión
// Suscriptor de micro-ROS con reconexión automática: escucha /esp32/led (std_msgs/Bool) y enciende o apaga el LED del pin 17.
// Montaje: pin 17 -> resistencia de 220 ohm -> pata larga (ánodo) del LED -> pata corta (cátodo) a GND.
// Si el agente se para o se reinicia, la placa lo detecta, apaga el LED y vuelve a conectar sola cuando el agente reaparece.
#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/bool.h>

#define PIN_LED 17

rcl_subscription_t suscriptor;
std_msgs__msg__Bool mensaje;
rclc_executor_t ejecutor;
rclc_support_t soporte;
rcl_allocator_t asignador;
rcl_node_t nodo;

enum Estado { ESPERANDO_AGENTE, AGENTE_DISPONIBLE, AGENTE_CONECTADO, AGENTE_PERDIDO };
Estado estado = ESPERANDO_AGENTE;

// Ejecuta la acción como mucho una vez cada MS milisegundos.
#define CADA_MS(MS, ACCION) do { static uint32_t ultima = 0; if (millis() - ultima >= (MS)) { ultima = millis(); ACCION; } } while (0)

void al_recibir(const void *entrada) {
  const std_msgs__msg__Bool *m = (const std_msgs__msg__Bool *)entrada;
  digitalWrite(PIN_LED, m->data ? HIGH : LOW);
}

bool crear_entidades() {
  asignador = rcl_get_default_allocator();
  if (rclc_support_init(&soporte, 0, NULL, &asignador) != RCL_RET_OK) return false;
  if (rclc_node_init_default(&nodo, "esp32_led", "", &soporte) != RCL_RET_OK) return false;
  if (rclc_subscription_init_default(&suscriptor, &nodo,
        ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Bool), "esp32/led") != RCL_RET_OK) return false;
  if (rclc_executor_init(&ejecutor, &soporte.context, 1, &asignador) != RCL_RET_OK) return false;
  if (rclc_executor_add_subscription(&ejecutor, &suscriptor, &mensaje, &al_recibir, ON_NEW_DATA) != RCL_RET_OK) return false;
  return true;
}

void destruir_entidades() {
  rmw_context_t *contexto = rcl_context_get_rmw_context(&soporte.context);
  (void)rmw_uros_set_context_entity_destroy_session_timeout(contexto, 0);  // no esperar a un agente que ya no está
  rcl_subscription_fini(&suscriptor, &nodo);
  rclc_executor_fini(&ejecutor);
  rcl_node_fini(&nodo);
  rclc_support_fini(&soporte);
}

void setup() {
  pinMode(PIN_LED, OUTPUT);
  digitalWrite(PIN_LED, LOW);
  Serial.begin(115200);
  set_microros_serial_transports(Serial);
}

void loop() {
  switch (estado) {
    case ESPERANDO_AGENTE:
      CADA_MS(500, estado = (rmw_uros_ping_agent(100, 1) == RMW_RET_OK) ? AGENTE_DISPONIBLE : ESPERANDO_AGENTE);
      break;
    case AGENTE_DISPONIBLE:
      estado = crear_entidades() ? AGENTE_CONECTADO : ESPERANDO_AGENTE;
      if (estado == ESPERANDO_AGENTE) destruir_entidades();
      break;
    case AGENTE_CONECTADO:
      CADA_MS(200, estado = (rmw_uros_ping_agent(100, 1) == RMW_RET_OK) ? AGENTE_CONECTADO : AGENTE_PERDIDO);
      if (estado == AGENTE_CONECTADO) rclc_executor_spin_some(&ejecutor, RCL_MS_TO_NS(100));
      break;
    case AGENTE_PERDIDO:
      digitalWrite(PIN_LED, LOW);  // seguridad: sin agente, el LED se apaga
      destruir_entidades();
      estado = ESPERANDO_AGENTE;
      break;
  }
}

8. Un nodo de ROS 2 en Python que hace parpadear el LED

Con el ejemplo del LED cargado, este nodo hace parpadear el LED a la frecuencia (Hz) que recibe por un tema de entrada. Publica en /esp32/led y escucha un Int32 en /led/frecuencia:

#!/usr/bin/env python3
"""Nodo mínimo de ROS 2: hace parpadear el LED del ESP32 (micro-ROS) a la frecuencia que le llegue por un tema.

Entrada : /led/frecuencia  (std_msgs/Int32)  frecuencia de parpadeo en Hz. 0 apaga el LED.
Salida  : /esp32/led       (std_msgs/Bool)   el tema que escucha el ejemplo `led` del ESP32.

Uso:
    source /opt/ros/lyrical/setup.bash          # o la distribución que uses
    python3 parpadeo.py
    ros2 topic pub --once /led/frecuencia std_msgs/msg/Int32 "{data: 2}"    # 2 parpadeos por segundo
"""
import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node
from std_msgs.msg import Bool, Int32

FRECUENCIA_MAXIMA = 20  # Hz: por encima, el enlace serie y el LED dejan de tener sentido


class Parpadeo(Node):
    def __init__(self):
        super().__init__("parpadeo")
        self.led = False
        self.temporizador = None
        self.pub = self.create_publisher(Bool, "/esp32/led", 10)
        self.create_subscription(Int32, "/led/frecuencia", self.al_recibir, 10)
        self.get_logger().info("Esperando frecuencias en /led/frecuencia (Hz)")

    def al_recibir(self, msg):
        hz = max(0, min(msg.data, FRECUENCIA_MAXIMA))
        if self.temporizador:
            self.destroy_timer(self.temporizador)
            self.temporizador = None
        if hz == 0:
            self.publicar(False)
            self.get_logger().info("LED apagado")
            return
        # un parpadeo completo son dos cambios (encender y apagar)
        self.temporizador = self.create_timer(1.0 / (2 * hz), self.cambiar)
        self.get_logger().info(f"Parpadeando a {hz} Hz")

    def cambiar(self):
        self.publicar(not self.led)

    def publicar(self, encendido):
        self.led = encendido
        self.pub.publish(Bool(data=encendido))


def main():
    rclpy.init()
    nodo = Parpadeo()
    try:
        rclpy.spin(nodo)
    except (KeyboardInterrupt, ExternalShutdownException):
        pass  # Ctrl+C: el LED se queda como estaba; para apagarlo, publica 0 en /led/frecuencia
    except Exception:
        if rclpy.ok():  # en algunas versiones, el cierre por Ctrl+C lanza un error de rcl; no es un fallo real
            raise
    nodo.destroy_node()
    rclpy.try_shutdown()


if __name__ == "__main__":
    main()
python3 parpadeo.py
ros2 topic pub --once /led/frecuencia std_msgs/msg/Int32 "{data: 2}"    # 2 parpadeos por segundo

En el repositorio hay otro nodo, morse.py, que convierte un texto en Morse y lo emite con el LED.

Cuánto aguanta por serie

Se midió hasta qué frecuencia puede publicar un ESP32 por el cable USB. Frecuencia máxima real que consigue la placa (con una prueba por celda, un solo montaje y un solo cable):

Puerto Entrega 16 bytes 64 bytes 128 bytes
115200 fiable 136 Hz 86 Hz 58 Hz
115200 best-effort 245 Hz 121 Hz 72 Hz
921600 fiable 500 Hz 400 Hz 320 Hz
921600 best-effort ~2000 Hz ~965 Hz 577 Hz
  • Cada publicación fiable bloquea a la placa unos 2-3 ms a 921600 baudios. En best-effort con 16-64 bytes, unos 0,1 ms.
  • No se perdieron mensajes: el enlace frena y la placa salta ciclos.
  • Para telemetría rápida (kHz con varias magnitudes) los temas no bastan: hace falta otro canal.
  • Son cifras de un montaje concreto, no una conclusión general.

Problemas típicos

Lo que ves Causa Solución
micro_ros_platformio.h: No such file or directory La librería no se instaló: lib_deps está vacío (URL sin sangría o archivo sin guardar) Pon lib_deps = https://github.com/micro-ROS/micro_ros_platformio en una sola línea y guarda con Ctrl+S
fatal error: sdkconfig.h al compilar Plataforma espressif32 demasiado nueva platform = [email protected] y borrar la carpeta .pio
No 'rosidl_typesupport_cpp' found ROS 2 está en el PATH Abrir VS Code desde una terminal sin source de ROS
Permission denied: /dev/ttyUSB0 Tu usuario no está en dialout Añadirlo y cerrar sesión
Write timeout o Serial data stream stopped al cargar Cable solo de carga, puerto USB o algo conectado a los pines Otro cable y otro puerto; desconecta lo que cuelgue de la placa
El agente no muestra session established La placa arrancó antes que el agente Pulsa EN, o usa la versión con reconexión
ros2 topic list no muestra los temas Otro dominio de ROS 2 o dos agentes a la vez Mismo ROS_DOMAIN_ID y un solo agente
Serial.print rompe la conexión Serial lo usa micro-ROS No imprimas por Serial: publica el estado como un tema

Código completo

El proyecto con los dos ejemplos, los nodos de Python y esta guía está en el repositorio: (enlace al repositorio pendiente de publicar).

Más sobre micro-ROS: Introducción a micro-ROS y micro-ROS y CAN BUS.