Caso práctico: UGV Beast ROS2 con cámara en Raspberry Pi 4

Caso práctico: UGV Beast ROS2 con cámara en Raspberry Pi 4 — hero

Objetivo y caso de uso

Qué construirás: Un UGV tipo “Beast” basado en Raspberry Pi 4 Model B, que patrulla un recorrido definido con ROS 2 Humble, transmite vídeo en tiempo real (720p a ~25 FPS, latencia 150–250 ms en LAN) y controla servos mediante la Adafruit 16-Channel PWM/Servo HAT.

Para qué sirve

  • Vigilancia básica de un pasillo o almacén pequeño, con patrullas automáticas de ida y vuelta y streaming de vídeo continuo al operador.
  • Inspección remota de laboratorios o aulas fuera de horario para comprobar luces, puertas y equipos sin entrar físicamente.
  • Monitorización de prototipos o impresoras 3D, acercando el robot a una zona concreta y revisando el estado en vídeo en tiempo real.
  • Demostraciones educativas de robótica móvil para enseñar integración de ROS 2, visión y control de movimiento en un mismo sistema.
  • Pruebas de integración con sistemas IoT, usando el vídeo como fuente para servicios de detección de presencia o eventos simples.

Resultado esperado

  • Robot capaz de ejecutar una ruta de patrulla en bucle (mínimo 5–10 minutos continuos) con control de velocidad y giros mediante ROS 2.
  • Streaming de la Raspberry Pi Camera Module 3 en ROS 2 a ~20–30 FPS, consumo de CPU < 40% y uso de red < 5 Mbps en 720p H.264.
  • Control de dirección/actuadores con el PCA9685 (mínimo 2–4 canales activos), con actualizaciones de comandos < 50 ms de latencia ROS.
  • Todos los nodos ROS 2 ejecutándose sin entorno gráfico, gestionados por terminal (launch files o composable nodes) de forma reproducible.

Público objetivo: Estudiantes, makers y desarrolladores que quieran construir un primer robot móvil ROS 2 funcional; Nivel: Intermedio (se asume familiaridad básica con Linux, terminal y conceptos ROS).

Arquitectura/flujo: Raspberry Pi 4 ejecuta ROS 2 Humble como único cerebro: un nodo de navegación simple publica comandos de velocidad; un nodo de control PWM en el HAT PCA9685 traduce los comandos a señales para motores y servos; un nodo de cámara publica el vídeo en un tópico de imagen comprimida; un operador remoto se conecta vía Wi-Fi para visualizar el stream y enviar órdenes manuales, mientras la Raspberry coordina todos los tópicos y servicios sin entorno gráfico.

Prerrequisitos

Sistema operativo y hardware base

  • Raspberry Pi 4 Model B (4 GB o más recomendado).
  • SO: Ubuntu Server 22.04 LTS 64-bit (aarch64) para Raspberry Pi.
  • Acceso:
  • Red local vía Ethernet o Wi-Fi.
  • Acceso SSH desde otro PC para trabajar sin entorno gráfico (headless).

Toolchain exacta y versiones

Usaremos la siguiente toolchain (todas las versiones deben ser respetadas para reproducibilidad):

  • Sistema operativo
  • Ubuntu Server 22.04.4 LTS (64-bit, aarch64) para Raspberry Pi.
  • ROS 2
  • Distribución ROS 2 Humble Hawksbill (paquetes ros-humble-*).
  • Compilación / Workspace
  • colcon versión instalada desde repos python3-colcon-common-extensions.
  • Workspace: ~/ros2_ws (carpeta fija).
  • Lenguaje y bibliotecas
  • Python 3.10 (por defecto en Ubuntu 22.04).
  • Paquetes ROS 2 desde apt:
  • ros-humble-desktop
  • ros-humble-ros2-control
  • ros-humble-diff-drive-controller
  • ros-humble-robot-localization
  • ros-humble-slam-toolbox
  • ros-humble-nav2-bringup
  • ros-humble-nav2-core
  • ros-humble-rviz2
  • Herramientas auxiliares
  • git ≥ 2.34
  • python3-pip
  • Librerías de cámara:
  • libraspberrypi-dev
  • v4l-utils
  • Librería PCA9685:
  • python3-smbus
  • Paquete Python adafruit-circuitpython-pca9685 (vía pip).

Materiales

Lista de materiales principales

  • 1 × Raspberry Pi 4 Model B (mínimo 4 GB RAM recomendable).
  • 1 × Raspberry Pi Camera Module 3 (interfaz CSI, lente estándar).
  • 1 × Adafruit 16-Channel PWM/Servo HAT (PCA9685).
  • 2 × motores DC con reductora y ruedas (para tracción diferencial) O bien base UGV que ya incluya:
  • Motor izquierdo + rueda.
  • Motor derecho + rueda.
  • 1 × controlador de motores DC (puede ser externo, pero el ejemplo se centrará en la parte conceptual y en el envío de comandos; no dependemos de un modelo concreto mientras se controle vía GPIO/PWM externos o puente H).
  • 1 × batería adecuada para el UGV (p.ej., 2S/3S LiPo + regulador a 5 V para la Raspberry Pi).
  • 1 × tarjeta microSD (32 GB o más, clase 10).
  • Cables:
  • Cintas para la cámara CSI.
  • Jumpers macho-hembra para I2C (SDA, SCL, 5 V, GND).
  • (Opcional, pero recomendado) Estructura tipo chasis UGV “Beast” con soporte para Raspberry Pi y batería.

Preparación y conexión

1. Configuración inicial de Ubuntu y ROS 2 Humble

  1. Instalar Ubuntu Server 22.04.4 LTS aarch64 en la microSD (usando Raspberry Pi Imager desde otro PC).
  2. Arrancar la Raspberry Pi 4 con esa tarjeta y realizar la configuración básica (usuario, red).
  3. Actualizar paquetes:

bash
sudo apt update
sudo apt upgrade -y

  1. Añadir repositorios de ROS 2 Humble:

«`bash
sudo apt install -y software-properties-common
sudo add-apt-repository universe
sudo add-apt-repository restricted
sudo add-apt-repository multiverse

sudo apt update
sudo apt install -y curl gnupg lsb-release
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
-o /usr/share/keyrings/ros-archive-keyring.gpg

echo «deb [arch=$(dpkg –print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main» \
| sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null

sudo apt update
«`

  1. Instalar toolchain ROS 2:

bash
sudo apt install -y \
ros-humble-desktop \
ros-humble-ros2-control \
ros-humble-diff-drive-controller \
ros-humble-robot-localization \
ros-humble-slam-toolbox \
ros-humble-nav2-bringup \
ros-humble-nav2-core \
ros-humble-rviz2 \
python3-colcon-common-extensions \
python3-rosdep \
git

  1. Inicializar rosdep:

bash
sudo rosdep init
rosdep update

  1. Añadir al ~/.bashrc:

bash
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
source ~/.bashrc

2. Activar y probar la cámara

  1. Instalar utilidades:

bash
sudo apt install -y v4l-utils libraspberrypi-dev

  1. Comprobar detección:

bash
v4l2-ctl --list-devices

Debe aparecer algo como mmal service 16.1 o bcm2835-codec y un dispositivo /dev/video0.

3. Conexión del PCA9685 (Adafruit 16-Channel PWM/Servo HAT)

El PCA9685 se comunica por I2C. Por defecto, en la Raspberry Pi 4:

  • SDA: GPIO 2 (pin físico 3).
  • SCL: GPIO 3 (pin físico 5).
  • 5V: pins físicos 2 o 4.
  • GND: pins físicos 6, 9, 14, etc.

Tabla de conexiones PCA9685 ↔ Raspberry Pi 4

Función PCA9685 Pin en HAT Conectar a Raspberry Pi 4 Comentario
VCC (lógica) VCC 3.3 V (pin 1) Alimentación lógica (según versión del HAT)
GND GND GND (pin 6) Masa común
SDA SDA GPIO 2 (pin 3) I2C SDA
SCL SCL GPIO 3 (pin 5) I2C SCL
V+ (servos) V+ 5–6 V desde batería/regulador Alimentación de servos
Canal 0 PWM0 Servo 0 (ej. dirección) Señal PWM
Canal 1 PWM1 Servo 1 (opcional) Señal PWM

Importante: No alimentar directamente servos potentes desde el 5 V de la Raspberry Pi. Usar un regulador/batería externo.

Habilitar I2C en Ubuntu 22.04

Editar /boot/firmware/config.txt:

sudo nano /boot/firmware/config.txt

Asegurarse de tener:

dtparam=i2c_arm=on

Reiniciar:

sudo reboot

Tras reiniciar, comprobar:

sudo apt install -y i2c-tools
sudo i2cdetect -y 1

Debes ver la dirección típica del PCA9685 (0x40).


Código completo

Crearemos un paquete ROS 2 llamado ugv_beast_patrol con:

  1. Nodo Python para:
  2. Control del PCA9685 (servos).
  3. Publicación de comandos de velocidad /cmd_vel para patrulla simple.
  4. Nodo Python para:
  5. Publicar imágenes de la Raspberry Pi Camera en /camera/image_raw.

1. Crear el workspace y el paquete

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src

ros2 pkg create --build-type ament_python ugv_beast_patrol

Estructura relevante:

  • ~/ros2_ws/src/ugv_beast_patrol/
  • package.xml
  • setup.py
  • ugv_beast_patrol/
    • __init__.py
    • patrol_node.py
    • camera_stream_node.py
  • resource/
  • launch/ (crearemos archivos de lanzamiento).

Instalar dependencias de Python para el PCA9685 y la cámara:

sudo apt install -y python3-smbus python3-pip
pip3 install adafruit-circuitpython-pca9685
pip3 install opencv-python

2. Código del nodo de patrulla y control PCA9685 (patrol_node.py)

# ~/ros2_ws/src/ugv_beast_patrol/ugv_beast_patrol/patrol_node.py

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
import board
import busio
from adafruit_pca9685 import PCA9685
import time


class PatrolNode(Node):
    """
    Nodo ROS 2 básico que:
      - Publica comandos /cmd_vel para un UGV diferencial.
      - Controla un servo (ej. dirección o cámara) mediante PCA9685.
      - Implementa un bucle de patrulla muy simple (ida y vuelta).
    """

    def __init__(self):
        super().__init__('patrol_node')

        # Parámetros configurables
        self.declare_parameter('linear_speed', 0.15)     # m/s
        self.declare_parameter('angular_speed', 0.0)     # rad/s
        self.declare_parameter('patrol_time', 10.0)      # segundos hacia adelante
        self.declare_parameter('servo_channel', 0)       # canal PCA9685
        self.declare_parameter('servo_center', 0.0015)   # ancho de pulso (s) centro
        self.declare_parameter('servo_left', 0.0012)     # pulso izquierda
        self.declare_parameter('servo_right', 0.0018)    # pulso derecha
        self.declare_parameter('patrol_period', 30.0)    # periodo completo (s)

        self.linear_speed = float(self.get_parameter('linear_speed').value)
        self.angular_speed = float(self.get_parameter('angular_speed').value)
        self.patrol_time = float(self.get_parameter('patrol_time').value)
        self.servo_channel = int(self.get_parameter('servo_channel').value)
        self.servo_center = float(self.get_parameter('servo_center').value)
        self.servo_left = float(self.get_parameter('servo_left').value)
        self.servo_right = float(self.get_parameter('servo_right').value)
        self.patrol_period = float(self.get_parameter('patrol_period').value)

        # Publisher de velocidad
        self.cmd_vel_pub = self.create_publisher(Twist, 'cmd_vel', 10)

        # Inicialización de I2C y PCA9685
        i2c = busio.I2C(board.SCL, board.SDA)
        self.pca = PCA9685(i2c)
        self.pca.frequency = 50  # Hz, típico para servos

        self.get_logger().info('PCA9685 inicializado a 50 Hz')

        # Configurar servo en posición centro al inicio
        self._set_servo_pulse(self.servo_center)
        self.get_logger().info('Servo en posición centro')

        # Timer para el bucle de patrulla
        self.start_time = self.get_clock().now().to_msg().sec
        self.timer = self.create_timer(0.1, self.timer_callback)

    def _set_servo_pulse(self, pulse_seconds: float):
        """
        Configura el pulso del servo en segundos (ej. 0.0015 = 1.5 ms).
        El PCA9685 tiene 4096 pasos por periodo.
        """
        period_seconds = 1.0 / self.pca.frequency
        pulse_length = int((pulse_seconds / period_seconds) * 4096)
        pulse_length = max(0, min(4095, pulse_length))
        self.pca.channels[self.servo_channel].duty_cycle = pulse_length

    def timer_callback(self):
        """
        Lógica simple de patrulla:
          - Durante 'patrol_time' segundos: avanza hacia adelante.
          - Luego frena, gira servo a izquierda, espera corto tiempo.
          - Vuelve hacia atrás 'patrol_time' segundos.
          - Restaura servo al centro.
          - Espera hasta completar 'patrol_period' y repite.
        """
        now = self.get_clock().now().to_msg().sec
        elapsed = now - self.start_time

        msg = Twist()

        # Fase 1: avance
        if 0 <= elapsed < self.patrol_time:
            msg.linear.x = self.linear_speed
            msg.angular.z = self.angular_speed

        # Fase 2: detener y mirar a la izquierda
        elif self.patrol_time <= elapsed < self.patrol_time + 2.0:
            msg.linear.x = 0.0
            msg.angular.z = 0.0
            self._set_servo_pulse(self.servo_left)

        # Fase 3: retroceso
        elif self.patrol_time + 2.0 <= elapsed < 2 * self.patrol_time + 2.0:
            msg.linear.x = -self.linear_speed
            msg.angular.z = -self.angular_speed

        # Fase 4: detener y mirar a la derecha
        elif 2 * self.patrol_time + 2.0 <= elapsed < 2 * self.patrol_time + 4.0:
            msg.linear.x = 0.0
            msg.angular.z = 0.0
            self._set_servo_pulse(self.servo_right)

        # Fase 5: centro y espera
        else:
            msg.linear.x = 0.0
            msg.angular.z = 0.0
            self._set_servo_pulse(self.servo_center)

            if elapsed > self.patrol_period:
                # Reiniciar ciclo
                self.start_time = now
                self.get_logger().info('Reiniciando ciclo de patrulla')

        self.cmd_vel_pub.publish(msg)


def main(args=None):
    rclpy.init(args=args)
    node = PatrolNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.pca.deinit()
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

Puntos clave del código

  • Usa geometry_msgs/Twist para publicar en /cmd_vel, compatible con diff_drive_controller.
  • Inicializa el PCA9685 vía I2C (board.SCL, board.SDA) y configura el servo.
  • Lógica simple de patrulla basada en el tiempo transcurrido.

3. Código del nodo de streaming de cámara (camera_stream_node.py)

# ~/ros2_ws/src/ugv_beast_patrol/ugv_beast_patrol/camera_stream_node.py

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2


class CameraStreamNode(Node):
    """
    Nodo ROS 2 que captura vídeo de /dev/video0 (Pi Camera Module 3 con driver V4L2)
    y publica las imágenes en el tópico /camera/image_raw.
    """

    def __init__(self):
        super().__init__('camera_stream_node')

        self.declare_parameter('device', '/dev/video0')
        self.declare_parameter('fps', 15)
        self.declare_parameter('width', 640)
        self.declare_parameter('height', 480)

        device = self.get_parameter('device').get_parameter_value().string_value
        self.fps = self.get_parameter('fps').get_parameter_value().integer_value
        width = self.get_parameter('width').get_parameter_value().integer_value
        height = self.get_parameter('height').get_parameter_value().integer_value

        self.bridge = CvBridge()
        self.publisher_ = self.create_publisher(Image, 'camera/image_raw', 10)

        # Abrir captura de vídeo
        self.cap = cv2.VideoCapture(device)
        if not self.cap.isOpened():
            self.get_logger().error(f'No se pudo abrir el dispositivo de vídeo: {device}')
            raise RuntimeError('Error abriendo cámara')

        self.cap.set(cv2.CAP_PROP_FPS, self.fps)
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)

        self.timer = self.create_timer(1.0 / float(self.fps), self.timer_callback)
        self.get_logger().info(f'Publicando vídeo desde {device} a {self.fps} fps')

    def timer_callback(self):
        ret, frame = self.cap.read()
        if not ret:
            self.get_logger().warning('No se pudo leer frame de la cámara')
            return

        # Convertir BGR (OpenCV) a mensaje ROS
        msg = self.bridge.cv2_to_imgmsg(frame, encoding='bgr8')
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.header.frame_id = 'camera_link'
        self.publisher_.publish(msg)

    def destroy_node(self):
        if hasattr(self, 'cap') and self.cap.isOpened():
            self.cap.release()
        super().destroy_node()


def main(args=None):
    rclpy.init(args=args)
    node = CameraStreamNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

Puntos clave del código

  • Usa sensor_msgs/Image y cv_bridge para publicar imágenes en ROS 2.
  • Ajusta resolución y FPS desde parámetros.
  • Asigna frame_id="camera_link" para integrarlo con el árbol TF del robot.

4. Configuración de setup.py y package.xml

setup.py

# ~/ros2_ws/src/ugv_beast_patrol/setup.py

from setuptools import setup

package_name = 'ugv_beast_patrol'

setup(
    name=package_name,
    version='0.0.1',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages',
         ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        ('share/' + package_name + '/launch', ['launch/patrol_with_camera.launch.py']),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='alumno',
    maintainer_email='alumno@example.com',
    description='UGV Beast patrol and camera streaming using Raspberry Pi 4, PCA9685 and Pi Camera 3',
    license='Apache License 2.0',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
            'patrol_node = ugv_beast_patrol.patrol_node:main',
            'camera_stream_node = ugv_beast_patrol.camera_stream_node:main',
        ],
    },
)

package.xml (fragmento relevante)

Asegúrate de declarar dependencias:

<?xml version="1.0"?>
<package format="3">
  <name>ugv_beast_patrol</name>
  <version>0.0.1</version>
  <description>UGV Beast basic patrol and camera streaming</description>
  <maintainer email="alumno@example.com">Alumno</maintainer>
  <license>Apache License 2.0</license>

  <buildtool_depend>ament_python</buildtool_depend>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>geometry_msgs</exec_depend>
  <exec_depend>sensor_msgs</exec_depend>
  <exec_depend>cv_bridge</exec_depend>

  <export>
  </export>
</package>

Instala cv_bridge:

sudo apt install -y ros-humble-cv-bridge

5. Archivo de lanzamiento (patrol_with_camera.launch.py)

# ~/ros2_ws/src/ugv_beast_patrol/launch/patrol_with_camera.launch.py

from launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    return LaunchDescription([
        Node(
            package='ugv_beast_patrol',
            executable='patrol_node',
            name='patrol_node',
            output='screen',
            parameters=[
                {'linear_speed': 0.15},
                {'angular_speed': 0.0},
                {'patrol_time': 8.0},
                {'patrol_period': 25.0},
                {'servo_channel': 0},
            ]
        ),
        Node(
            package='ugv_beast_patrol',
            executable='camera_stream_node',
            name='camera_stream_node',
            output='screen',
            parameters=[
                {'device': '/dev/video0'},
                {'fps': 15},
                {'width': 640},
                {'height': 480},
            ]
        ),
    ])

Compilación, instalación y ejecución

1. Compilar con colcon

Desde el workspace:

cd ~/ros2_ws
colcon build

Fuentea el setup.bash del workspace:

echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc
source ~/.bashrc

2. Ejecutar el caso práctico de patrulla + streaming

En la Raspberry Pi (o vía SSH):

ros2 launch ugv_beast_patrol patrol_with_camera.launch.py

Debes ver en consola:

  • Logs de patrol_node indicando fases de patrulla.
  • Mensajes de camera_stream_node confirmando la publicación de vídeo.

3. Visualizar el vídeo y el movimiento desde otro PC

En otro PC (también con ROS 2 Humble y conectividad con la Raspberry Pi):

  1. Configurar las variables de entorno ROS 2 para que usen la misma red (por ejemplo, usando ROS_DOMAIN_ID o Fast DDS discovery automático).
  2. Comprobar tópico de la cámara:

bash
ros2 topic list
ros2 topic echo /camera/image_raw

(Verás datos en bruto; para ver vídeo, usar RViz o rqt_image_view).

  1. Visualizar con rqt_image_view:

bash
sudo apt install -y ros-humble-rqt-image-view
rqt_image_view

En la interfaz, seleccionar /camera/image_raw.

  1. Comprobar comandos de velocidad:

bash
ros2 topic echo /cmd_vel


Validación paso a paso

1. Validar comunicación con el PCA9685

  1. Desde la Raspberry Pi, antes de lanzar ROS:

bash
sudo i2cdetect -y 1

  • Debe aparecer 40 como dirección del PCA9685.
  • Si no aparece, revisar cableado, alimentación y config.txt.

  • Lanzar solo el nodo de patrulla:

bash
ros2 run ugv_beast_patrol patrol_node

  • Observar si el servo conectado al canal 0 se mueve:
    • Inicialmente al centro.
    • Luego a izquierda/derecha según el ciclo.

2. Validar publicación de /cmd_vel

Con el nodo patrol_node en ejecución, desde otra terminal en la Raspberry Pi:

ros2 topic hz /cmd_vel
  • Debes ver una frecuencia alrededor de 10 Hz (o la de tu timer).
  • Métrica: frecuencia estable (varianza < 1 Hz).

3. Validar la cámara

  1. Con la cámara conectada y el nodo camera_stream_node en marcha:

bash
ros2 run ugv_beast_patrol camera_stream_node

  1. En otra terminal:

bash
ros2 topic hz /camera/image_raw

  • Debes ver ~15 Hz, acorde al parámetro fps=15.

  • Previsualizar con rqt_image_view (desde un PC o la propia Pi si tienes X11 remoto) y comprobar:

  • Imagen estable, sin cortes frecuentes.

  • Retardo percibido < 0,5 s (métrico subjetivo pero fácil de estimar moviendo un objeto delante de la cámara).

4. Validar patrulla física

Con el robot en el suelo:

  1. Asegúrate de que los motores responden a /cmd_vel (esto requiere que tu controlador de motores esté conectado y escuche este tópico o esté configurado via diff_drive_controller y hardware interface; para nivel básico, se asume que /cmd_vel ya se traduce a movimiento mediante tu stack).
  2. Marca con cinta adhesiva:
  3. Punto inicial.
  4. Distancia de 3 m hacia adelante.
  5. ejecuta:

bash
ros2 launch ugv_beast_patrol patrol_with_camera.launch.py

  1. Observar:
  2. El robot avanza aproximadamente esos 3 m y se detiene.
  3. El servo gira izquierda/derecha.
  4. El robot retrocede y se detiene cerca de la marca inicial.
  5. Error de retorno ≤ 30 cm.

Troubleshooting (errores típicos y soluciones)

1. i2cdetect -y 1 no muestra la dirección 0x40

Causa probable: I2C deshabilitado o cableado incorrecto.

Solución:

  • Revisar /boot/firmware/config.txt: asegurarse de dtparam=i2c_arm=on.
  • Verificar conexiones:
  • SDA en GPIO 2 (pin 3).
  • SCL en GPIO 3 (pin 5).
  • GND común entre Pi y HAT.
  • Reiniciar y volver a ejecutar i2cdetect.

2. Error al abrir cámara: No se pudo abrir el dispositivo de vídeo: /dev/video0

Causa: módulo de cámara no detectado, dispositivo incorrecto o falta de permisos.

Solución:

  • Comprobar que la cámara está bien conectada (cable CSI firme y en la orientación correcta).
  • Ejecutar:

bash
v4l2-ctl --list-devices

  • Usar el dispositivo correcto (/dev/video1, etc.) en el parámetro device de camera_stream_node.
  • Asegurarse de que el usuario tiene permisos sobre /dev/videoX (en caso extremo, probar como sudo solo para diagnóstico).

3. Nodo camera_stream_node se ejecuta pero no hay imágenes en /camera/image_raw

Causa: cv_bridge no instalado o error silencioso.

Solución:

  • Verificar que cv_bridge esté instalado:

bash
sudo apt install -y ros-humble-cv-bridge

  • Revisar la salida de logs:

bash
ros2 run ugv_beast_patrol camera_stream_node --ros-args --log-level DEBUG

  • Verificar con ros2 topic list que el tópico está publicado.

4. colcon build falla por dependencias de Python o ROS 2

Causa: faltan paquetes como cv_bridge, geometry_msgs o sensor_msgs.

Solución:

  • Confirmar que el package.xml tiene:

xml
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>cv_bridge</exec_depend>

  • Ejecutar de nuevo:

bash
sudo apt update
sudo apt install -y ros-humble-cv-bridge ros-humble-geometry-msgs ros-humble-sensor-msgs
cd ~/ros2_ws
colcon build --symlink-install

5. El servo no se mueve aunque el PCA9685 se detecta

Causa: alimentación insuficiente o pulso fuera del rango adecuado.

Solución:

  • Asegurarse de que el pin V+ de servos está alimentado con 5–6 V adecuados.
  • Verificar la masa común entre Raspberry Pi y PCA9685.
  • Ajustar los valores servo_left, servo_center, servo_right en el nodo:

bash
ros2 run ugv_beast_patrol patrol_node --ros-args -p servo_left:=0.0010 -p servo_center:=0.0015 -p servo_right:=0.0020

  • Comprobar si el servo funciona directamente con un generador de PWM o un test simple.

6. El robot se mueve de manera errática o demasiado rápido

Causa: valores de linear_speed demasiado altos o conversión incorrecta en el controlador de motores.

Solución:

  • Bajar la velocidad:

bash
ros2 run ugv_beast_patrol patrol_node --ros-args -p linear_speed:=0.05

  • Verificar que la unidad esperada por tu nodo/controlador de motores es m/s para cmd_vel.linear.x.

7. Latencia de vídeo muy alta (>1 s)

Causa: red saturada, resolución muy alta o CPU de la Pi sobrecargada.

Solución:

  • Reducir resolución y FPS:

bash
ros2 run ugv_beast_patrol camera_stream_node --ros-args \
-p width:=320 -p height:=240 -p fps:=10

  • Asegurarse de que no se ejecutan procesos pesados en la Pi (p.ej., RViz local).
  • Usar conexión Ethernet en lugar de Wi-Fi si es posible.

8. ros2 topic desde el PC no ve los tópicos de la Raspberry Pi

Causa: problemas de descubrimiento DDS o redes separadas.

Solución:

  • Verificar que ambas máquinas están en la misma subred IP.
  • Deshabilitar firewalls temporales para prueba (ufw disable con cuidado).
  • Asegurarse de que ambas usan la misma versión de ROS 2 (Humble) y el mismo ROS_DOMAIN_ID (o ninguno configurado).

Mejoras / variantes

Una vez que el caso básico de ros2-patrol-camera-streaming funciona, puedes extenderlo:

  1. Integración con diff_drive_controller y ros2_control
  2. Crear un archivo URDF del UGV con:
    • base_link
    • left_wheel_link
    • right_wheel_link
  3. Configurar un hardware interface (aunque sea “dummy” inicialmente) y lanzar diff_drive_controller para convertir /cmd_vel en velocidades de rueda.

  4. Odometría e IMU con robot_localization

  5. Si tu UGV integra IMU, usar un YAML para ekf_node que fusione /imu/data y /wheel/odom.
  6. Métrica: error de posición acumulado más estable que con odometría simple.

  7. SLAM y navegación (slam_toolbox, nav2)

  8. Ejecutar slam_toolbox en remoto para construir un mapa mientras patrulla.
  9. Guardar mapa con map_server.
  10. Usar Nav2 para mandar objetivos simples, en lugar de patrulla fija.
  11. Comprobar que el UGV puede repetir una ruta en el mapa guardado.

  12. Control avanzado de la cámara

  13. Añadir un segundo servo para inclinación vertical de la cámara (usar canal 1 del PCA9685).
  14. Añadir un nodo que reciba comandos de tipo std_msgs/Float32 para orientar la cámara en tiempo real.

  15. Grabación y análisis de rosbag

  16. Grabar /camera/image_raw, /cmd_vel y (cuando lo tengas) /odom con ros2 bag.
  17. Analizar offline para medir latencias, tasas y consistencia de odometría.

Checklist de verificación

Marca cada ítem cuando lo completes:

  • [ ] Ubuntu Server 22.04.4 LTS aarch64 instalado y actualizado en Raspberry Pi 4 Model B.
  • [ ] ROS 2 Humble instalado con paquetes: desktop, ros2-control, diff-drive-controller, robot-localization, slam-toolbox, nav2*, rviz2.
  • [ ] Workspace ~/ros2_ws creado y compilado con colcon build sin errores.
  • [ ] Cámara Raspberry Pi Camera Module 3 detectada (/dev/video0 u otro) y v4l2-ctl --list-devices la muestra.
  • [ ] I2C habilitado, i2cdetect -y 1 muestra la dirección 0x40 del PCA9685.
  • [ ] Nodo patrol_node ejecutándose, publicando /cmd_vel a una frecuencia estable (~10 Hz).
  • [ ] Servo en el canal 0 del PCA9685 se mueve a posiciones izquierda, centro y derecha según el ciclo de patrulla.
  • [ ] Nodo camera_stream_node ejecutándose, publicando /camera/image_raw a la tasa configurada (≥ 10–15 fps).
  • [ ] Desde un PC remoto se visualiza el vídeo con rqt_image_view y la latencia está por debajo de ~500 ms.
  • [ ] El UGV completa un recorrido de patrulla sencilla de al menos 3 m de ida y vuelta con error de retorno ≤ 30 cm.
  • [ ] Has documentado (para ti) los parámetros de patrulla (velocidad, tiempos, ángulos de servo) que producen el mejor comportamiento en tu entorno.

Con todo lo anterior, habrás construido un UGV Beast básico con ROS 2, capaz de realizar una patrulla automática mientras envía streaming de vídeo desde la Raspberry Pi Camera Module 3, controlando servos a través del Adafruit 16-Channel PWM/Servo HAT (PCA9685) y utilizando ROS 2 Humble en una Raspberry Pi 4 Model B.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué tipo de robot se construirá según el artículo?




Pregunta 2: ¿Cuál es el propósito principal del UGV mencionado?




Pregunta 3: ¿Qué tecnología se utilizará para la transmisión de vídeo?




Pregunta 4: ¿Cuál es la latencia esperada en la transmisión de vídeo en LAN?




Pregunta 5: ¿Qué sistema operativo se utilizará en el UGV?




Pregunta 6: ¿Qué componente se usará para controlar los servos?




Pregunta 7: ¿Cuál es el objetivo de las patrullas automáticas del robot?




Pregunta 8: ¿Qué tipo de público está dirigido el proyecto?




Pregunta 9: ¿Qué se espera del consumo de CPU durante la transmisión de vídeo?




Pregunta 10: ¿Qué se utilizará para gestionar los nodos de ROS 2?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Waypoints GPS en UGV Beast (ROS 2)

Caso práctico: Waypoints GPS en UGV Beast (ROS 2) — hero

Objetivo y caso de uso

Qué construirás: Un sistema ROS 2 Humble de navegación por waypoints GPS en un UGV Beast con Raspberry Pi 4, Adafruit Ultimate GPS HAT (MTK3339) y Waveshare 10-DOF IMU, capaz de ir de un punto A a B a C de forma autónoma suavizando la trayectoria con IMU + odometría.

Para qué sirve

  • Patrullar automáticamente el perímetro de una finca (p. ej. 4–6 waypoints formando un rectángulo) registrando vídeo o datos ambientales.
  • Recorrer una ruta fija en un aparcamiento para vigilancia periódica (pasadas cada 5–10 minutos, latencia de control < 50 ms).
  • Realizar demos de navegación autónoma al aire libre en un campus, mostrando cambios en tiempo real de la ruta desde RViz2.
  • Ejecutar rutas de reparto interno en almacenes semiabiertos donde el GPS es utilizable, con actualización de pose a > 10 Hz.
  • Montar un circuito educativo A–B–C–A para enseñar conceptos de ROS 2 (topics, frames TF, control de velocidad) en menos de 10 minutos de setup.

Resultado esperado

  • El robot recorre al menos 3 waypoints GPS consecutivos con un error de llegada típico de 3–5 m (limitado por la precisión del MTK3339).
  • Publicación estable en /fix, /imu, /odom y /cmd_vel a 10–20 Hz, con latencia de extremo a extremo (GPS → cmd_vel) < 100 ms.
  • Uso de CPU en la Raspberry Pi 4 < 45 % y carga de GPU irrelevante (< 5 %, sin aceleración pesada), garantizando > 30 Hz de lazo de control.
  • Transición limpia entre waypoints (sin oscilaciones fuertes ni cambios bruscos > 50 % en velocidad lineal/angular por ciclo).

Público objetivo: Estudiantes, makers y desarrolladores junior de robótica móvil; Nivel: Intermedio (se asume familiaridad básica con Linux, ROS 2 y cinemática diferencial).

Arquitectura/flujo: Nodo GPS lee /fix, se fusiona con IMU (MPU9250) y odometría en un filtro (p. ej. ekf_node) para generar /odom → nodo de navegación por waypoints calcula el error pose-objetivo, genera comandos de velocidad en /cmd_vel → controlador del UGV ejecuta la velocidad; monitorización desde RViz2 con TF entre map, odom y base_link.

Prerrequisitos

Sistema operativo y plataforma

  • Hardware principal:
  • Raspberry Pi 4 Model B (4 GB o 8 GB recomendado).
  • Sistema operativo:
  • Ubuntu Server 22.04 LTS 64-bit (aarch64) para Raspberry Pi 4.
  • ROS 2:
  • ROS 2 Humble Hawksbill (aarch64) instalado desde apt.

Versiones de toolchain

En este caso práctico usaremos:

  • Kernel y SO (ejemplo típico):
  • Ubuntu 22.04.4 LTS
  • Kernel Linux 5.15.x para Raspberry Pi
  • ROS 2:
  • ros-humble-desktop (meta-paquete principal)
  • Versiones instaladas por apt oficiales de Ubuntu.
  • Compilador y herramientas:
  • gcc 11.x
  • cmake 3.22+
  • python3 3.10
  • colcon (python3-colcon-common-extensions)
  • Librerías ROS 2 específicas:
  • ros-humble-ros2-control
  • ros-humble-diff-drive-controller
  • ros-humble-robot-localization
  • ros-humble-slam-toolbox
  • ros-humble-nav2-bringup
  • ros-humble-nav2-core
  • ros-humble-nav2-costmap-2d
  • ros-humble-nav2-planner
  • ros-humble-nav2-controller
  • ros-humble-nav2-lifecycle-manager
  • ros-humble-rviz2

Nota: Aunque instalamos SLAM y Nav2 completos según la pauta, en este caso práctico usaremos principalmente robot_localization y un nodo propio de navegación por GPS.

Otros prerrequisitos

  • Conocer comandos básicos de terminal: cd, ls, nano, vim.
  • Saber usar ssh para acceder a la Raspberry Pi.
  • Habilidad básica de lectura de pines GPIO e I2C desde documentación.

Materiales

Lista de materiales principales

  • Computadora a bordo
  • 1 × Raspberry Pi 4 Model B (4 GB o 8 GB RAM).
  • Sensor de posicionamiento
  • 1 × Adafruit Ultimate GPS HAT (MTK3339) para Raspberry Pi.
  • Unidad de medida inercial
  • 1 × Waveshare 10-DOF IMU Breakout
    • Acelerómetro + giroscopio: MPU9250
    • Presión/barómetro: BMP280
  • Robot base UGV Beast (ROS 2) – RPi
  • Chasis UGV Beast con motores de tracción diferencial.
  • Controlador de motores (p. ej. puente H doble o driver de motor compatible).
  • Batería adecuada para el UGV y la Raspberry Pi (ej. LiPo 3S con regulador 5 V).
  • Cables y accesorios
  • Cables dupont hembra-hembra para conexión I2C (Waveshare IMU → Raspberry Pi GPIO).
  • Separadores/espaciadores para montar el GPS HAT sobre la Raspberry Pi.
  • Tarjeta microSD (32 GB mínimo) para Ubuntu 22.04.
  • Red
  • Conexión Wi-Fi o Ethernet para acceso remoto a la Raspberry Pi.

Preparación y conexión

1. Instalación del sistema operativo y ROS 2 Humble

En tu PC:

  1. Descargar imagen de Ubuntu Server 22.04 64-bit para Raspberry Pi desde la web oficial de Ubuntu.
  2. Volcarla a la tarjeta microSD (con Raspberry Pi Imager o balenaEtcher).

En la Raspberry Pi (por consola, sin GUI):

Configurar Ubuntu 22.04 y ROS 2 Humble

# Actualizar sistema
sudo apt update
sudo apt upgrade -y

# Configurar locales (si aún no lo has hecho)
sudo apt install -y locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8

# Añadir repositorios ROS 2 Humble
sudo apt install -y software-properties-common
sudo add-apt-repository universe
sudo apt update

sudo apt install -y curl
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
  -o /usr/share/keyrings/ros-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" \
| sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null

sudo apt update

# Instalar ROS 2 Humble desktop
sudo apt install -y ros-humble-desktop

# Paquetes adicionales requeridos
sudo apt install -y \
  ros-humble-ros2-control \
  ros-humble-diff-drive-controller \
  ros-humble-robot-localization \
  ros-humble-slam-toolbox \
  ros-humble-nav2-bringup \
  ros-humble-nav2-core \
  ros-humble-nav2-costmap-2d \
  ros-humble-nav2-planner \
  ros-humble-nav2-controller \
  ros-humble-nav2-lifecycle-manager \
  ros-humble-rviz2

# Herramientas de compilación
sudo apt install -y \
  python3-colcon-common-extensions \
  build-essential \
  cmake \
  git

Añadir source de ROS 2 al .bashrc:

echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
source ~/.bashrc

2. Conexión de sensores a la Raspberry Pi

El Adafruit Ultimate GPS HAT se monta físicamente sobre la Raspberry Pi 4. El Waveshare 10-DOF IMU se conecta por I2C a los pines de la Raspberry Pi.

2.1. Adafruit Ultimate GPS HAT (MTK3339)

  • Se inserta encima del GPIO de la Raspberry Pi, haciendo contacto con los 40 pines.
  • Alimentación: toma 5 V directamente del conector.
  • Comunicación principal: UART (ttyAMA0/ttyS0) y también acceso por I2C a RTC (si está presente, según modelo).

Configuración esencial:

  • Habilitar UART en la Raspberry Pi.
  • Deshabilitar la consola serie sobre el UART principal para usarlo con GPS.

En Ubuntu 22.04 para RPi (sin GUI), editar:

sudo nano /boot/firmware/cmdline.txt

Eliminar cualquier referencia a console=serial0,115200 o similar. Deja solo la consola por tty1.

Luego, habilitar UART en:

sudo nano /boot/firmware/config.txt

Añadir:

enable_uart=1

Guardar y reiniciar:

sudo reboot

Tras el reinicio, el dispositivo serie del GPS suele aparecer como /dev/ttyAMA0 o /dev/serial0. Compruébalo:

ls -l /dev/ttyAMA0 /dev/serial0

2.2. Waveshare 10-DOF IMU Breakout (MPU9250 + BMP280) por I2C

Conectar los pines:

Elemento Pin Raspberry Pi 4 Descripción RPi GPIO Pin IMU Waveshare 10-DOF
Alimentación +3.3 V Pin 1 3V3 Power VCC
GND Pin 6 Ground GND
I2C SDA Pin 3 GPIO2 (SDA1, bus I2C-1) SDA
I2C SCL Pin 5 GPIO3 (SCL1, bus I2C-1) SCL

Asegúrate de que la IMU esté configurada para 3.3 V en caso de jumper/selector.

Habilitar I2C en config.txt:

sudo nano /boot/firmware/config.txt

Añadir:

dtparam=i2c_arm=on

Reiniciar:

sudo reboot

Comprobar que I2C funciona:

sudo apt install -y i2c-tools
sudo i2cdetect -y 1

Deberías ver direcciones típicas como:

  • 0x68 (MPU9250).
  • 0x76 o 0x77 (BMP280).

Código completo: paquete ROS 2 para ros2-gps-waypoint-nav

Crearemos un workspace ~/ros2_ws con:

  • Un paquete ugv_beast_description con URDF sencillo + ros2_control.
  • Un paquete ugv_beast_bringup con lanzadores.
  • Un paquete ros2_gps_waypoint_nav con un nodo Python para navegar waypoints GPS.

1. Crear el workspace y estructura

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws

# Paquete descripción
cd src
ros2 pkg create --build-type ament_cmake ugv_beast_description

# Paquete bringup
ros2 pkg create --build-type ament_cmake ugv_beast_bringup

# Paquete de navegación por GPS (Python)
ros2 pkg create --build-type ament_python ros2_gps_waypoint_nav

2. URDF y ros2_control (ugv_beast_description)

No entraremos en todos los detalles mecánicos del UGV Beast, pero definimos un modelo mínimo.

Crear directorios:

cd ~/ros2_ws/src/ugv_beast_description
mkdir -p urdf config

Archivo urdf/ugv_beast.urdf.xacro (simplificado, parte clave mostrada):

<?xml version="1.0"?>
<robot name="ugv_beast" xmlns:xacro="http://www.ros.org/wiki/xacro">

  <!-- Parámetros físico-geométricos aproximados -->
  <xacro:property name="wheel_radius" value="0.1"/> <!-- 10 cm -->
  <xacro:property name="track_width"  value="0.35"/> <!-- distancia entre ruedas -->

  <link name="base_link">
    <inertial>
      <mass value="10.0"/>
      <origin xyz="0 0 0.1" rpy="0 0 0"/>
      <inertia ixx="0.5" iyy="0.5" izz="0.5"
               ixy="0.0" ixz="0.0" iyz="0.0"/>
    </inertial>
    <visual>
      <origin xyz="0 0 0.1" rpy="0 0 0"/>
      <geometry>
        <box size="0.5 0.3 0.2"/>
      </geometry>
      <material name="gray">
        <color rgba="0.6 0.6 0.6 1.0"/>
      </material>
    </visual>
  </link>

  <!-- Rueda izquierda -->
  <link name="left_wheel_link"/>
  <joint name="left_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="left_wheel_link"/>
    <origin xyz="0 ${track_width/2} 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
  </joint>

  <!-- Rueda derecha -->
  <link name="right_wheel_link"/>
  <joint name="right_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="right_wheel_link"/>
    <origin xyz="0 -${track_width/2} 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
  </joint>

  <!-- Plugin ros2_control dif-drive -->
  <ros2_control name="ugv_beast_controller" type="system">
    <hardware>
      <plugin>ros2_control_hardware_interface/GenericSystem</plugin>
      <!-- En la práctica, aquí iría tu driver propio hacia el controlador de motores -->
    </hardware>
    <joint name="left_wheel_joint">
      <command_interface name="velocity"/>
      <state_interface name="velocity"/>
    </joint>
    <joint name="right_wheel_joint">
      <command_interface name="velocity"/>
      <state_interface name="velocity"/>
    </joint>
  </ros2_control>

</robot>

Archivo config/diff_drive_controller.yaml:

diff_drive_controller:
  ros__parameters:
    use_sim_time: false
    publish_rate: 50.0
    base_frame_id: base_link
    odom_frame_id: odom
    left_wheel_names: ["left_wheel_joint"]
    right_wheel_names: ["right_wheel_joint"]
    wheel_separation: 0.35    # track_width
    wheel_radius: 0.1
    cmd_vel_timeout: 0.5
    publish_wheel_data: true
    enable_odom_tf: true
    velocity_rolling_window_size: 10
    linear:
      x:
        has_velocity_limits: true
        max_velocity: 0.6
        min_velocity: -0.6
    angular:
      z:
        has_velocity_limits: true
        max_velocity: 1.5
        min_velocity: -1.5

Calibración rápida:
– Mide el diámetro de la rueda: radio = diámetro/2.
– Mide la distancia entre centros de ruedas: wheel_separation.
– Ajusta los valores en diff_drive_controller.yaml para que el odómetro no derive demasiado.

3. Configurar robot_localization (EKF IMU + odom)

En ugv_beast_bringup, crear config/ekf.yaml:

cd ~/ros2_ws/src/ugv_beast_bringup
mkdir -p config launch

Archivo config/ekf.yaml:

ekf_filter_node:
  ros__parameters:
    frequency: 30.0
    sensor_timeout: 0.1
    two_d_mode: true
    transform_time_offset: 0.0
    transform_timeout: 0.0
    publish_tf: true
    map_frame: map
    odom_frame: odom
    base_link_frame: base_link
    world_frame: odom

    # Entrada de odometría (p.ej. de diff_drive_controller /odom)
    odom0: /odom
    odom0_config: [true,  true,  false,
                   false, false, true,
                   false, false, false,
                   false, false, false,
                   false, false, false]
    odom0_differential: false
    odom0_relative: false

    # IMU (Waveshare 10-DOF) publicando en /imu/data
    imu0: /imu/data
    imu0_config: [false, false, false,
                  true,  true,  true,
                  false, false, false,
                  false, false, false,
                  false, false, false]
    imu0_differential: false
    imu0_relative: false
    imu0_remove_gravitational_acceleration: true

Ajusta los topics odom0 e imu0 a los que efectivamente generes con tus drivers de IMU y odometría. En este caso asumimos:
/odom publicado por el diff_drive_controller.
/imu/data publicado por un driver de la IMU Waveshare (no desarrollamos aquí el driver específico; puedes usar uno basado en MPU9250 + BMP280 en Python o C++ que publique sensor_msgs/msg/Imu).

4. Nodo de navegación por waypoints GPS (ros2_gps_waypoint_nav)

4.1. Estructura del paquete

En ros2_gps_waypoint_nav, edita package.xml y setup.cfg/setup.py para incluir dependencias básicas: rclpy, sensor_msgs, geometry_msgs, nav_msgs, std_msgs.

package.xml (fragmentos clave):

<package format="3">
  <name>ros2_gps_waypoint_nav</name>
  <version>0.0.1</version>
  <description>Navegación por waypoints GPS para UGV Beast (ROS2)</description>
  <maintainer email="you@example.com">Tu Nombre</maintainer>
  <license>Apache-2.0</license>

  <buildtool_depend>ament_cmake</buildtool_depend>

  <depend>rclpy</depend>
  <depend>sensor_msgs</depend>
  <depend>geometry_msgs</depend>
  <depend>nav_msgs</depend>
  <depend>std_msgs</depend>

  <exec_depend>python3</exec_depend>

</package>

setup.py:

from setuptools import setup

package_name = 'ros2_gps_waypoint_nav'

setup(
    name=package_name,
    version='0.0.1',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages',
         ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='Tu Nombre',
    maintainer_email='you@example.com',
    description='Navegación por waypoints GPS usando ROS2 y UGV Beast',
    license='Apache-2.0',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
            'gps_waypoint_nav = ros2_gps_waypoint_nav.gps_waypoint_nav:main',
        ],
    },
)

Crear directorio de código:

cd ~/ros2_ws/src/ros2_gps_waypoint_nav
mkdir -p ros2_gps_waypoint_nav
touch ros2_gps_waypoint_nav/__init__.py

4.2. Lógica del nodo gps_waypoint_nav.py

Este nodo:

  • Se suscribe a /fix (sensor_msgs/msg/NavSatFix) del GPS MTK3339 en el HAT.
  • Se suscribe a /odometry/filtered (nav_msgs/msg/Odometry) para tener una mejor pose local.
  • Convierte una lista de waypoints GPS (lat, lon) a un sistema local plano (aprox. ENU simple).
  • Calcula la distancia y el ángulo hacia el siguiente waypoint respecto a la orientación actual.
  • Publica comandos /cmd_vel (geometry_msgs/msg/Twist) para avanzar hacia el waypoint.
  • Considera el waypoint alcanzado cuando la distancia es menor a un umbral (p. ej. 2 m).

Archivo ros2_gps_waypoint_nav/gps_waypoint_nav.py:

#!/usr/bin/env python3
import math
from typing import List, Tuple

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import NavSatFix
from nav_msgs.msg import Odometry
from geometry_msgs.msg import Twist
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy

# Utilidad: conversión simple lat/lon a sistema local (aprox UTM plano)
def geodetic_to_local(lat_ref, lon_ref, lat, lon):
    """
    Conversión aproximada de coordenadas geodésicas (lat, lon en grados)
    a coordenadas locales (x, y) en metros usando aproximación de equirectangular.
    Válido para recorridos cortos (< 1 km aprox.).
    """
    # Radio medio de la Tierra (m)
    R = 6378137.0
    # Convertir a radianes
    lat_ref_rad = math.radians(lat_ref)
    lat_rad = math.radians(lat)
    d_lat = lat_rad - lat_ref_rad
    d_lon = math.radians(lon - lon_ref)
    x = R * d_lon * math.cos(lat_ref_rad)
    y = R * d_lat
    return x, y


class GPSWaypointNavNode(Node):
    def __init__(self):
        super().__init__('gps_waypoint_nav')

        # Parámetros configurables
        self.declare_parameter('waypoints', [
            # Ejemplo: lista [lat1, lon1, lat2, lon2, ...]
            40.4168, -3.7038,  # Punto 1
            40.4169, -3.7035,  # Punto 2
            40.4170, -3.7032   # Punto 3
        ])
        self.declare_parameter('goal_tolerance', 2.0)        # en metros
        self.declare_parameter('linear_speed', 0.3)          # m/s
        self.declare_parameter('angular_speed_gain', 1.0)    # factor proporcional
        self.declare_parameter('max_angular_speed', 1.0)     # rad/s
        self.declare_parameter('fix_topic', '/fix')
        self.declare_parameter('odom_topic', '/odometry/filtered')
        self.declare_parameter('cmd_vel_topic', '/cmd_vel')

        # Cargar parámetros
        wp_list = self.get_parameter('waypoints').get_parameter_value().double_array_value
        if len(wp_list) % 2 != 0:
            self.get_logger().error('El parámetro "waypoints" debe tener longitud par (lat, lon)')
            wp_list = []

        self.waypoints: List[Tuple[float, float]] = []
        for i in range(0, len(wp_list), 2):
            self.waypoints.append((wp_list[i], wp_list[i+1]))

        self.goal_tolerance = self.get_parameter('goal_tolerance').get_parameter_value().double_value
        self.linear_speed = self.get_parameter('linear_speed').get_parameter_value().double_value
        self.angular_speed_gain = self.get_parameter('angular_speed_gain').get_parameter_value().double_value
        self.max_angular_speed = self.get_parameter('max_angular_speed').get_parameter_value().double_value

        fix_topic = self.get_parameter('fix_topic').get_parameter_value().string_value
        odom_topic = self.get_parameter('odom_topic').get_parameter_value().string_value
        cmd_vel_topic = self.get_parameter('cmd_vel_topic').get_parameter_value().string_value

        # Estado interno
        self.current_fix: NavSatFix = None
        self.current_yaw: float = 0.0
        self.local_ref_set: bool = False
        self.lat_ref: float = 0.0
        self.lon_ref: float = 0.0
        self.current_wp_idx: int = 0

        qos = QoSProfile(
            reliability=ReliabilityPolicy.BEST_EFFORT,
            history=HistoryPolicy.KEEP_LAST,
            depth=10
        )

        # Suscripciones
        self.fix_sub = self.create_subscription(
            NavSatFix, fix_topic, self.fix_callback, qos)

        self.odom_sub = self.create_subscription(
            Odometry, odom_topic, self.odom_callback, 10)

        # Publicador de velocidad
        self.cmd_vel_pub = self.create_publisher(Twist, cmd_vel_topic, 10)

        # Timer de control (10 Hz)
        self.control_timer = self.create_timer(0.1, self.control_loop)

        self.get_logger().info('Nodo gps_waypoint_nav inicializado.')
        self.get_logger().info(f'Waypoints cargados: {self.waypoints}')

    def fix_callback(self, msg: NavSatFix):
        self.current_fix = msg
        if not self.local_ref_set and msg.status.status >= 0:
            # Establecer referencia local en el primer fix válido
            self.lat_ref = msg.latitude
            self.lon_ref = msg.longitude
            self.local_ref_set = True
            self.get_logger().info(
                f'Referencia local establecida en lat={self.lat_ref}, lon={self.lon_ref}')

    def odom_callback(self, msg: Odometry):
        # Extraer yaw de la orientación cuaternión
        q = msg.pose.pose.orientation
        # Conversión quaternion -> yaw
        siny_cosp = 2.0 * (q.w * q.z + q.x * q.y)
        cosy_cosp = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
        self.current_yaw = math.atan2(siny_cosp, cosy_cosp)

    def control_loop(self):
        # Condiciones mínimas
        if not self.waypoints:
            self.get_logger().warn_once('No hay waypoints configurados.')
            return

        if self.current_fix is None or not self.local_ref_set:
            self.get_logger().warn_once('Esperando a posición GPS válida...')
            return

        if self.current_wp_idx >= len(self.waypoints):
            # Ruta completada
            self.stop_robot()
            self.get_logger().info_once('Todos los waypoints alcanzados.')
            return

        # Waypoint objetivo actual
        target_lat, target_lon = self.waypoints[self.current_wp_idx]

        # Posición actual en sistema local (x, y)
        cur_x, cur_y = geodetic_to_local(
            self.lat_ref, self.lon_ref,
            self.current_fix.latitude, self.current_fix.longitude
        )

        # Posición objetivo en sistema local (x, y)
        goal_x, goal_y = geodetic_to_local(
            self.lat_ref, self.lon_ref,
            target_lat, target_lon
        )

        dx = goal_x - cur_x
        dy = goal_y - cur_y
        distance = math.hypot(dx, dy)

        # Comprobar si el waypoint está alcanzado
        if distance <= self.goal_tolerance:
            self.get_logger().info(
                f'Waypoint {self.current_wp_idx} alcanzado. Distancia={distance:.2f} m')
            self.current_wp_idx += 1
            # Parar brevemente
            self.stop_robot()
            return

        # Ángulo hacia el objetivo en el sistema local
        target_yaw = math.atan2(dy, dx)
        yaw_error = self.normalize_angle(target_yaw - self.current_yaw)

        # Control sencillo P de la orientación y avance constante
        twist = Twist()
        twist.linear.x = self.linear_speed
        twist.angular.z = self.angular_speed_gain * yaw_error

        # Saturar velocidad angular
        if twist.angular.z > self.max_angular_speed:
            twist.angular.z = self.max_angular_speed
        elif twist.angular.z < -self.max_angular_speed:
            twist.angular.z = -self.max_angular_speed

        self.cmd_vel_pub.publish(twist)

    def stop_robot(self):
        twist = Twist()
        self.cmd_vel_pub.publish(twist)

    @staticmethod
    def normalize_angle(angle):
        # Llevar ángulo a rango [-pi, pi]
        while angle > math.pi:
            angle -= 2.0 * math.pi
        while angle < -math.pi:
            angle += 2.0 * math.pi
        return angle


def main(args=None):
    rclpy.init(args=args)
    node = GPSWaypointNavNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    node.stop_robot()
    node.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

Dar permisos de ejecución:

chmod +x ~/ros2_ws/src/ros2_gps_waypoint_nav/ros2_gps_waypoint_nav/gps_waypoint_nav.py

Compilación y ejecución

1. Compilar el workspace con colcon

cd ~/ros2_ws
colcon build

Tras compilar sin errores:

source install/setup.bash

Para que se haga siempre:

echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc
source ~/.bashrc

2. Lanzar los componentes requeridos

En una sesión tmux o varias terminales SSH, seguiremos una secuencia.

  1. Lanzar driver GPS
    Puedes usar un nodo que publique sensor_msgs/NavSatFix en /fix a partir de /dev/ttyAMA0. Por ejemplo, usando nmea_navsat_driver (si lo instalas) o un script propio. Suponiendo que ya tienes un nodo publicando /fix:

bash
ros2 topic echo /fix # Para verificar

  1. Lanzar IMU driver
    De forma análoga, lanza tu driver para MPU9250 + BMP280 que publique en /imu/data (tipo sensor_msgs/msg/Imu).

bash
ros2 topic echo /imu/data

  1. Lanzar diff_drive_controller + robot_localization
    Crea un launch sencillo en ugv_beast_bringup/launch/ugv_beast_bringup.launch.py (esquema orientativo, sin mostrarlo entero aquí por extensión) que:

  2. Cargue el URDF de ugv_beast_description.

  3. Inicie controller_manager con diff_drive_controller.
  4. Inicie el nodo ekf_node de robot_localization con ekf.yaml.

Ejecútalo (ejemplo):

bash
ros2 launch ugv_beast_bringup ugv_beast_bringup.launch.py

  1. Lanzar el nodo ros2-gps-waypoint-nav

En otra terminal:

bash
ros2 run ros2_gps_waypoint_nav gps_waypoint_nav \
--ros-args \
-p waypoints:="[40.4168, -3.7038, 40.4169, -3.7035, 40.4170, -3.7032]" \
-p goal_tolerance:=2.0 \
-p linear_speed:=0.3 \
-p angular_speed_gain:=1.0 \
-p max_angular_speed:=0.8

Asegúrate de usar coordenadas GPS reales de tu entorno de prueba.


Validación paso a paso

1. Validar sensores

  1. GPS HAT (MTK3339):
  2. Comprobar dispositivo:

    bash
    ls -l /dev/ttyAMA0
    sudo cat /dev/ttyAMA0 | head

  3. Debes ver mensajes NMEA ($GPGGA, $GPRMC, etc.).

  4. En ROS 2, verifica el topic /fix:

    bash
    ros2 topic list
    ros2 topic echo /fix

  5. Señales de éxito:

    • Frecuencia ≥ 1 Hz (ros2 topic hz /fix).
    • status.status >= 0 (fix 2D/3D).
  6. IMU Waveshare 10-DOF:

  7. i2cdetect -y 1 muestra direcciones del MPU9250 y BMP280.
  8. En ROS 2:

    bash
    ros2 topic echo /imu/data
    ros2 topic hz /imu/data

  9. Espera al menos 50 Hz si el driver está configurado a esa tasa.

2. Validar odometría y EKF

  • Con diff_drive_controller activo:

bash
ros2 topic echo /odom
ros2 topic echo /odometry/filtered

  • Mover ligeramente el robot (o ruedas en el aire) y verificar que las posiciones cambian coherentemente.
  • Comprobar TF:

bash
ros2 run tf2_tools view_frames

Luego inspeccionar el PDF generado y verificar frames: odom -> base_link, map -> odom.

3. Validar /cmd_vel desde el nodo de navegación

  • Antes de mover el robot en el suelo, haz una prueba con el robot levantado (ruedas en el aire) o sin motores energizados:
  • Levanta el nodo de navegación.

  • En otra terminal:

    bash
    ros2 topic echo /cmd_vel

  • Fija waypoints cercanos a tu posición actual.

  • Comprueba que:
    • /cmd_vel tiene linear.x alrededor de 0.3.
    • angular.z cambia de signo según el ángulo hacia el objetivo.

4. Prueba de campo

  1. Coloca el robot en un espacio abierto con buena visibilidad de cielo.
  2. Espera a que el GPS consiga un fix estable (idealmente HDOP bajo, si expones este dato).
  3. Mide las coordenadas del punto inicial (puedes leer /fix y anotar latitude/longitude).
  4. Define 2–3 waypoints alrededor (p.ej. formar un triángulo de 10–15 m de lado).
  5. Lanza todos los nodos como en la sección anterior.
  6. Observa:
  7. El robot se orienta hacia el primer waypoint y avanza.
  8. Al entrar en el radio de tolerancia (~2 m), se detiene brevemente, cambia de orientación y se dirige al siguiente.
  9. Completa la secuencia de waypoints con desviación ≤ 3–5 m respecto a cada punto (limitado por precisión GPS).

Troubleshooting (errores típicos y soluciones)

  1. No aparece /fix en ROS 2
  2. Causas probables:
    • UART está todavía ocupado por la consola serie.
    • Driver del GPS no está usando /dev/ttyAMA0 correcto.
  3. Solución:

    • Revisa /boot/firmware/cmdline.txt y asegúrate de quitar console=serial0,....
    • Verifica enable_uart=1 en config.txt.
    • Confirma el dispositivo con ls -l /dev/ttyAMA0 /dev/serial0.
  4. /imu/data no existe

  5. Causas:
    • I2C no habilitado.
    • Cableado incorrecto (SDA/SCL invertidos o sin GND común).
  6. Solución:

    • Habilita dtparam=i2c_arm=on.
    • Revisa que el Waveshare IMU 10-DOF esté alimentado a 3.3 V.
    • Verifica direcciones con i2cdetect -y 1.
  7. El robot gira en círculos y no avanza hacia el waypoint

  8. Causas:
    • Frame de referencia de odometría mal configurado.
    • Orientación (yaw) no coincide con el eje del robot.
  9. Solución:

    • Comprueba que base_link está alineado con la dirección de avance del robot en el URDF.
    • Revisa TF (odom -> base_link) y confirmarlo en RViz.
    • Ajusta angular_speed_gain y max_angular_speed para evitar oscilaciones.
  10. El robot se detiene aleatoriamente aunque aún no llegó al waypoint

  11. Causas:
    • Pérdidas de señal GPS: status.status < 0 o saltos bruscos.
    • robot_localization no recibe datos a tiempo (time-out).
  12. Solución:

    • Verifica calidad de señal GPS en un espacio más abierto.
    • Asegúrate de que la frecuencia de /fix sea estable.
    • Ajusta sensor_timeout en ekf.yaml si es demasiado bajo.
  13. Error de compilación de ros2_gps_waypoint_nav

  14. Causas:
    • setup.py o package.xml mal configurados.
  15. Solución:

    • Revisa que packages=[package_name] en setup.py.
    • Verifica que __init__.py existe.
    • Asegúrate de que ros2_gps_waypoint_nav/gps_waypoint_nav.py es ejecutable.
  16. colcon build no encuentra dependencias como rclpy

  17. Causas:
    • ros-humble-desktop o python3-rclpy no instalados.
  18. Solución:

    • Reinstala paquetes ROS 2 básicos:

    bash
    sudo apt install -y ros-humble-desktop python3-rclpy

  19. El robot avanza demasiado rápido o se sale del área de pruebas

  20. Causas:
    • Parámetros de velocidad demasiado altos (linear_speed, max_angular_speed).
  21. Solución:

    • Reduce linear_speed a 0.1–0.2 m/s.
    • Limita max_angular_speed a 0.5 rad/s.
    • Aumenta goal_tolerance si es necesario.
  22. La ruta de waypoints no se completa (se queda en el waypoint 0)

  23. Causas:
    • La lista waypoints no está bien formateada o el umbral de cercanía es muy pequeño.
  24. Solución:
    • Revisa el parámetro waypoints: longitud par, en grados decimales.
    • Aumenta goal_tolerance a 3–4 m como prueba.

Mejoras y variantes

  • Uso de Nav2 completo:
    Integrar el nodo gps_waypoint_nav con Nav2, convirtiendo cada waypoint GPS a un objetivo en el frame map y dejando que Nav2 planifique la trayectoria detallada.
  • Planificación con obstáculos:
    Añadir un LiDAR (ej. RPLIDAR A1) y configurar slam_toolbox + costmaps de Nav2 para evitar obstáculos entre waypoints.
  • Fusión de barómetro (BMP280):
    Integrar lecturas de altitud (presión) en robot_localization para entornos con cambios de altura moderados.
  • Registro de datos:
    Usar ros2 bag record para guardar /fix, /imu/data, /odometry/filtered, /cmd_vel y analizar rutas recorridas.
  • Interfaz de configuración de waypoints:
    Implementar un servicio ROS 2 que permita cambiar dinámicamente la lista de waypoints sin reiniciar el nodo.
  • Modo “return-to-home”:
    Guardar la posición inicial y añadir un último waypoint que lleve al robot de vuelta a origen.

Checklist de verificación

Marca cada ítem cuando lo completes:

  • [ ] Ubuntu 22.04 64-bit instalado y accesible por SSH en la Raspberry Pi 4 Model B.
  • [ ] ROS 2 Humble instalado con ros-humble-desktop y paquetes adicionales (ros2-control, robot_localization, slam-toolbox, nav2*, rviz2).
  • [ ] Workspace ~/ros2_ws creado y compilado con colcon build.
  • [ ] Adafruit Ultimate GPS HAT (MTK3339) montado correctamente sobre la Raspberry Pi.
  • [ ] UART habilitado (enable_uart=1) y consola serie deshabilitada en /boot/firmware/cmdline.txt.
  • [ ] /dev/ttyAMA0 muestra datos NMEA y hay un nodo ROS 2 publicando en /fix (≥ 1 Hz).
  • [ ] Waveshare 10-DOF IMU Breakout (MPU9250 + BMP280) conectado por I2C (pines SDA/SCL correctos).
  • [ ] i2cdetect -y 1 detecta direcciones del MPU9250 y BMP280.
  • [ ] Nodo IMU operativo publicando /imu/data a ≥ 50 Hz.
  • [ ] URDF del robot cargado, diff_drive_controller operativo, publicando /odom.
  • [ ] robot_localization configurado con ekf.yaml y publicando /odometry/filtered.
  • [ ] Nodo gps_waypoint_nav se ejecuta sin errores y publica en /cmd_vel.
  • [ ] En prueba estática, /cmd_vel cambia según la posición del waypoint.
  • [ ] En prueba en campo, el robot recorre al menos 3 waypoints con error ≤ 3–5 m.
  • [ ] Has aplicado al menos una mejora (p. ej. ajuste de velocidades, tolerancias, o logging de datos).

Con todo esto, habrás completado con éxito el caso práctico de ros2-gps-waypoint-nav sobre el UGV Beast (ROS 2) – Raspberry Pi 4 Model B + Adafruit Ultimate GPS HAT (MTK3339) + Waveshare 10-DOF IMU Breakout (MPU9250 + BMP280), logrando un sistema funcional de navegación básica por waypoints GPS con ROS 2 Humble.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el objetivo principal del sistema que se va a construir?




Pregunta 2: ¿Qué hardware se utilizará para la navegación del UGV?




Pregunta 3: ¿Cuál es la precisión de llegada típica al waypoint según el artículo?




Pregunta 4: ¿Qué frecuencia de publicación se espera para los datos de odometría?




Pregunta 5: ¿Qué tipo de tareas se pueden realizar con este sistema?




Pregunta 6: ¿Qué se busca evitar en la transición entre waypoints?




Pregunta 7: ¿Cuál es la carga de CPU máxima permitida en la Raspberry Pi 4?




Pregunta 8: ¿Qué tipo de público está dirigido el sistema?




Pregunta 9: ¿Cuál es el propósito de usar RViz2 en el sistema?




Pregunta 10: ¿Qué tipo de rutas se pueden ejecutar en almacenes semiabiertos?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: teleoperación UGV Beast (ROS 2) con joystick

Caso práctico: teleoperación UGV Beast (ROS 2) con joystick — hero

Objetivo y caso de uso

Qué construirás: Un UGV Beast basado en ROS 2 Humble sobre Raspberry Pi 4 con HAT CAN MCP2515, teleoperado mediante joystick usando joy, teleop_twist_joy y diff_drive_controller enviando comandos de velocidad por bus CAN.

Para qué sirve

  • Realizar pruebas de movilidad en laboratorio recorriendo pasillos de 5–15 m verificando tracción, curvas de 90° y distancia de frenado < 30 cm.
  • Entrenar maniobras de precisión (giros en U en pasillos de 1 m, marcha atrás hasta una diana a < 5 cm de error).
  • Generar trayectorias de referencia y mapas iniciales grabando cmd_vel y odometría para futuros algoritmos de navegación autónoma.
  • Validar el hardware de locomoción comprobando que los motores responden a rampas de velocidad lineal 0–0.8 m/s y angular 0–1 rad/s sin fallos CAN.
  • Usar el robot en demostraciones o clases, permitiendo control inmediato con joystick sin desarrollar lógica de navegación compleja.

Resultado esperado

  • Teleoperación fluida con frecuencia estable de comandos cmd_vel de 20–50 Hz y latencia joystick->ruedas < 80 ms medida con ROS 2 (ros2 topic hz, ros2 topic delay).
  • Uso de CPU en la Raspberry Pi < 35% y carga de bus CAN < 40% al mantener movimientos continuos (avance recto, giros, frenadas) durante sesiones de > 10 minutos.
  • Respuesta estable del controlador diferencial: sin oscilaciones perceptibles y error en velocidad lineal < 10% respecto al comando durante tramos de 3–5 m.
  • Capacidad de seguir un recorrido en pasillo de 10 m con dos giros de 90° sin pérdidas de comunicación CAN ni paradas inesperadas.

Público objetivo: Desarrolladores de robótica móvil, equipos de I+D y docentes; Nivel: Intermedio en ROS 2 y Linux embebido.

Arquitectura/flujo: Joystick USB conectado a estación de control → nodo joy en ROS 2 → nodo teleop_twist_joy genera geometry_msgs/Twist en /cmd_veldiff_drive_controller en la Raspberry Pi convierte cmd_vel en velocidades de rueda → frame CAN a través del Waveshare 2-CH CAN HAT (MCP2515) → controladoras de motor del UGV Beast ejecutan los comandos.

Prerrequisitos

Sistema operativo y versiones

Se usará una configuración reproducible basada en línea de comandos (sin entorno gráfico):

  • Placa principal: Raspberry Pi 4 Model B 4GB.
  • SO en la Raspberry Pi:
  • Ubuntu Server 22.04.5 LTS 64-bit (aarch64) para Raspberry Pi.
  • Kernel típico: 5.15.x (el que instala Ubuntu 22.04 por defecto en Pi 4).
  • ROS 2 en la Raspberry Pi:
  • ROS 2 Humble Hawksbill (aarch64).
  • Instalación por apt.

Toolchain exacta

En la Raspberry Pi 4:

  • Compilador C++:
  • g++ (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
  • CMake:
  • cmake version 3.22.1
  • Colcon:
  • python3-colcon-common-extensions versión de repositorios de Ubuntu 22.04.
  • Python:
  • Python 3.10.x (por defecto en Ubuntu 22.04).
  • ROS 2 paquetes clave (instalación por apt):
  • ros-humble-desktop
  • ros-humble-ros2-control
  • ros-humble-diff-drive-controller
  • ros-humble-robot-localization
  • ros-humble-slam-toolbox
  • ros-humble-nav2-bringup
  • ros-humble-nav2-simple-commander (normalmente incluido en nav2)
  • ros-humble-rviz2
  • ros-humble-joy
  • ros-humble-teleop-twist-joy
  • Herramientas CAN:
  • can-utils 2021.08.0 (instalada desde apt como can-utils).

En el PC remoto (opcional, para joystick y RViz2), se recomienda:

  • Ubuntu Desktop 22.04.5 LTS 64-bit.
  • ROS 2 Humble instalado por apt igualmente.

Materiales

Lista de materiales principales

  1. Computadora de a bordo:
  2. Raspberry Pi 4 Model B 4GB (modelo exacto requerido).
  3. HAT CAN:
  4. Waveshare 2-CH CAN HAT (MCP2515), compatible con Raspberry Pi, interfaz SPI.
  5. UGV Beast (base móvil):
  6. Chasis UGV Beast con:
    • Controladora de motores con interfaz CAN (p. ej., un controlador propio del UGV o ESC con protocolo CAN).
    • Motores de tracción diferencial (rueda izquierda y derecha).
  7. Alimentación:
  8. Batería del UGV Beast (p. ej., LiPo 12 V o 24 V según especificación del robot).
  9. Regulador DC-DC para alimentar la Raspberry Pi 4 con 5 V / 3 A (mínimo).
  10. Conectividad:
  11. Cable CAN (par trenzado, p. ej., cable con hilos verde/amarillo).
  12. Conectores para interfaz CAN del UGV (según hardware del Beast).
  13. Joystick USB:
  14. Cualquier joystick o gamepad genérico reconocido por Linux (p. ej., Logitech F310, Xbox 360 Controller con cable).
  15. Red:
  16. Router WiFi o cable Ethernet para conectar:
    • Raspberry Pi 4
    • PC remoto (para lanzar teleop y RViz2 si se desea).
  17. Almacenamiento:
  18. Tarjeta microSD (32 GB, Clase 10 o superior) para Ubuntu 22.04.

Preparación y conexión

1. Conexión física del Waveshare 2-CH CAN HAT (MCP2515)

El HAT se monta directamente sobre la Raspberry Pi 4 mediante el conector de 40 pines (GPIO header). No se requieren cables intermedios para el bus SPI interno, ya que el HAT se alinea con el conector.

Los pines clave usados por el MCP2515 son:

Función MCP2515 Pin en Raspberry Pi 4 Notas
VCC (3.3V) Pin 1 (3V3) Alimentación lógica
GND Pin 6 (GND) Tierra común
SPI_MOSI Pin 19 (GPIO10) MOSI (SPI0_MOSI)
SPI_MISO Pin 21 (GPIO9) MISO (SPI0_MISO)
SPI_SCK Pin 23 (GPIO11) SCLK (SPI0_SCLK)
SPI_CS Pin 24 (GPIO8) CE0 (SPI0_CE0_N), asignado a mcp2515
INT (interrup.) Pin 22 (GPIO25) Línea de interrupción del MCP2515

En la práctica, al usar el HAT apilado, ya vienen todos esos pines cableados internamente.

2. Conexión CAN entre HAT y controladora del UGV Beast

El HAT Waveshare 2-CH CAN ofrece dos canales CAN (CAN0, CAN1). Para este caso práctico usaremos CAN0.

En el HAT, los terminales suelen etiquetarse como:

  • CAN0_H y CAN0_L
  • (Y otros dos para CAN1_H y CAN1_L)

Conecta:

  • CAN0_H del HAT → CAN_H de la controladora del UGV Beast.
  • CAN0_L del HAT → CAN_L de la controladora del UGV Beast.

Asegúrate de:

  • Tener resistencia de terminación de 120 Ω en cada extremo del bus (a menudo el HAT tiene un jumper para activar esta resistencia; el UGV Beast puede tenerla integrada).
  • Mantener el cable par trenzado para minimizar interferencias.

3. Alimentación

  1. Alimenta la Raspberry Pi 4 con:
  2. 5 V, 3 A mínimo (mediante USB-C o un regulador DC-DC desde la batería del UGV).
  3. Alimenta la controladora de motores del UGV Beast según su especificación (p. ej., batería de 12 V o 24 V).
  4. Asegúrate de que la tierra (GND) de la Raspberry Pi 4 y la controladora del UGV Beast estén comunes (normalmente se comparte por el bus CAN, pero verifica el diseño).

Código completo y configuración ROS 2

El objetivo del proyecto es ros2-joy-teleop-ugv: teleoperar el UGV Beast por joystick. Para ello:

  1. Usaremos paquetes estándar:
  2. joy (lectura del joystick).
  3. teleop_twist_joy (convierte ejes/botones del joystick en geometry_msgs/Twistcmd_vel).
  4. diff_drive_controller (recibe cmd_vel y genera comandos de rueda).
  5. Implementaremos un paquete propio que:
  6. Defina el modelo URDF básico del UGV.
  7. Configure ros2_control + diff_drive_controller.
  8. Publique los comandos de rueda en un nodo CAN (C++) que hable con la controladora de motores por MCP2515.

1. Creación del workspace y paquete

En la Raspberry Pi 4:

# 1. Configurar ROS 2 Humble (tras instalarlo por apt)
sudo apt update
sudo apt install -y \
  ros-humble-desktop \
  ros-humble-ros2-control \
  ros-humble-diff-drive-controller \
  ros-humble-robot-localization \
  ros-humble-slam-toolbox \
  ros-humble-nav2-bringup \
  ros-humble-rviz2 \
  ros-humble-joy \
  ros-humble-teleop-twist-joy \
  can-utils

# 2. Sourcing inicial
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
source ~/.bashrc

# 3. Crear workspace
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src

# 4. Crear paquete para el UGV Beast
ros2 pkg create --build-type ament_cmake ugv_beast_teleop --dependencies rclcpp std_msgs geometry_msgs nav_msgs control_msgs hardware_interface pluginlib

2. Modelo URDF y ros2_control

En este nivel básico, definimos un modelo mínimo del UGV Beast con tracción diferencial:

  • base_link como cuerpo principal.
  • Dos ruedas: left_wheel_link y right_wheel_link.
  • Parámetros mecánicos (ejemplo, ajustables en calibración):
  • Radio de rueda: 0.1 m (10 cm).
  • Distancia entre ruedas (track width): 0.4 m.

Crea un directorio urdf en el paquete:

cd ~/ros2_ws/src/ugv_beast_teleop
mkdir urdf config launch src

Archivo urdf/ugv_beast.urdf.xacro (o .urdf simple; aquí lo haremos en URDF directo para simplificar):

<?xml version="1.0"?>
<robot name="ugv_beast" xmlns:xacro="http://ros.org/wiki/xacro">

  <link name="base_link">
    <inertial>
      <origin xyz="0 0 0.05" rpy="0 0 0"/>
      <mass value="10.0"/>
      <inertia ixx="0.5" ixy="0.0" ixz="0.0" iyy="0.8" iyz="0.0" izz="1.0"/>
    </inertial>
    <visual>
      <origin xyz="0 0 0.05" rpy="0 0 0"/>
      <geometry>
        <box size="0.5 0.3 0.1"/>
      </geometry>
      <material name="gray">
        <color rgba="0.5 0.5 0.5 1.0"/>
      </material>
    </visual>
    <collision>
      <origin xyz="0 0 0.05" rpy="0 0 0"/>
      <geometry>
        <box size="0.5 0.3 0.1"/>
      </geometry>
    </collision>
  </link>

  <link name="left_wheel_link">
    <visual>
      <geometry>
        <cylinder length="0.05" radius="0.1"/>
      </geometry>
    </visual>
  </link>

  <link name="right_wheel_link">
    <visual>
      <geometry>
        <cylinder length="0.05" radius="0.1"/>
      </geometry>
    </visual>
  </link>

  <joint name="left_wheel_joint" type="continuous">
    <origin xyz="0 0.2 0.0" rpy="0 0 0"/>
    <parent link="base_link"/>
    <child link="left_wheel_link"/>
    <axis xyz="0 1 0"/>
  </joint>

  <joint name="right_wheel_joint" type="continuous">
    <origin xyz="0 -0.2 0.0" rpy="0 0 0"/>
    <parent link="base_link"/>
    <child link="right_wheel_link"/>
    <axis xyz="0 1 0"/>
  </joint>

  <!-- ros2_control interface -->
  <ros2_control name="ugv_beast_system" type="system">
    <hardware>
      <plugin>ugv_beast_teleop/CanDiffDriveHardware</plugin>
      <param name="can_interface">can0</param>
      <param name="wheel_radius">0.1</param>
      <param name="wheel_separation">0.4</param>
    </hardware>

    <joint name="left_wheel_joint">
      <command_interface name="velocity"/>
      <state_interface name="velocity"/>
    </joint>

    <joint name="right_wheel_joint">
      <command_interface name="velocity"/>
      <state_interface name="velocity"/>
    </joint>

    <controller name="diff_controllers" type="diff_drive_controller/DiffDriveController">
      <param name="left_wheel_names">["left_wheel_joint"]</param>
      <param name="right_wheel_names">["right_wheel_joint"]</param>
      <param name="wheel_separation">0.4</param>
      <param name="wheel_radius">0.1</param>
      <param name="publish_rate">50.0</param>
      <param name="cmd_vel_timeout">0.5</param>
      <param name="use_stamped_vel">false</param>
    </controller>
  </ros2_control>

</robot>

Nota: El plugin ugv_beast_teleop/CanDiffDriveHardware será nuestro hardware_interface que envía comandos por CAN.

3. Nodo de hardware CAN (C++): enviar comandos a la controladora del UGV

Crearemos un hardware_interface simple y un nodo que demuestre el envío de comandos por CAN. Para un nivel básico, enfocaremos el ejemplo a:

  • Leer velocidades de rueda desde ros2_control.
  • Convertirlas a tramas CAN (formato simplificado).
  • Publicarlas en el bus can0 usando socketcan.

Archivo src/can_diff_drive_hardware.cpp (fragmento principal):

#include <rclcpp/rclcpp.hpp>
#include <hardware_interface/system_interface.hpp>
#include <hardware_interface/handle.hpp>
#include <hardware_interface/hardware_info.hpp>
#include <hardware_interface/types/hardware_interface_type_values.hpp>
#include <pluginlib/class_list_macros.hpp>

#include <linux/can.h>
#include <linux/can/raw.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <net/if.h>
#include <cstring>
#include <unistd.h>

using CallbackReturn = hardware_interface::CallbackReturn;

namespace ugv_beast_teleop
{

class CanDiffDriveHardware : public hardware_interface::SystemInterface
{
public:
  CanDiffDriveHardware() = default;

  CallbackReturn on_init(const hardware_interface::HardwareInfo & info) override
  {
    if (hardware_interface::SystemInterface::on_init(info) != CallbackReturn::SUCCESS) {
      return CallbackReturn::ERROR;
    }

    wheel_radius_ = std::stod(info.hardware_parameters.at("wheel_radius"));
    wheel_separation_ = std::stod(info.hardware_parameters.at("wheel_separation"));
    can_interface_ = info.hardware_parameters.at("can_interface");

    // Inicializar socket CAN
    if ((can_socket_ = socket(PF_CAN, SOCK_RAW, CAN_RAW)) < 0) {
      RCLCPP_ERROR(rclcpp::get_logger("CanDiffDriveHardware"), "Error creating CAN socket");
      return CallbackReturn::ERROR;
    }

    struct ifreq ifr;
    std::strncpy(ifr.ifr_name, can_interface_.c_str(), IFNAMSIZ - 1);
    if (ioctl(can_socket_, SIOCGIFINDEX, &ifr) < 0) {
      RCLCPP_ERROR(rclcpp::get_logger("CanDiffDriveHardware"), "CAN interface %s not found", can_interface_.c_str());
      return CallbackReturn::ERROR;
    }

    struct sockaddr_can addr{};
    addr.can_family = AF_CAN;
    addr.can_ifindex = ifr.ifr_ifindex;

    if (bind(can_socket_, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
      RCLCPP_ERROR(rclcpp::get_logger("CanDiffDriveHardware"), "Error binding CAN socket");
      return CallbackReturn::ERROR;
    }

    // Inicializar vectores de estados y comandos
    hw_states_.resize(2, 0.0);
    hw_commands_.resize(2, 0.0);

    return CallbackReturn::SUCCESS;
  }

  std::vector<hardware_interface::StateInterface> export_state_interfaces() override
  {
    std::vector<hardware_interface::StateInterface> interfaces;
    interfaces.emplace_back(hardware_interface::StateInterface(
      "left_wheel_joint", hardware_interface::HW_IF_VELOCITY, &hw_states_[0]));
    interfaces.emplace_back(hardware_interface::StateInterface(
      "right_wheel_joint", hardware_interface::HW_IF_VELOCITY, &hw_states_[1]));
    return interfaces;
  }

  std::vector<hardware_interface::CommandInterface> export_command_interfaces() override
  {
    std::vector<hardware_interface::CommandInterface> interfaces;
    interfaces.emplace_back(hardware_interface::CommandInterface(
      "left_wheel_joint", hardware_interface::HW_IF_VELOCITY, &hw_commands_[0]));
    interfaces.emplace_back(hardware_interface::CommandInterface(
      "right_wheel_joint", hardware_interface::HW_IF_VELOCITY, &hw_commands_[1]));
    return interfaces;
  }

  hardware_interface::return_type read(const rclcpp::Time &, const rclcpp::Duration &) override
  {
    // En este ejemplo básico, asumimos que no leemos estados reales (odometría directa del motor).
    // Simplemente copiamos comandos a estados para tener algo coherente.
    hw_states_[0] = hw_commands_[0];
    hw_states_[1] = hw_commands_[1];
    return hardware_interface::return_type::OK;
  }

  hardware_interface::return_type write(const rclcpp::Time &, const rclcpp::Duration &) override
  {
    // Convertir velocidades de rueda (rad/s) a mensaje CAN según protocolo del UGV Beast.
    // Ejemplo simple: ID 0x100, 4 bytes: int16_t left, int16_t right (en centi-rad/s).

    struct can_frame frame{};
    frame.can_id = 0x100;
    frame.can_dlc = 4;

    int16_t left = static_cast<int16_t>(hw_commands_[0] * 100.0);
    int16_t right = static_cast<int16_t>(hw_commands_[1] * 100.0);

    frame.data[0] = left & 0xFF;
    frame.data[1] = (left >> 8) & 0xFF;
    frame.data[2] = right & 0xFF;
    frame.data[3] = (right >> 8) & 0xFF;

    int nbytes = write_can_frame(frame);
    if (nbytes != sizeof(struct can_frame)) {
      RCLCPP_WARN(rclcpp::get_logger("CanDiffDriveHardware"), "Failed to send full CAN frame");
    }

    return hardware_interface::return_type::OK;
  }

private:
  int write_can_frame(const struct can_frame &frame)
  {
    return ::write(can_socket_, &frame, sizeof(frame));
  }

  double wheel_radius_{0.1};
  double wheel_separation_{0.4};
  std::string can_interface_{"can0"};
  int can_socket_{-1};

  std::vector<double> hw_states_;
  std::vector<double> hw_commands_;
};

}  // namespace ugv_beast_teleop

PLUGINLIB_EXPORT_CLASS(ugv_beast_teleop::CanDiffDriveHardware, hardware_interface::SystemInterface)

Este código:

  • Implementa un SystemInterface para ros2_control.
  • Abre un socket CAN sobre la interfaz can0.
  • Envía una trama CAN con las velocidades de rueda codificadas como enteros de 16 bits (formato de ejemplo; deberías adaptarlo al protocolo real de tu UGV Beast).

4. CMakeLists.txt y package.xml

En CMakeLists.txt añade:

cmake_minimum_required(VERSION 3.8)
project(ugv_beast_teleop)

if(NOT CMAKE_CXX_STANDARD)
  set(CMAKE_CXX_STANDARD 17)
endif()

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(hardware_interface REQUIRED)
find_package(pluginlib REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(nav_msgs REQUIRED)
find_package(control_msgs REQUIRED)

include_directories(
  include
)

add_library(can_diff_drive_hardware SHARED
  src/can_diff_drive_hardware.cpp
)

target_link_libraries(can_diff_drive_hardware
  ${rclcpp_LIBRARIES}
)

ament_target_dependencies(can_diff_drive_hardware
  rclcpp
  hardware_interface
  pluginlib
)

pluginlib_export_plugin_description_file(hardware_interface hardware_plugins.xml)

install(TARGETS
  can_diff_drive_hardware
  LIBRARY DESTINATION lib
)

install(FILES
  urdf/ugv_beast.urdf.xacro
  DESTINATION share/${PROJECT_NAME}/urdf
)

install(FILES
  hardware_plugins.xml
  DESTINATION share/${PROJECT_NAME}
)

ament_package()

Crea el archivo hardware_plugins.xml en el directorio raíz del paquete:

<library path="libcan_diff_drive_hardware.so">
  <class
    type="ugv_beast_teleop::CanDiffDriveHardware"
    base_class_type="hardware_interface::SystemInterface">
    <description>CAN-based diff drive hardware interface for UGV Beast.</description>
  </class>
</library>

En package.xml, asegúrate de tener:

<package format="3">
  <name>ugv_beast_teleop</name>
  <version>0.0.1</version>
  <description>Teleoperación UGV Beast por joystick con ROS 2 y CAN.</description>
  <maintainer email="tu_email@example.com">Tu Nombre</maintainer>
  <license>MIT</license>

  <buildtool_depend>ament_cmake</buildtool_depend>

  <depend>rclcpp</depend>
  <depend>hardware_interface</depend>
  <depend>pluginlib</depend>
  <depend>geometry_msgs</depend>
  <depend>nav_msgs</depend>
  <depend>control_msgs</depend>

  <export>
    <build_type>ament_cmake</build_type>
    <hardware_interface plugin="${prefix}/hardware_plugins.xml"/>
  </export>
</package>

5. Configuración de joy y teleop_twist_joy

En el PC remoto (o en la propia Raspberry Pi si conectas el joystick allí):

  1. Conecta el joystick USB.
  2. Instala paquetes (si no lo hiciste):
sudo apt install -y ros-humble-joy ros-humble-teleop-twist-joy
  1. Crea un archivo de configuración YAML (por ejemplo en el PC) para mapear ejes/botones: ~/ros2_ws/src/ugv_beast_teleop/config/teleop_joy.yaml:
teleop_twist_joy_node:
  ros__parameters:
    axis_linear.x: 1
    scale_linear.x: 0.5
    axis_angular.z: 0
    scale_angular.z: 1.0
    enable_button: 4  # LB en mandos tipo Xbox
    enable_turbo_button: 5

Con esto:

  • Eje 1 (normalmente joystick izquierdo vertical) controla la velocidad lineal.
  • Eje 0 (joystick izquierdo horizontal) controla velocidad angular (ajústalo según tu joystick).
  • Botón 4 debe mantenerse pulsado para enviar cmd_vel.

Compilación y ejecución

1. Configurar interfaz CAN (MCP2515) en Raspberry Pi 4

Edita /boot/firmware/config.txt en Ubuntu 22.04 para la Pi:

sudo nano /boot/firmware/config.txt

Añade:

dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
dtoverlay=spi0-hw-cs
dtparam=spi=on

Guarda, sal y reinicia:

sudo reboot

Tras reiniciar, configura la interfaz CAN a 500 kbit/s (ajusta a la velocidad de tu bus):

sudo ip link set can0 up type can bitrate 500000
ip -details link show can0

Verifica que can0 está UP.

2. Compilar el workspace en la Raspberry Pi 4

cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash

3. Lanzar robot_state_publisher y diff_drive_controller

Crea un launch simple launch/ugv_beast_bringup.launch.py:

from launch import LaunchDescription
from launch_ros.actions import Node

from ament_index_python.packages import get_package_share_directory
import os

def generate_launch_description():
    pkg_share = get_package_share_directory('ugv_beast_teleop')
    urdf_file = os.path.join(pkg_share, 'urdf', 'ugv_beast.urdf.xacro')

    with open(urdf_file, 'r') as infp:
        robot_desc = infp.read()

    robot_state_publisher_node = Node(
        package='robot_state_publisher',
        executable='robot_state_publisher',
        name='robot_state_publisher',
        parameters=[{'robot_description': robot_desc}]
    )

    diff_drive_controller_node = Node(
        package='controller_manager',
        executable='ros2_control_node',
        parameters=[{'robot_description': robot_desc}],
        output='screen'
    )

    spawner_diff = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['diff_controllers'],
        output='screen'
    )

    return LaunchDescription([
        robot_state_publisher_node,
        diff_drive_controller_node,
        spawner_diff
    ])

Asegúrate de tener robot_state_publisher instalado:
sudo apt install -y ros-humble-robot-state-publisher

Lanza desde la Raspberry Pi:

cd ~/ros2_ws
source install/setup.bash
ros2 launch ugv_beast_teleop ugv_beast_bringup.launch.py

4. Lanzar joy y teleop_twist_joy

En el PC remoto (o en la Pi si usas el joystick allí), en una terminal:

source /opt/ros/humble/setup.bash
ros2 run joy joy_node

En otra terminal, usando tu archivo de configuración:

source /opt/ros/humble/setup.bash
ros2 run teleop_twist_joy teleop_node --ros-args \
  --params-file ~/ros2_ws/src/ugv_beast_teleop/config/teleop_joy.yaml \
  -r cmd_vel:=/diff_controllers/cmd_vel_unstamped

Ajustamos el remapeo para que teleop_twist_joy publique en el tema que espera diff_drive_controller (cmd_vel_unstamped si use_stamped_vel=false).


Validación paso a paso

1. Comprobación de ROS 2 en Raspberry Pi

En la Pi, con el bringup corriendo, abre otra terminal:

source ~/ros2_ws/install/setup.bash
ros2 topic list

Debes ver (entre otros):

  • /diff_controllers/cmd_vel_unstamped
  • /joint_states
  • /tf
  • /tf_static

Ver estados de las ruedas:

ros2 topic echo /joint_states

Aunque no haya odometría real, deberías ver cambios cuando envíes comandos.

2. Verificar joy y cmd_vel en el PC remoto

Con joy_node y teleop_twist_joy lanzados:

  1. Verifica que el joystick es reconocido:

bash
ros2 topic echo /joy

  • Mueve los joysticks y pulsa botones: deberían cambiar los arreglos axes y buttons.

  • Verifica que, con el botón de habilitación pulsado, se publica cmd_vel:

bash
ros2 topic echo /diff_controllers/cmd_vel_unstamped

  • Mueve el joystick lineal: verás linear.x variando.
  • Mueve el joystick angular: verás angular.z variando.

3. Validar comandos CAN en la Raspberry Pi

En la Raspberry Pi (otra terminal):

sudo apt install -y can-utils
candump can0

Mientras mueves el joystick:

  • Deberían aparecer tramas CAN con ID 0x100 (según nuestro ejemplo).
  • La frecuencia de aparición se corresponde con la publish_rate del controlador y la tasa de cmd_vel (~20–30 Hz).

Métrica básica:

  • Si mantienes el joystick en posición fija, deberías ver un flujo estable de tramas CAN sin errores en consola.

4. Validar movimiento físico del UGV Beast

Con el UGV en el suelo y espacio seguro alrededor:

  1. Pulsa el botón de habilitación en el joystick.
  2. Empuja el joystick hacia adelante:
  3. El UGV debe avanzar en línea recta.
  4. Recuerda nuestro ejemplo: scale_linear.x: 0.5 → velocidad máxima 0,5 m/s (teórica).
  5. Gira el joystick hacia la derecha/izquierda:
  6. El UGV debe rotar en su lugar o trazar una curva.

Métricas aproximadas:

  • Avance de 1 m:
  • Mantén el joystick a la mitad de recorrido hacia adelante durante 4 s.
  • Si el radio de rueda y separación están bien calibrados, la distancia recorrida debería ser ≈1 m (±10 %).

Troubleshooting (errores típicos y soluciones)

  1. No aparece can0 tras reiniciar

  2. Revisa /boot/firmware/config.txt:

    • Asegúrate de haber escrito:
    • dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
    • dtparam=spi=on
  3. Comprueba módulos cargados:

    bash
    lsmod | grep mcp251x

  4. Verifica si el dispositivo SPI está habilitado:

    bash
    ls /dev/spidev0.*

  5. Si no aparece, revisa que el HAT está correctamente apilado sobre la Pi y sin pines doblados.

  6. ip link set can0 up da error

  7. Mensaje típico: Cannot find device "can0".

  8. Solución:
    • Usa dmesg | grep -i mcp2515 para ver mensajes del kernel.
    • Verifica la velocidad: asegúrate de que el bus CAN está correctamente cableado y con terminación si hay otro nodo activo.
  9. Si hay conflicto de direcciones de interrupción, revisa el pin INT (GPIO25) y que no esté siendo usado por otro periférico.

  10. No se reciben tramas en candump can0

  11. Verifica que:

    • Estás moviendo el joystick con el botón de habilitación pulsado.
    • teleop_twist_joy está remapeando a /diff_controllers/cmd_vel_unstamped.
    • diff_drive_controller está activo (ros2 control list_controllers).
  12. Prueba a enviar una trama de prueba manual:

    bash
    cansend can0 100#01000200

  13. Si tampoco se ve, comprueba la conexión física con la controladora del UGV o con otro nodo CAN conocido.

  14. El UGV se mueve al revés (adelante/atrás invertido)

  15. Síntoma:

    • Mueves joystick hacia adelante y el UGV va hacia atrás.
  16. Solución:

    • Invierte el signo de la rueda correspondiente en write() de can_diff_drive_hardware.cpp:

    cpp
    int16_t left = static_cast<int16_t>(-hw_commands_[0] * 100.0);
    int16_t right = static_cast<int16_t>(-hw_commands_[1] * 100.0);

    • O intercambia las ruedas izquierda/derecha según necesidad (modificando el orden de comandos).
  17. El UGV gira más de un lado que de otro (errores de calibración)

  18. Causa probable:

    • Valores incorrectos de wheel_radius y wheel_separation.
  19. Pasos de calibración básica:

    1. Mide el diámetro real de las ruedas y calcula radius = diámetro / 2.
    2. Mide con precisión la distancia entre centros de ruedas (wheel_separation).
    3. Actualiza estos parámetros tanto en:
      • <hardware> del URDF.
      • <controller> del diff_drive_controller.
    4. Reconstruye el workspace y prueba nuevamente.
  20. No hay cmd_vel aunque joy publica valores

  21. Verifica:

    • ros2 topic echo /joy → debe cambiar.
    • Configuración YAML de teleop_twist_joy:
    • enable_button debe coincidir con un botón real (comprueba en el echo).
    • Ejes (axis_linear.x, axis_angular.z) deben corresponder a los ejes correctos.
  22. Comprueba remapeo:

    bash
    ros2 topic list | grep cmd_vel

  23. Si teleop_twist_joy publica en /cmd_vel pero diff_drive_controller escucha en /diff_controllers/cmd_vel_unstamped, ajusta el remapeo (-r cmd_vel:=/diff_controllers/cmd_vel_unstamped).

  24. Error de plugin en ros2_control_node

  25. Mensaje típico:

    • Could not load library libcan_diff_drive_hardware.so
  26. Soluciones:

    • Asegúrate de que hardware_plugins.xml y la definición pluginlib_export_plugin_description_file están bien configurados.
    • Reconstruye:

    bash
    cd ~/ros2_ws
    colcon build --symlink-install --packages-select ugv_beast_teleop
    source install/setup.bash

    • Verifica que el nombre de la librería en hardware_plugins.xml coincide con la generada (libcan_diff_drive_hardware.so).
  27. Latencia alta entre joystick y movimiento

  28. Posibles causas:

    • Red WiFi saturada o con mala señal.
    • publish_rate del controlador demasiado baja.
  29. Acciones:
    • Usa Ethernet en vez de WiFi entre PC y Raspberry Pi, si es posible.
    • Aumenta publish_rate en los parámetros del diff_drive_controller a 50–100 Hz.
    • Verifica uso de CPU en la Raspberry (top, htop) y cierra procesos innecesarios.

Mejoras y variantes

  1. Integrar IMU y robot_localization

  2. Añade una IMU al UGV Beast (por I2C, SPI o CAN).

  3. Configura ekf_node de robot_localization para fusionar:
    • wheel_odom (de diff_drive_controller).
    • imu/data.
  4. Ventaja: odometría más estable y robusta a resbalamientos.

  5. Añadir LiDAR y SLAM

  6. Instalar un LiDAR (p. ej. RPLIDAR A1) conectado por USB a la Raspberry Pi.

  7. Usar rplidar_ros + slam_toolbox para crear mapas en línea.
  8. Teleoperar el UGV mientras SLAM genera el mapa, y luego guardarlo con map_server.

  9. Navegación semiautónoma (Nav2)

  10. Con mapa y odometría configurados, emplear nav2 para:

    • Enviar objetivos de navegación (2D Nav Goal en RViz2).
    • Usar teleop sólo para maniobras especiales o emergencias.
  11. Seguridad adicional

  12. Implementar un botón de parada de emergencia (E-STOP) que:

    • Corte alimentación de motores.
    • O envíe tramas CAN de parada inmediata.
  13. Añadir supervisión de tiempo:

    • Si no se reciben cmd_vel durante un tiempo, detener el UGV automáticamente.
  14. Teleoperación desde tablets o gamepads inalámbricos

  15. Usar rosbridge_suite para controlar el UGV con una interfaz web.

  16. Integrar mando inalámbrico (Bluetooth) con el PC remoto.

Checklist de verificación

Puedes usar esta lista como autoevaluación (marca mentalmente o en papel):

  • [ ] Ubuntu Server 22.04 64-bit instalado y funcionando en Raspberry Pi 4 Model B 4GB.
  • [ ] ROS 2 Humble instalado por apt en la Raspberry Pi (ros-humble-desktop y paquetes adicionales).
  • [ ] HAT Waveshare 2-CH CAN HAT (MCP2515) correctamente montado sobre la Raspberry Pi.
  • [ ] Configuración SPI y MCP2515 en /boot/firmware/config.txt correcta (reinicio hecho).
  • [ ] Interfaz can0 visible y subida con ip link set can0 up type can bitrate 500000.
  • [ ] Workspace ~/ros2_ws creado con el paquete ugv_beast_teleop.
  • [ ] Modelo URDF mínimo del UGV Beast creado con base_link y ruedas izquierda/derecha.
  • [ ] Plugin CanDiffDriveHardware implementado, compilado y exportado en hardware_plugins.xml.
  • [ ] ros2_control_node y diff_drive_controller lanzados sin errores desde la Raspberry Pi.
  • [ ] Joystick USB reconocido en el PC remoto (ros2 topic echo /joy funciona).
  • [ ] teleop_twist_joy configurado y publicando en /diff_controllers/cmd_vel_unstamped.
  • [ ] Tramas CAN observadas con candump can0 cuando se mueve el joystick.
  • [ ] El UGV Beast se mueve en la dirección esperada al accionar el joystick (adelante/atrás y giros).
  • [ ] Latencia de control aceptable (< 0,3 s percibida).
  • [ ] Sin errores críticos persistentes en los logs (ros2 y dmesg).

Si todos los elementos de la checklist se cumplen, has completado con éxito el caso práctico ros2-joy-teleop-ugv para el UGV Beast (ROS 2) – RPi con Raspberry Pi 4 Model B 4GB + Waveshare 2-CH CAN HAT (MCP2515).

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué plataforma se utilizará para construir el UGV Beast?




Pregunta 2: ¿Cuál es el propósito del HAT CAN MCP2515 en el proyecto?




Pregunta 3: ¿Qué herramienta se usará para teleoperar el UGV Beast?




Pregunta 4: ¿Cuál es la distancia mínima de frenado que se debe verificar?




Pregunta 5: ¿Qué velocidad lineal máxima debe responder el hardware de locomoción?




Pregunta 6: ¿Qué frecuencia de comandos cmd_vel se espera lograr?




Pregunta 7: ¿Qué tipo de maniobras se entrenarán con el UGV Beast?




Pregunta 8: ¿Cuál es el objetivo principal de generar trayectorias de referencia?




Pregunta 9: ¿Qué porcentaje de uso de CPU se espera mantener en la Raspberry Pi?




Pregunta 10: ¿Cuál es la latencia máxima permitida entre el joystick y las ruedas?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: UGV Beast ROS 2 con RPi seguidor de línea

Caso práctico: UGV Beast ROS 2 con RPi seguidor de línea — hero

Objetivo y caso de uso

Qué construirás: Un UGV tipo Beast con Raspberry Pi 4 y cámara Arducam OV5647, ejecutando ROS 2 Humble y un nodo propio ros2-line-follower-camera para seguir una línea negra en el suelo.

Para qué sirve

  • Automatizar un robot de reparto interno que sigue una ruta marcada en el suelo dentro de un almacén.
  • Guiar un vehículo educativo en circuitos de competición de seguidores de línea, con curvas suaves y cambios moderados de iluminación.
  • Implementar rutas predefinidas en laboratorios o fábricas para mover piezas ligeras entre estaciones sin usar sensores adicionales.
  • Prototipar AGVs de logística interna que se orientan únicamente con visión monocular (cámara frontal) y una línea en el piso.
  • Practicar visión artificial básica aplicada a robótica móvil en ROS 2, incluyendo publicación de imágenes y control de velocidad.

Resultado esperado

  • Mantener el centro del robot dentro de un corredor de ±5 cm respecto al centro de la línea durante al menos el 90 % del recorrido en un circuito simple.
  • Publicar imágenes de la cámara en /camera/image_raw a ≥ 15 FPS con carga de CPU < 70 % en la Raspberry Pi 4.
  • Publicar comandos de velocidad en /cmd_vel desde ros2-line-follower-camera a ≥ 10 Hz, con latencia desde captura de imagen hasta comando < 100 ms.
  • Completar un circuito cerrado de prueba (p. ej., 10–20 m con 3–4 curvas amplias) sin salirse de la línea más de 2 veces por vuelta.

Público objetivo: Estudiantes de robótica, makers y desarrolladores que comienzan con ROS 2 en plataformas móviles; Nivel: Inicial–intermedio en ROS 2 y Linux, básico en visión por computador.

Arquitectura/flujo: La cámara Arducam OV5647 se conecta a la Raspberry Pi 4, que ejecuta un nodo de cámara (publica en /camera/image_raw) y el nodo ros2-line-follower-camera; este nodo procesa cada frame, detecta la posición de la línea, calcula el error lateral y el ángulo de la línea, los traduce en comandos de velocidad lineal y angular que publica en /cmd_vel, y el controlador del UGV aplica dichos comandos a los motores en tiempo casi real.

Prerrequisitos (SO, versiones y toolchain concreta)

Sistema operativo y hardware

  • Plataforma principal:
  • Raspberry Pi 4 Model B 4GB (aarch64)
  • Sistema operativo:
  • Ubuntu Server 22.04 LTS 64‑bit para Raspberry Pi (aarch64), sin entorno gráfico.
  • Conectividad:
  • Acceso SSH a la Raspberry Pi desde un PC en la misma red (para editar, compilar y probar).

Toolchain y versiones

En la Raspberry Pi (aarch64):

  • ROS 2:
  • Distribución: ROS 2 Humble Hawksbill
  • Paquetes instalados vía apt:
  • ros-humble-desktop
  • ros-humble-ros2-control
  • ros-humble-diff-drive-controller
  • ros-humble-robot-localization
  • ros-humble-slam-toolbox
  • ros-humble-nav2-bringup
  • ros-humble-nav2-costmap-2d
  • ros-humble-nav2-controller
  • ros-humble-nav2-planner
  • ros-humble-nav2-behavior-tree
  • ros-humble-nav2-lifecycle-manager
  • ros-humble-rviz2
  • Lenguaje principal para el nodo seguidor de línea:
  • Python 3.10 (incluido en Ubuntu 22.04)
  • Compilación:
  • colcon versión instalada desde repositorios de Ubuntu (python3-colcon-common-extensions)
  • Gestión de paquetes:
  • apt (Ubuntu)
  • Control de versiones (opcional pero recomendado):
  • git ≥ 2.34

Materiales

Lista de materiales principales

  • 1 × Raspberry Pi 4 Model B 4GB + Arducam 5MP OV5647 Camera Module (modelo exacto solicitado).
  • 1 × Tarjeta microSD (32 GB o más, clase 10), con Ubuntu Server 22.04 64‑bit instalado.
  • 1 × Chasis de robot tipo UGV Beast con:
  • 2 motores DC con encoder (tracción diferencial).
  • 2 ruedas motrices + rueda loca.
  • Controlador de motores (por ejemplo, un driver tipo L298N o un hat específico para la plataforma Beast).
  • 1 × Fuente de alimentación/batería adecuada para el UGV (por ejemplo, LiPo 2S–3S con BEC o banco de baterías con regulación).
  • 1 × Cable USB para depuración (si el chasis incluye microcontrolador adicional).
  • 1 × Conexión a red (Ethernet o Wi-Fi configurado en la Raspberry Pi).
  • Cables Dupont macho-macho / macho-hembra para la conexión GPIO (si el driver de motores se controla desde la Pi).

Material opcional (pero útil)

  • Regla o metro para medir desplazamientos reales y calibrar el robot.
  • Cinta aislante negra para marcar la línea en el suelo.
  • Cartulina blanca o espuma EVA blanca como fondo de alto contraste.

Preparación y conexión

1. Preparación del sistema operativo y ROS 2 Humble

  1. Instala Ubuntu Server 22.04 64‑bit (aarch64) en la microSD (por ejemplo con Raspberry Pi Imager).
  2. Arranca la Raspberry Pi 4 con la microSD y conéctala a red.
  3. Actualiza el sistema:

bash
sudo apt update
sudo apt upgrade -y

  1. Instala ROS 2 Humble y paquetes necesarios:

bash
sudo apt install -y \
ros-humble-desktop \
ros-humble-ros2-control \
ros-humble-diff-drive-controller \
ros-humble-robot-localization \
ros-humble-slam-toolbox \
ros-humble-nav2-bringup \
ros-humble-nav2-costmap-2d \
ros-humble-nav2-controller \
ros-humble-nav2-planner \
ros-humble-nav2-behavior-tree \
ros-humble-nav2-lifecycle-manager \
ros-humble-rviz2 \
python3-colcon-common-extensions \
python3-argcomplete \
git

  1. Añade el setup.bash de ROS 2 a tu .bashrc:

bash
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
source ~/.bashrc

2. Habilitar la cámara Arducam 5MP OV5647 en Raspberry Pi 4

En Ubuntu Server 22.04 para Raspberry Pi, la cámara se habilita mediante:

  1. Editar /boot/firmware/config.txt:

bash
sudo nano /boot/firmware/config.txt

  1. Asegúrate de tener estas líneas (y sin #):

text
start_x=1
gpu_mem=128
dtoverlay=imx219 # En algunas imágenes se usa este overlay genérico para cámaras; revisar documentación Arducam

Nota: Algunas versiones de firmware usan dtoverlay=ov5647. Si tu módulo Arducam 5MP OV5647 tiene documentación que especifica este overlay, usa:

text
dtoverlay=ov5647

  1. Guarda y reinicia:

bash
sudo reboot

  1. Verifica que el dispositivo de cámara está presente (según kernel/driver, puede ser /dev/video0):

bash
ls /dev/video*

Debe aparecer algo como /dev/video0.

3. Conexión física de la cámara al Raspberry Pi 4

La Arducam 5MP OV5647 Camera Module se conecta mediante el conector CSI:

  • En la Raspberry Pi 4 hay un conector FPC (flex cable) cerca del conector HDMI.
  • Pasos (sin electricidad):
  • Apaga la Raspberry Pi y desconecta la alimentación.
  • Levanta suavemente la pestaña negra del conector CSI.
  • Inserta el cable plano de la Arducam con los contactos metálicos apuntando hacia los contactos del conector de la Pi.
  • Vuelve a bajar la pestaña hasta fijar el cable.
  • Conecta la alimentación y arranca.

4. Conexión del controlador de motores al Raspberry Pi 4

No dibujaremos esquema, pero describimos la conexión lógica típica (ejemplo con driver tipo L298N y motores DC):

Elemento Conexión en Raspberry Pi 4 Descripción breve
IN1 driver motor GPIO 17 (pin físico 11) Dirección rueda izquierda (bit 1)
IN2 driver motor GPIO 27 (pin físico 13) Dirección rueda izquierda (bit 2)
IN3 driver motor GPIO 22 (pin físico 15) Dirección rueda derecha (bit 1)
IN4 driver motor GPIO 23 (pin físico 16) Dirección rueda derecha (bit 2)
ENA (PWM izquierda) GPIO 18 (pin físico 12, PWM) Control velocidad izquierda
ENB (PWM derecha) GPIO 13 (pin físico 33, PWM) Control velocidad derecha
GND driver GND Raspberry Pi (ej: pin 6) Referencia común
+12 V (o según motores) Batería / fuente externa Alimentación motores (no desde la Pi)
5 V lógica driver (si hay) 5 V Pi (pin 2 o 4, según driver) Solo lógica, revisa que no back-feed 5 V

Importante: Asegúrate de que la masa (GND) de la Raspberry Pi y la del driver de motores está unida. Si tu chasis UGV Beast trae su propia placa controladora, adapta estos pines según la documentación del fabricante, pero mantén la lógica de “2 GPIO de dirección + 1 PWM por motor”.


Código completo del seguidor de línea con cámara

Crearemos un paquete ROS 2 en Python dentro de un workspace ~/ros2_ws. Este paquete:

  • Se llamará ros2_line_follower_camera.
  • Tendrá un nodo line_follower_node.py que:
  • Suscribe al tópico de la cámara /camera/image_raw.
  • Usa OpenCV para procesar la imagen, detectar la línea negra en el suelo.
  • Calcula un error horizontal (distancia del centro de la línea al centro de la imagen).
  • Genera comandos de velocidad (lineal y angular) en /cmd_vel para un robot diferencial controlado por diff_drive_controller.

1. Crear el workspace y el paquete

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src

ros2 pkg create --build-type ament_python ros2_line_follower_camera

Instala dependencias necesarias para procesamiento de imágenes:

sudo apt install -y python3-opencv python3-numpy

Edita el archivo package.xml del paquete (~/ros2_ws/src/ros2_line_follower_camera/package.xml) y asegúrate de incluir dependencias:

<exec_depend>rclpy</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>cv_bridge</exec_depend>

Instala ros-humble-cv-bridge:

sudo apt install -y ros-humble-cv-bridge

2. Nodo Python line_follower_node.py

Crea el archivo ~/ros2_ws/src/ros2_line_follower_camera/ros2_line_follower_camera/line_follower_node.py:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node

from sensor_msgs.msg import Image
from geometry_msgs.msg import Twist

from cv_bridge import CvBridge
import cv2
import numpy as np


class LineFollowerNode(Node):
    def __init__(self):
        super().__init__('line_follower_node')

        # Parámetros configurables
        self.declare_parameter('camera_topic', '/camera/image_raw')
        self.declare_parameter('linear_speed', 0.15)   # m/s
        self.declare_parameter('kp_angular', 0.0025)   # ganancia proporcional
        self.declare_parameter('view_height_ratio', 0.3)  # zona inferior de la imagen a usar

        camera_topic = self.get_parameter('camera_topic').get_parameter_value().string_value
        self.linear_speed = self.get_parameter('linear_speed').get_parameter_value().double_value
        self.kp_angular = self.get_parameter('kp_angular').get_parameter_value().double_value
        self.view_height_ratio = self.get_parameter('view_height_ratio').get_parameter_value().double_value

        # Puente ROS 2 <-> OpenCV
        self.bridge = CvBridge()

        # Suscriptor a la cámara
        self.image_sub = self.create_subscription(
            Image,
            camera_topic,
            self.image_callback,
            10
        )

        # Publicador de velocidad
        self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)

        self.get_logger().info(f'Line follower node iniciado. Suscrito a {camera_topic}')

    def image_callback(self, msg: Image):
        # Convertir a imagen OpenCV
        try:
            frame = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
        except Exception as e:
            self.get_logger().error(f'Error al convertir imagen: {e}')
            return

        height, width, _ = frame.shape

        # Usar solo la franja inferior de la imagen para buscar la línea
        roi_height = int(height * self.view_height_ratio)
        y_start = height - roi_height
        roi = frame[y_start:height, 0:width]

        # Convertir a escala de grises
        gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

        # Suavizar para reducir ruido
        blur = cv2.GaussianBlur(gray, (5, 5), 0)

        # Umbral binario: asumimos línea negra sobre fondo claro
        _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

        # Encontrar contornos
        contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

        if contours:
            # Tomar el contorno más grande como la línea principal
            largest_contour = max(contours, key=cv2.contourArea)
            M = cv2.moments(largest_contour)

            if M['m00'] > 0:
                cx = int(M['m10'] / M['m00'])  # centroide x
                cy = int(M['m01'] / M['m00'])  # centroide y (en ROI)

                # Error de posición (positivo si la línea está a la derecha)
                error_x = cx - width // 2

                # Control proporcional para la velocidad angular
                angular_z = -self.kp_angular * float(error_x)

                # Crear mensaje Twist
                cmd = Twist()
                cmd.linear.x = self.linear_speed
                cmd.angular.z = angular_z

                self.cmd_vel_pub.publish(cmd)

                self.get_logger().debug(
                    f'Linea detectada: cx={cx}, error_x={error_x}, ang_z={angular_z:.3f}'
                )
            else:
                # No se pudo calcular el centroide: para el robot
                self.stop_robot('Moment zero area (m00=0)')

        else:
            # Sin contornos: línea no encontrada, parar robot
            self.stop_robot('No se encontraron contornos para la línea')

    def stop_robot(self, reason: str):
        cmd = Twist()
        cmd.linear.x = 0.0
        cmd.angular.z = 0.0
        self.cmd_vel_pub.publish(cmd)
        self.get_logger().warn(f'Robot detenido: {reason}')


def main(args=None):
    rclpy.init(args=args)
    node = LineFollowerNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.stop_robot('Nodo finalizado')
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

Dale permisos de ejecución:

chmod +x ~/ros2_ws/src/ros2_line_follower_camera/ros2_line_follower_camera/line_follower_node.py

3. Ajustar setup.py del paquete

Edita ~/ros2_ws/src/ros2_line_follower_camera/setup.py:

from setuptools import setup

package_name = 'ros2_line_follower_camera'

setup(
    name=package_name,
    version='0.0.1',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages',
         ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='tu_nombre',
    maintainer_email='tu_email@example.com',
    description='Nodo seguidor de línea usando cámara OV5647 en Raspberry Pi 4 con ROS 2 Humble',
    license='Apache License 2.0',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
            'line_follower_node = ros2_line_follower_camera.line_follower_node:main',
        ],
    },
)

Modelado del robot (URDF + ros2_control + diff_drive_controller)

Aunque el foco es el seguidor de línea, necesitamos que el UGV Beast se expose en ROS 2 como robot diferencial. Crearemos un URDF sencillo con diff_drive_controller.

1. Parámetros geométricos del UGV Beast

Debes medir tu robot:

  • Radio de rueda (wheel_radius): por ejemplo, 0.03 m (ruedas de 6 cm de diámetro).
  • Distancia entre centros de ruedas (wheel_separation o track width): por ejemplo, 0.18 m.

Añade estos valores a un archivo YAML de parámetros del controlador.

2. URDF simplificado

Crea un paquete de descripción ugv_beast_description:

cd ~/ros2_ws/src
ros2 pkg create --build-type ament_cmake ugv_beast_description

Dentro crea urdf/ugv_beast.urdf.xacro. Para no alargar excesivamente, indicamos los elementos clave que deben existir:

  • base_link
  • left_wheel_link y right_wheel_link
  • Juntas left_wheel_joint y right_wheel_joint de tipo continuous
  • Plugin ros2_control con hardware y diff_drive_system.

En este caso práctico básico asumiremos que el control bajo nivel (GPIO/PWM) está gestionado fuera de ros2_control (por ejemplo, por un nodo propio o por firmware de la placa del Beast). Lo importante es que el diff_drive_controller publique/consuma /cmd_vel y odom.


Compilación, instalación y ejecución

1. Compilar el workspace con colcon

cd ~/ros2_ws
colcon build

Una vez completado:

echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc
source ~/.bashrc

2. Nodo de la cámara

Dependiendo del driver utilizado, puedes exponer la cámara como un nodo ROS 2. Una opción ligera en Ubuntu es usar un nodo genérico tipo v4l2_camera (si lo instalas). Para mantener el caso práctico sencillo, puedes usar un nodo ejemplo como image_tools (incluido en ros-humble-desktop) adaptado, pero lo más realista es:

Instalar v4l2_camera:

sudo apt install -y ros-humble-v4l2-camera

Lanzar el nodo de cámara:

ros2 run v4l2_camera v4l2_camera_node --ros-args -p video_device:=/dev/video0 -p image_size:="[640,480]"

Este nodo publicará en /image_raw. Ajusta el parámetro de tu line_follower_node para usar ese tópico, o remapea.

Ejemplo ejecutando el seguidor de línea con remapeo:

ros2 run ros2_line_follower_camera line_follower_node \
  --ros-args -p camera_topic:=/image_raw

3. Lanzar el controlador diferencial (diff_drive_controller)

Asumiendo que tienes un ros2_control configurado y un archivo de parámetros YAML, lanzarías algo como:

ros2 launch ugv_beast_bringup ugv_beast_diff_drive.launch.py

En este caso práctico básico, si aún no tienes implementada la parte ros2_control real con hardware, puedes:

  • Probar el nodo line_follower_node sin movimiento, sólo examinando /cmd_vel.
  • Implementar posteriormente el puente /cmd_vel → GPIO/PWM según tu hardware.

Validación paso a paso

1. Verificar que la cámara funciona

  1. Asegúrate de que /dev/video0 existe:

bash
ls /dev/video0

  1. Lanza el nodo v4l2_camera:

bash
ros2 run v4l2_camera v4l2_camera_node --ros-args -p video_device:=/dev/video0

  1. Comprueba que el tópico de imagen está activo:

bash
ros2 topic list

Debes ver algo como:

  • /image_raw
  • /camera_info

  • Comprueba el tipo de mensaje:

bash
ros2 topic echo /image_raw --qos-reliability best_effort --qos-durability volatile

Verás muchos datos binarios/imprimibles. Para medir tasa de publicación:

bash
ros2 topic hz /image_raw

Criterio de éxito: ≥ 15 Hz.

2. Verificar el nodo ros2-line-follower-camera

  1. Abre otra terminal (con source ~/ros2_ws/install/setup.bash).
  2. Lanza el nodo:

bash
ros2 run ros2_line_follower_camera line_follower_node \
--ros-args -p camera_topic:=/image_raw

  1. Comprueba que se crea el tópico /cmd_vel:

bash
ros2 topic list | grep cmd_vel

  1. Mide la frecuencia de publicación de /cmd_vel:

bash
ros2 topic hz /cmd_vel

Criterio de éxito: ≥ 10 Hz mientras haya imágenes entrantes.

  1. Coloca delante de la cámara una superficie blanca con una línea negra (cinta aislante). Asegúrate de que la línea cruza la parte inferior del campo de visión de la cámara.

  2. Cuando la línea esté centrada, espera ver que:

  3. linear.x ≈ valor del parámetro linear_speed (0.15).

  4. angular.z ≈ 0.

  5. Desplaza la línea hacia la izquierda/derecha; deberías observar:

  6. Si la línea se mueve hacia la derecha en la imagen (error positivo), angular.z será negativo (gira hacia la derecha, según la convención de tu robot).

  7. Si la línea se mueve hacia la izquierda, angular.z será positivo.

Puedes comprobarlo con:

bash
ros2 topic echo /cmd_vel

3. Validar en el UGV Beast en movimiento

  1. Coloca el UGV Beast en el suelo sobre una pista con línea negra sobre blanco (recta de al menos 2–3 m).
  2. Lanza los nodos:
  3. Cámara:

    bash
    ros2 run v4l2_camera v4l2_camera_node --ros-args -p video_device:=/dev/video0 -p image_size:="[640,480]"

  4. Seguidor de línea:

    bash
    ros2 run ros2_line_follower_camera line_follower_node \
    --ros-args -p camera_topic:=/image_raw -p linear_speed:=0.10 -p kp_angular:=0.003

  5. Si el puente cmd_vel → motores está funcional (por diff_drive_controller o nodo propio), el robot empezará a moverse hacia adelante y ajustar su dirección.

  6. Criterios de validación medibles:

  7. El robot no se sale de la línea más de 5 cm lateralmente en la mayoría del recorrido.
  8. En curvas suaves, el robot desacelera el giro adecuadamente y no “zigzaguea” de manera extrema.
  9. El movimiento responde en menos de 0.3 s al mover la línea manualmente.

Troubleshooting (errores típicos y soluciones)

1. No aparece /dev/video0

Síntoma: ls /dev/video* no muestra /dev/video0.

Causas y soluciones:
– La cámara no está bien conectada:
– Apaga la Pi, revisa el cable plano FPC, verifica orientación y que la pestaña CSI está bien fijada.
– Configuración en config.txt incorrecta:
– Asegúrate de tener:

```text
start_x=1
gpu_mem=128
dtoverlay=ov5647   # o el recomendado por Arducam para OV5647
```
  • Reinicia después de modificarlo.
  • Firmware/overlay no soporta esa cámara:
  • Revisa la documentación de Arducam 5MP OV5647 para Ubuntu 22.04 en Raspberry Pi 4; puede requerir un overlay distinto o actualización de firmware.

2. line_follower_node falla al convertir la imagen

Síntoma: En la consola, mensajes: Error al convertir imagen.

Causas y soluciones:
– Tipo de codificación no soportado:
– Asegúrate de que el nodo de cámara publica en un formato compatible (bgr8 o convertible).
– Con ros2 topic echo /image_raw -n 1, revisa el campo encoding.
– Si es yuyv o similar, modifica:

```python
frame = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
```

por

```python
frame = self.bridge.imgmsg_to_cv2(msg)
```

y prueba.

3. Proceso de detección no encuentra contornos

Síntoma: El robot se mantiene parado con alertas Robot detenido: No se encontraron contornos para la línea.

Causas y soluciones:
– Contraste insuficiente entre línea y fondo:
– Usa fondo blanco brillante (cartulina) y cinta negra muy oscura.
– Aumenta iluminación.
– Umbral inadecuado:
– En lugar de umbral automático OTSU, prueba con un valor fijo:

```python
_, binary = cv2.threshold(blur, 100, 255, cv2.THRESH_BINARY_INV)
```
  • Ajusta 100 a tu entorno.
  • La línea está fuera de la región de interés:
  • Cambia view_height_ratio (por ejemplo, 0.5 para usar media imagen).

4. El robot oscila mucho (zigzag)

Síntoma: El UGV empeora la trayectoria, corrigiendo en exceso la dirección.

Causas y soluciones:
– Ganancia kp_angular demasiado alta:
– Reduce el valor: por ejemplo, de 0.003 a 0.0015.
– Velocidad lineal demasiado alta:
– Baja linear_speed a 0.08 o 0.05 m/s para principiantes.
– Retardo elevado de cámara:
– Reduce resolución (image_size:="[320,240]") para aumentar FPS.

5. Tópico /cmd_vel no llega al controlador de motores

Síntoma: Ves datos en /cmd_vel pero las ruedas no se mueven.

Causas y soluciones:
diff_drive_controller no está configurado:
– Asegúrate de haber creado y lanzado un controller_manager con diff_drive_controller.
– Puente hardware no implementado:
– Este caso práctico no entra al detalle de GPIO/PWM; necesitás un nodo que subscribe /cmd_vel y maneje los pines o que tu placa Beast haga de interfaz.
– Espacio de nombres (namespace) diferente:
– Verifica que tu controlador espera /cmd_vel y no otro tópico como /diff_drive_controller/cmd_vel_unstamped.

6. ros2 topic hz /image_raw muestra tasas muy bajas (≤ 5 Hz)

Síntoma: La cámara publica a muy baja frecuencia, el robot reacciona tarde.

Causas y soluciones:
– Resolución demasiado alta:
– Reduce image_size a [320,240] en v4l2_camera.
– Exceso de carga de CPU:
– Verifica con top que no haya otros procesos pesados.
– Desactiva nodos innecesarios durante pruebas.

7. Error al construir el workspace con colcon

Síntoma: colcon build falla en ros2_line_follower_camera con errores de dependencias.

Causas y soluciones:
– Falta alguna dependencia en package.xml:
– Asegúrate de incluir rclpy, sensor_msgs, geometry_msgs, cv_bridge.
– No instalaste ros-humble-cv-bridge o python3-opencv:
– Instálalos con:

```bash
sudo apt install -y ros-humble-cv-bridge python3-opencv
```
  • No ejecutaste source /opt/ros/humble/setup.bash antes de compilar:
  • Asegúrate de que lo tienes en el .bashrc.

Mejoras y variantes

Una vez tengas el caso básico funcionando, puedes plantear las siguientes mejoras:

  1. Control más avanzado (PID)
    En lugar de sólo control proporcional (kp_angular), añade términos integral y derivativo para suavizar y anticipar correcciones.

  2. Cálculo de la línea como recta
    En vez de usar solo el centroide del contorno, usa técnicas como:

  3. Ajuste de línea (regresión) a partir de puntos de la línea.
  4. Hough transform para detectar segmentos.

  5. Seguimiento de intersecciones
    Extiende el algoritmo para reconocer cruces en T o intersecciones y tomar decisiones:

  6. Girar a la izquierda o derecha según bandera.
  7. Parar en un cruce y esperar orden externa (ej. topic de alto nivel).

  8. Integrar con Nav2 y SLAM
    Aunque aquí sólo usamos la línea, tu UGV Beast puede:

  9. Mapear el entorno con slam_toolbox.
  10. Usar nav2 para navegación global cuando no haya línea.
  11. Combinar modos “seguimiento de línea” y “navegación libre”.

  12. Publicar imágenes procesadas
    Crea un tópico /line_follower/debug_image para ver por RViz o herramientas de visualización la binarización y contornos, ayudando a depurar.

  13. Parámetros dinámicos
    Usa parámetros ROS 2 dinámicos para ajustar kp_angular, linear_speed y view_height_ratio en tiempo real sin reiniciar el nodo.


Checklist de verificación (para el alumno)

Marca cada ítem al completarlo:

  • [ ] Ubuntu Server 22.04 64‑bit instalado y actualizado en Raspberry Pi 4 Model B 4GB.
  • [ ] ROS 2 Humble y paquetes (ros-humble-desktop, ros-humble-ros2-control, ros-humble-diff-drive-controller, ros-humble-robot-localization, ros-humble-slam-toolbox, ros-humble-nav2-*, ros-humble-rviz2) instalados con apt.
  • [ ] Cámara Arducam 5MP OV5647 Camera Module conectada al puerto CSI y habilitada en /boot/firmware/config.txt.
  • [ ] /dev/video0 visible tras el arranque de la Raspberry Pi.
  • [ ] Nodo v4l2_camera funcionando y publicando en /image_raw a ≥ 15 Hz (ros2 topic hz /image_raw).
  • [ ] Workspace ~/ros2_ws creado, con paquete ros2_line_follower_camera y dependencias instaladas (cv_bridge, python3-opencv).
  • [ ] colcon build se ejecuta sin errores y source ~/ros2_ws/install/setup.bash está en .bashrc.
  • [ ] Nodo line_follower_node se ejecuta correctamente y crea el tópico /cmd_vel.
  • [ ] Con la línea negra en el campo de visión, /cmd_vel publica valores razonables de linear.x y angular.z.
  • [ ] El puente /cmd_vel → controlador de motores del UGV Beast funciona y las ruedas reaccionan.
  • [ ] En una pista de al menos 5 m, el UGV Beast sigue la línea sin desviarse más de ±5 cm la mayor parte del tiempo.

Si todos los ítems están marcados, has completado con éxito el caso práctico ros2-line-follower-camera para el UGV Beast (ROS 2) – Raspberry Pi 4 Model B 4GB + Arducam 5MP OV5647 Camera Module a nivel básico.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué tipo de robot se está construyendo en el proyecto?




Pregunta 2: ¿Cuál es el propósito principal del robot mencionado?




Pregunta 3: ¿Qué cámara se utiliza en el proyecto?




Pregunta 4: ¿Qué software se está utilizando para controlar el robot?




Pregunta 5: ¿Cuál es la frecuencia mínima a la que debe publicar comandos de velocidad?




Pregunta 6: ¿Qué se espera lograr en un circuito simple?




Pregunta 7: ¿Cuál es el objetivo de utilizar visión monocular en el robot?




Pregunta 8: ¿Qué carga de CPU se debe mantener por debajo en la Raspberry Pi 4?




Pregunta 9: ¿Cuál es el público objetivo del proyecto?




Pregunta 10: ¿Qué tipo de circuito se menciona como ejemplo para la prueba?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Logger cadena de frío Arduino MKR GSM 1400

Caso práctico: Logger cadena de frío Arduino MKR GSM 1400 — hero

Objetivo y caso de uso

Qué construirás: Un registrador de datos de cadena de frío utilizando Arduino MKR GSM 1400, DS3231, MicroSD SPI y DS18B20 para monitoreo de temperatura.

Para qué sirve

  • Monitoreo de temperatura en transporte de productos farmacéuticos.
  • Registro de condiciones ambientales en el almacenamiento de alimentos perecederos.
  • Control de temperatura en laboratorios de investigación.
  • Alertas en tiempo real para condiciones fuera de rango.

Resultado esperado

  • Registro de temperatura cada 5 minutos con precisión de ±0.5°C.
  • Envío de datos a la nube con una frecuencia de 1 paquete cada 10 minutos.
  • Latencia de respuesta de alertas de temperatura superior a 2 segundos.
  • Capacidad de almacenar hasta 1,000 registros en la tarjeta MicroSD.

Público objetivo: Ingenieros y desarrolladores de IoT; Nivel: Avanzado

Arquitectura/flujo: Arduino MKR GSM 1400 <-> DS18B20 (sensor) <-> DS3231 (reloj) <-> MicroSD (almacenamiento) <-> MQTT (comunicación)

Nivel: Avanzado

Prerrequisitos

  • Sistemas operativos probados:
  • Windows 11 23H2 (64-bit) o Windows 10 22H2 (64-bit).
  • Ubuntu 22.04 LTS (x86_64).
  • macOS 13.6 Ventura o macOS 14 Sonoma (Apple Silicon o Intel).

  • Toolchain exacta (línea de comandos, sin IDE gráfico):

  • Python 3.11.6
  • PlatformIO Core 6.1.15
  • Plataforma de compilación SAMD para PlatformIO:
    • platform = atmelsam@8.1.0
    • framework = arduino (ArduinoCore-samd 1.8.13)
  • Librerías Arduino (versionadas, a instalar vía PlatformIO):

    • arduino-libraries/MKRGSM@1.5.1
    • adafruit/RTClib@2.1.4
    • paulstoffregen/OneWire@2.3.7
    • milesburton/DallasTemperature@3.11.0
    • arduino-libraries/SD@1.2.4
  • Requisitos de hardware/driver:

  • Arduino MKR GSM 1400 usa USB CDC nativo. No requiere drivers en macOS y Linux modernos.
  • Windows 10/11: el controlador CDC-ACM se instala automáticamente. Si no aparece el puerto, actualizar Windows Update o usar el driver del paquete Arduino SAMD (instalado al usar Arduino IDE, opcional).
  • Permisos en Linux: agregar el usuario al grupo dialout para poder abrir el puerto serie (ver sección de compilación/ejecución).

  • Conectividad:

  • SIM nano con datos activos y APN conocido de tu operador (PIN opcional). Se usará GSM/GPRS para publicar los registros.
  • Cobertura GSM suficiente en el lugar de pruebas (2G/3G dependiendo del módem/operador).

Materiales

  • Arduino MKR GSM 1400 (modelo exacto ABX00018).
  • Módulo RTC DS3231 (compatible 3.3 V, con batería CR2032 para respaldo).
  • MicroSD SPI (soporte de tarjeta microSD a 3.3 V) + nivelador/buffer CD74HC4050.
  • CD74HC4050 como buffer unidireccional para líneas de salida del MKR hacia la MicroSD.
  • Nota: el CD74HC4050 se alimenta a 3.3 V; MISO no pasa por el 4050 (es entrada a la MCU).
  • Tarjeta microSD clase 10 (formateada FAT32).
  • Sensor de temperatura DS18B20 (cápsula TO-92 o sonda impermeable) a 3.3 V.
  • Resistencia 4.7 kΩ (pull-up del bus 1-Wire del DS18B20).
  • Antena para el MKR GSM 1400 (obligatoria para radio).
  • SIM con datos (APN/usuario/contraseña, PIN si aplica).
  • Cables Dupont/M-F/M-M según módulos.
  • Fuente de alimentación:
  • USB 5 V estable para desarrollo.
  • Recomendado: batería LiPo 3.7 V conectada al conector JST del MKR para evitar caídas de tensión durante ráfagas GSM (opcional pero recomendable).
  • Consumibles:
  • Batería CR2032 para el DS3231.
  • Cinta térmica o abrazaderas para fijar la sonda DS18B20 a paquetes fríos.

Preparación y conexión

Consideraciones generales de señal y alimentación

  • El MKR GSM 1400 trabaja a 3.3 V. No aplicar 5 V a sus entradas.
  • El módulo microSD y el DS3231 deben alimentarse a 3.3 V. Muchos módulos de mercado incluyen regulador a 3.3 V; si tu módulo microSD está diseñado para 5 V, es preferible usar uno nativo 3.3 V. En este caso incluimos el CD74HC4050 como buffer/aislador unidireccional en SCK, MOSI y CS.
  • La línea MISO (desde la MicroSD al MKR) no debe atravesar el CD74HC4050 (el 4050 es unidireccional: de entrada a salida); se conecta directamente a la entrada MISO del MKR (tensión 3.3 V compatible).
  • El DS18B20 necesita un pull-up de 4.7 kΩ entre su línea de datos y 3.3 V (si tu cable es largo, podrías necesitar ajustar el valor o la topología para integridad de señal).

Tabla de conexiones

La SPI del MKR GSM 1400 está expuesta en el header SPI (MOSI/MISO/SCK). No uses pines digitales numerados para MOSI/MISO/SCK; usa los pines etiquetados del conector SPI. El pin CS sí puede ser cualquier GPIO (usaremos D4).

Función Componente Pin en MKR GSM 1400 A través del CD74HC4050 Pin del módulo Notas
Alimentación 3.3 V Todos 3V3 N/A VCC 3.3 V a DS3231, MicroSD y CD74HC4050.
Tierra Todos GND N/A GND Masa común para todos los módulos.
SPI MOSI MicroSD MOSI (cabecera SPI) Sí (buffer) DI (MOSI) Conectar MOSI del MKR a entrada del 4050; salida del 4050 al DI.
SPI MISO MicroSD MISO (cabecera SPI) No DO (MISO) Directo MicroSD→MKR (3.3 V).
SPI SCK MicroSD SCK (cabecera SPI) Sí (buffer) SCK MKR SCK→4050→SCK de MicroSD.
SPI CS (GPIO) MicroSD D4 Sí (buffer) CS Selección de chip; define D4 en el firmware.
I2C SDA DS3231 SDA No SDA I2C a 3.3 V.
I2C SCL DS3231 SCL No SCL I2C a 3.3 V.
1-Wire DAT DS18B20 D5 No DQ Añadir pull-up 4.7 kΩ entre D5 y 3.3 V.
Alimentación DS18B20 DS18B20 3V3 N/A VDD Modo alimentación normal (no parasitario).
Tierra DS18B20 DS18B20 GND N/A GND
VCC CD74HC4050 3V3 N/A VCC Alimentar el 4050 a 3.3 V para nivel lógico correcto.
Entradas 4050 Desde MKR (MOSI, SCK, D4) MOSI, SCK, D4 N/A 4050-IN Entradas del buffer desde el MKR.
Salidas 4050 Hacia MicroSD (DI, SCK, CS) N/A N/A 4050-OUT Salidas del buffer hacia el módulo MicroSD.
Antena GSM MKR GSM 1400 Conector u.FL N/A Antena Conecta la antena antes de encender el módem.
SIM MKR GSM 1400 Ranura SIM N/A SIM Inserta la nanoSIM con datos activos.

Preparación previa a energizar

  1. Formatea la microSD a FAT32 (tamaño de clúster por defecto).
  2. Inserta la CR2032 en el DS3231 (asegura polaridad).
  3. Inserta la nanoSIM y conecta la antena GSM al MKR.
  4. Alimenta todo por USB. Para pruebas con GSM se recomienda además conectar una batería LiPo al conector JST del MKR para evitar resets por picos de corriente (el módem puede requerir >1.5 A en ráfagas muy cortas).
  5. Ten a mano los datos del APN de tu operador (APN, usuario, contraseña) y el PIN de la SIM si estuviera activo.

Código completo (C++ Arduino, PlatformIO)

A continuación se muestra un firmware con:
– Registro periódico en microSD (CSV).
– Timestamps desde DS3231.
– Lectura de temperatura DS18B20.
– Umbrales configurables de cadena de frío (p. ej. 2 °C a 8 °C).
– Publicación periódica por GSM vía HTTP POST.
– Retardo no bloqueante con millis().

Archivo: platformio.ini (en la raíz del proyecto):

[env:mkrgsm1400]
platform = atmelsam@8.1.0
board = mkrgsm1400
framework = arduino
monitor_speed = 115200
lib_deps =
  arduino-libraries/MKRGSM@1.5.1
  adafruit/RTClib@2.1.4
  paulstoffregen/OneWire@2.3.7
  milesburton/DallasTemperature@3.11.0
  arduino-libraries/SD@1.2.4
build_flags =
  -DARDUINOJSON_USE_LONG_LONG=1

Archivo: src/main.cpp

#include <Arduino.h>
#include <MKRGSM.h>
#include <Wire.h>
#include <RTClib.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <SPI.h>
#include <SD.h>

// =========================
// Configuración del proyecto
// =========================

// SIM/APN
static const char SIM_PIN[] = "";              // PIN de la SIM o "" si no tiene
static const char APN[] = "YOUR_APN";          // Cambiar por el APN de tu operador
static const char APN_USER[] = "";             // Usuario APN si aplica
static const char APN_PASS[] = "";             // Contraseña APN si aplica

// Publicación HTTP
static const char HTTP_HOST[] = "httpbin.org"; // Servidor de pruebas
static const int  HTTP_PORT = 80;
static const char HTTP_PATH[] = "/post";       // Ruta POST

// Umbrales de cadena de frío
static const float TEMP_MIN_C = 2.0f;          // °C
static const float TEMP_MAX_C = 8.0f;          // °C
static const uint32_t BREACH_HOLDOFF_MS = 10000; // Persistencia (10 s) antes de disparar alarma

// Logging y temporización
static const uint32_t SAMPLE_INTERVAL_MS = 60000; // 60 s
static const uint32_t SEND_INTERVAL_MS   = 300000; // 5 min
static const char LOG_FILENAME[] = "/coldchain.csv";

// Pines
static const uint8_t PIN_SD_CS   = 4;          // CS de microSD (D4), vía CD74HC4050
static const uint8_t PIN_1WIRE   = 5;          // D5 para DS18B20 con pull-up 4.7k a 3.3V

// =========================
// Objetos globales
// =========================
RTC_DS3231 rtc;

OneWire oneWire(PIN_1WIRE);
DallasTemperature dallas(&oneWire);

GSM gsmAccess;
GPRS gprs;
GSMClient netClient; // No-SSL. Para HTTPS usar GSMSSLClient (requiere certificados).

File logFile;

DeviceAddress dsAddr;
bool dsFound = false;

bool sdReady  = false;
bool gsmReady = false;

// Timers no bloqueantes
uint32_t t_lastSample = 0;
uint32_t t_lastSend   = 0;

// Estado de alarma
bool inBreach = false;
uint32_t breachSince = 0;

// =========================
// Utilidades
// =========================

String iso8601(const DateTime& dt) {
  char buf[25];
  snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02dZ",
           dt.year(), dt.month(), dt.day(),
           dt.hour(), dt.minute(), dt.second());
  return String(buf);
}

void ensureRTCInit() {
  if (!rtc.begin()) {
    Serial.println(F("[RTC] Error: no se detecta DS3231 en I2C (0x68)."));
    return;
  }
  if (rtc.lostPower()) {
    Serial.println(F("[RTC] Se detectó pérdida de energía. Ajustando a tiempo de compilación."));
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
  Serial.println(F("[RTC] OK"));
}

void ensureDallasInit() {
  dallas.begin();
  if (dallas.getDeviceCount() < 1) {
    Serial.println(F("[DS18B20] No se detectan sensores. Verifica cableado y pull-up."));
    dsFound = false;
    return;
  }
  dsFound = dallas.getAddress(dsAddr, 0);
  if (!dsFound) {
    Serial.println(F("[DS18B20] No se obtuvo dirección del primer sensor."));
    return;
  }
  dallas.setResolution(dsAddr, 12); // Máxima resolución
  Serial.print(F("[DS18B20] Sensor 0: "));
  for (uint8_t i = 0; i < 8; i++) {
    Serial.print(dsAddr[i], HEX); if (i < 7) Serial.print(":");
  }
  Serial.println();
}

bool ensureSDInit() {
  if (sdReady) return true;
  if (!SD.begin(PIN_SD_CS)) {
    Serial.println(F("[SD] Falló SD.begin(). Verifica CS, 4050 y formato FAT32."));
    sdReady = false;
    return false;
  }
  // Crear encabezado si no existe
  if (!SD.exists(LOG_FILENAME)) {
    File f = SD.open(LOG_FILENAME, FILE_WRITE);
    if (f) {
      f.println(F("timestamp,temp_c,status,breach"));
      f.close();
      Serial.println(F("[SD] Archivo creado con encabezado."));
    } else {
      Serial.println(F("[SD] No se pudo crear el archivo de log."));
      sdReady = false;
      return false;
    }
  }
  sdReady = true;
  Serial.println(F("[SD] OK"));
  return true;
}

float readTemperatureC() {
  if (!dsFound) return NAN;
  dallas.requestTemperatures();
  float c = dallas.getTempC(dsAddr);
  return c;
}

String statusFromTemp(float c) {
  if (isnan(c)) return "NA";
  if (c < TEMP_MIN_C) return "LOW";
  if (c > TEMP_MAX_C) return "HIGH";
  return "OK";
}

void appendLog(const String& line) {
  if (!ensureSDInit()) return;
  File f = SD.open(LOG_FILENAME, FILE_WRITE);
  if (f) {
    f.println(line);
    f.close();
  } else {
    Serial.println(F("[SD] Error al abrir el log para escritura."));
  }
}

bool ensureGSM() {
  if (gsmReady) return true;
  Serial.print(F("[GSM] Inicializando módem... "));
  bool connected = false;
  for (int i = 0; i < 3 && !connected; i++) {
    if (gsmAccess.begin(SIM_PIN) == GSM_READY) {
      connected = true;
      break;
    }
    delay(2000);
  }
  if (!connected) {
    Serial.println(F("Fallo."));
    return false;
  }
  Serial.println(F("OK"));

  Serial.print(F("[GPRS] Adjuntando a APN... "));
  if (gprs.attachGPRS(APN, APN_USER, APN_PASS)) {
    Serial.println(F("OK"));
    gsmReady = true;
    return true;
  } else {
    Serial.println(F("Fallo. Verifica APN/credenciales/cobertura."));
    gsmReady = false;
    return false;
  }
}

bool httpPostJSON(const String& host, int port, const String& path, const String& json) {
  if (!ensureGSM()) return false;

  Serial.print(F("[HTTP] Conectando a ")); Serial.print(host); Serial.print(F(":")); Serial.println(port);
  if (!netClient.connect(host.c_str(), port)) {
    Serial.println(F("[HTTP] No se pudo conectar."));
    return false;
  }

  String req = String("POST ") + path + " HTTP/1.1\r\n" +
               "Host: " + host + "\r\n" +
               "Content-Type: application/json\r\n" +
               "Connection: close\r\n" +
               "Content-Length: " + String(json.length()) + "\r\n\r\n" +
               json;

  netClient.print(req);

  // Leer respuesta simple
  uint32_t start = millis();
  bool ok = false;
  while (millis() - start < 10000) {
    while (netClient.available()) {
      String line = netClient.readStringUntil('\n');
      line.trim();
      if (line.startsWith("HTTP/1.1 200")) ok = true;
      // parar si fin de headers
      if (line.length() == 0) {
        // cuerpo siguiente (omitimos)
        break;
      }
    }
    if (!netClient.connected()) break;
  }
  netClient.stop();
  Serial.println(ok ? F("[HTTP] Respuesta 200 OK.") : F("[HTTP] Respuesta no OK."));
  return ok;
}

void publishLastSample(float c, const String& status, const String& ts, bool breach) {
  // JSON mínimo
  String json = String("{\"device\":\"mkrgsm1400\",\"ts\":\"") + ts +
                "\",\"temp_c\":" + String(c, 3) +
                ",\"status\":\"" + status +
                "\",\"breach\":" + (breach ? "true" : "false") + "}";

  httpPostJSON(HTTP_HOST, HTTP_PORT, HTTP_PATH, json);
}

void printBanner() {
  Serial.println();
  Serial.println(F("== cellular-cold-chain-sd-logger =="));
  Serial.println(F("HW: Arduino MKR GSM 1400 + DS3231 + MicroSD SPI (CD74HC4050) + DS18B20"));
  Serial.println(F("Toolchain: PlatformIO Core 6.1.15, atmelsam@8.1.0, ArduinoCore-samd 1.8.13"));
  Serial.println(F("Log: /coldchain.csv | Intervalo muestreo: 60 s | Envios: 5 min"));
  Serial.println();
}

// =========================
// Setup y loop
// =========================

void setup() {
  Serial.begin(115200);
  while (!Serial && millis() < 4000) { /* esperar CDC */ }
  printBanner();

  ensureRTCInit();
  ensureDallasInit();
  ensureSDInit();

  // Confirmar fecha/hora inicial
  DateTime now = rtc.now();
  Serial.print(F("[RTC] Tiempo actual: ")); Serial.println(iso8601(now));

  // Intentar arrancar GSM/GPRS temprano (no bloquear)
  ensureGSM();

  t_lastSample = millis();
  t_lastSend   = millis();
}

void loop() {
  uint32_t nowMs = millis();

  // Muestreo periódico
  if (nowMs - t_lastSample >= SAMPLE_INTERVAL_MS || t_lastSample == 0) {
    t_lastSample = nowMs;

    // Leer tiempo y temperatura
    DateTime now = rtc.now();
    String ts = iso8601(now);

    float tempC = readTemperatureC();
    String stat = statusFromTemp(tempC);

    // Lógica de brecha
    bool breachEvent = false;
    if (stat == "LOW" || stat == "HIGH") {
      if (!inBreach) {
        // inicia periodo de confirmación
        if (breachSince == 0) breachSince = nowMs;
        if (nowMs - breachSince >= BREACH_HOLDOFF_MS) {
          inBreach = true;
          breachEvent = true;
          Serial.println(F("[ALERTA] Temperatura fuera de rango persistente."));
        }
      }
    } else {
      inBreach = false;
      breachSince = 0;
    }

    // Línea CSV
    String line = ts;
    line += ",";
    if (isnan(tempC)) line += "NaN"; else line += String(tempC, 3);
    line += ",";
    line += stat;
    line += ",";
    line += (inBreach ? "1" : "0");

    Serial.print(F("[LOG] ")); Serial.println(line);
    appendLog(line);

    // Opcional: escribir un archivo de estado rápido
    File f = SD.open("/last.txt", FILE_WRITE);
    if (f) {
      f.seek(0);
      f.print("ts=");    f.println(ts);
      f.print("temp=");  f.println(isnan(tempC) ? String("NaN") : String(tempC, 3));
      f.print("status=");f.println(stat);
      f.print("breach=");f.println(inBreach ? "1" : "0");
      f.close();
    }
  }

  // Envío periódico por GSM
  if (nowMs - t_lastSend >= SEND_INTERVAL_MS || t_lastSend == 0) {
    t_lastSend = nowMs;

    DateTime now = rtc.now();
    String ts = iso8601(now);
    float tempC = readTemperatureC();
    String stat = statusFromTemp(tempC);

    if (!isnan(tempC)) {
      publishLastSample(tempC, stat, ts, inBreach);
    } else {
      Serial.println(F("[GSM] Se omite envío: temperatura NaN."));
    }
  }

  // Trabajo de fondo mínimo
  delay(10);
}

Puntos clave del código:
– Usa DS3231 como fuente de tiempo; si detecta pérdida de energía del RTC, fija el reloj a tiempo de compilación.
– Inicializa la SD y crea el archivo CSV con encabezado si no existe.
– Lee el DS18B20 a 12 bits y registra cada minuto: timestamp ISO8601, temperatura, estado y si hay brecha activa.
– Implementa retardo de confirmación antes de declarar una “brecha” (para evitar falsos positivos por transitorios).
– Publica cada 5 minutos un JSON pequeño al endpoint HTTP (no TLS) usando MKRGSM. Para producción, usa TLS con GSMSSLClient y certificados.

Compilación, flash y ejecución (comandos exactos)

1) Instalar PlatformIO Core 6.1.15:

  • Windows/macOS/Linux (recomendado con pipx para aislar):
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install "platformio==6.1.15"
platformio --version

Salida esperada (similar):
– PlatformIO Core, version 6.1.15

2) Inicializar el proyecto para MKR GSM 1400:

mkdir -p cellular-cold-chain-sd-logger
cd cellular-cold-chain-sd-logger
pio project init --board mkrgsm1400 --project-option "platform=atmelsam@8.1.0" --project-option "framework=arduino"

3) Sustituir platformio.ini con el contenido proporcionado antes y crear src/main.cpp:

mkdir -p src
# Copia y pega el platformio.ini y el src/main.cpp según el tutorial

4) Instalar dependencias (se instalarán automáticamente en el primer build, pero puedes forzarlo):

pio pkg install
pio lib --global install "arduino-libraries/MKRGSM@1.5.1" "adafruit/RTClib@2.1.4" "paulstoffregen/OneWire@2.3.7" "milesburton/DallasTemperature@3.11.0" "arduino-libraries/SD@1.2.4"

5) Compilar:

pio run

6) Conectar el MKR GSM 1400 por USB. En Linux, si no tienes permisos de puerto serie:

sudo usermod -aG dialout $USER
# cierra sesión y vuelve a entrar, o reinicia la sesión

7) Localizar el puerto serie:

pio device list
  • Windows: COMx (p. ej., COM5)
  • macOS: /dev/cu.usbmodemXXXX
  • Linux: /dev/ttyACM0

8) Subir firmware:

# Si PlatformIO detecta el puerto automáticamente:
pio run -t upload

# O especificando puerto:
pio run -t upload --upload-port /dev/ttyACM0

9) Abrir monitor serie a 115200 baudios:

pio device monitor -b 115200

Notas:
– Si el módem necesita varios segundos para adjuntarse a la red, verás reintentos en la consola.
– Conecta primero la antena antes de energizar el MKR.

Validación paso a paso

1) Arranque y diagnóstico inicial
– Abre el monitor serie. Debes ver:
– Banner con descripciones de HW/toolchain.
– [RTC] OK y hora actual en ISO8601. Si aparece “pérdida de energía”, el RTC se ajustó a la hora de compilación; configura el reloj manual si quieres exactitud total (puedes cambiar el ajuste en código a un método propio).
– [SD] OK y/o creación del archivo con encabezado.
– Detección del DS18B20 (imprime la dirección de 8 bytes).

2) Verificación del DS18B20
– Observa líneas [LOG] cada 60 s con el valor temp_c. Coloca el sensor en:
– Ambiente (~20–25 °C) para ver “OK” si los umbrales están 2–8 °C, verás “HIGH”.
– Mezcla de hielo y agua (~0 °C) para ver “LOW”.
– Confirma que el estado (“status”) coincide con tus expectativas.

3) Verificación del DS3231
– Desconecta USB, mantén la CR2032 y espera 2–3 min.
– Reconecta. La hora debe continuar avanzando correctamente (no reiniciarse).
– Si la hora es errónea, reemplaza la CR2032 y vuelve a compilar o añade una rutina para ajustar el RTC (e.g., desde host vía un comando serie).

4) Verificación de escritura en SD
– Tras varios ciclos, extrae la microSD y abre coldchain.csv:
– Debe tener encabezado y filas similares a:
– 2025-01-12T18:25:00Z,5.312,OK,0
– 2025-01-12T18:26:00Z,8.954,HIGH,1
– Alternativamente, deja la SD y revisa /last.txt con un lector si tu módulo lo permite (o quita la SD y léela en PC).

5) Verificación GSM/GPRS y HTTP
– En la consola, busca:
– [GSM] Inicializando módem… OK
– [GPRS] Adjuntando a APN… OK
– [HTTP] Conectando a httpbin.org:80
– [HTTP] Respuesta 200 OK.
– Las publicaciones se realizan cada 5 min (puedes bajar SEND_INTERVAL_MS para testear).
– Si no obtienes 200 OK:
– Verifica APN, cobertura, saldo de datos, y que no haya firewall bloqueando puertos outbound 80.

6) Verificación de detección de brecha
– Coloca el sensor fuera del rango (p. ej., agua tibia ~30 °C).
– Tras el tiempo de persistencia (10 s por BREACH_HOLDOFF_MS), debe mostrarse:
– [ALERTA] Temperatura fuera de rango persistente.
– En el CSV, la columna breach pasa a 1 mientras dure la brecha.

7) Estabilidad de alimentación (opcional pero recomendado)
– Si notas reinicios durante el adjunto GPRS o POST:
– Conecta una LiPo al MKR y repite.
– Observa que el sistema no reinicia durante picos de transmisión.

Troubleshooting (errores típicos y soluciones)

1) SD.begin() falla o el CSV no se crea
– Síntomas: [SD] Falló SD.begin().
– Causas probables:
– CS incorrecto: verifica que el firmware use D4 y que el cable vaya por la salida del 4050 al pin CS del módulo SD.
– Orden del CD74HC4050: recuerda que es unidireccional. Usa 4050 para MOSI, SCK y CS desde MCU→SD. No inserte el 4050 en MISO.
– MicroSD no formateada FAT32 o defectuosa.
– Alimentación inestable a 3.3 V.
– Solución:
– Revisa cableado, pinout y reemplaza la microSD o formatea (FAT32).
– Confirma continuidad de señales con multímetro.

2) No se detecta el DS18B20
– Síntomas: “[DS18B20] No se detectan sensores.”
– Causas:
– Falta resistencia de 4.7 kΩ entre D5 y 3.3 V.
– Sensor alimentado en modo parasitario sin configurar el firmware.
– Cableado invertido (GND/VDD/DQ).
– Solución:
– Añade pull-up de 4.7 kΩ.
– Verifica el pinout del encapsulado o sonda.
– Usa alimentación normal (VDD a 3.3V).

3) El RTC DS3231 marca hora errónea o constante
– Causas:
– Falta CR2032 o agotada.
– Módulo DS3231 a 5 V incompatible con 3.3 V (I2C pull-ups a 5 V).
– Solución:
– Cambia CR2032.
– Asegura que las resistencias de pull-up I2C vayan a 3.3 V (en algunos módulos, hay jumpers o resistencias que debes modificar).

4) GSM no registra o GPRS no adjunta
– Síntomas:
– [GSM] fallo, [GPRS] fallo.
– Causas:
– SIM con PIN no configurado (SIM_PIN vacío).
– APN incorrecto o credenciales faltantes.
– Cobertura insuficiente o antena desconectada.
– Solución:
– Ajusta SIM_PIN, APN, usuario, contraseña.
– Mueve el dispositivo a zona con mejor cobertura.
– Conecta correctamente la antena.

5) Reinicios aleatorios al enviar por GSM
– Causas:
– Picos de corriente del módem (hasta >1 A en burst).
– Solución:
– Usa una LiPo en el conector JST del MKR.
– Añade un condensador de reserva (e.g., 470–1000 µF) en la línea de 3.3/5 V según diseño total.

6) No aparece el puerto serie
– Windows:
– Prueba otro cable USB (datos, no solo carga).
– Reinstala el driver CDC (vía Arduino IDE si es necesario).
– macOS/Linux:
– Comprueba permisos (Linux: grupo dialout).
– Verifica con pio device list.
– Todos:
– Pulsa el botón de reset doble-rapido para forzar el bootloader (el puerto puede cambiar temporalmente).

7) HTTP responde distinto a 200 OK
– Causas:
– Servidor indisponible, cortafuegos operatorio, NAT peculiar.
– Solución:
– Prueba otro host (temporalmente un servidor propio) o un APN diferente.
– Considera activar TLS con GSMSSLClient y un endpoint HTTPS confiable (necesitarás cargar certificados).

8) Archivo CSV corrupto tras apagones
– Causa:
– Extracción de la SD mientras el archivo está abierto.
– Solución:
– El código abre/cierra por escritura atómica por línea. Minimiza riesgo. Aun así, evita desconectar durante escritura y usa alimentación estable.

Mejoras/variantes

  • Seguridad y transporte:
  • Migrar de HTTP a HTTPS con GSMSSLClient. Cargar certificados raíz al módem (proceso específico para MKRGSM; usar herramienta de certificados del core SAMD o scripts AT). Cambiar GSMClient por GSMSSLClient y ajustar puerto 443.
  • Usar MQTT sobre TLS para publicar a un broker (p. ej., AWS IoT Core) con autenticación por certificados.
  • Robustez de almacenamiento:
  • Rotación de logs (daily rolling): coldchain-YYYYMMDD.csv y purga automática según espacio libre.
  • Cálculo de checksum por línea (CRC32) para detección de corrupción.
  • Multi-sensor:
  • Gestionar múltiples DS18B20 en el mismo bus 1-Wire (identificación por dirección y columnas separadas).
  • Gestión de energía:
  • Dormir entre muestras (modo standby del SAMD21, apagar módem entre envíos). Despertar por RTC (alarma DS3231) si se desea.
  • Alertas activas:
  • Enviar SMS cuando se detecte brecha persistente (usar MKRGSM SMS).
  • Añadir buzzer y LED de estado (verde OK, rojo brecha).
  • Geolocalización aproximada:
  • Consultar Cell-ID a la red y adjuntar MCC/MNC/LAC/CI al payload para trazabilidad.
  • Configuración remota:
  • Leer umbrales, intervalos y APN desde un archivo config.ini en SD, o por comandos simples vía serie.
  • Integridad temporal:
  • Sincronización NTP por GPRS al arranque si el RTC perdió energía (UDP NTP en puerto 123) y actualización del DS3231.

Checklist de verificación

  • [ ] He instalado PlatformIO Core 6.1.15 y puedo ejecutar platformio --version.
  • [ ] He inicializado el proyecto con mkrgsm1400 y la plataforma atmelsam@8.1.0.
  • [ ] He copiado platformio.ini y src/main.cpp exactamente como en el tutorial.
  • [ ] He cableado la MicroSD por SPI usando el conector SPI del MKR y el CD74HC4050 en MOSI/SCK/CS, dejando MISO directo.
  • [ ] He conectado el DS3231 a SDA/SCL (3.3 V) y le he colocado una CR2032 funcional.
  • [ ] He cableado el DS18B20 al pin D5 con resistencia de 4.7 kΩ a 3.3 V.
  • [ ] He insertado la antena y la SIM en el MKR GSM 1400.
  • [ ] He configurado APN/usuario/contraseña y PIN en el código.
  • [ ] El monitor serie muestra [RTC] OK, [SD] OK y el DS18B20 detectado.
  • [ ] Veo líneas [LOG] cada minuto con timestamp y temperatura.
  • [ ] Se crea/actualiza coldchain.csv en la microSD con datos válidos.
  • [ ] Cada 5 min obtengo [HTTP] Respuesta 200 OK (o he ajustado APN/host hasta conseguirlo).
  • [ ] He probado condiciones de baja y alta temperatura y el estado cambia a LOW/HIGH; se marca breach cuando persiste.
  • [ ] El sistema no se reinicia durante transmisiones GSM (uso LiPo si fue necesario).

Con este caso práctico has construido un registrador de temperatura para cadena de frío con sello temporal por RTC, almacenamiento en microSD mediante SPI y publicación celular periódica, todo sobre Arduino MKR GSM 1400 y respetando el camino de señal con CD74HC4050. El proyecto es una base sólida para despliegues de campo y escalabilidad hacia comunicaciones seguras, configuración remota y optimización energética.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión mínima de Windows requerida para el sistema operativo?




Pregunta 2: ¿Qué versión de Python se necesita para la toolchain?




Pregunta 3: ¿Cuál es la librería de Arduino que se debe instalar para el módulo RTC?




Pregunta 4: ¿Qué tipo de SIM se requiere para la conectividad?




Pregunta 5: ¿Cuál es el modelo exacto del Arduino requerido?




Pregunta 6: ¿Qué controlador se instala automáticamente en Windows 10/11?




Pregunta 7: ¿Cuál es la versión de la plataforma de compilación SAMD para PlatformIO?




Pregunta 8: ¿Qué tipo de batería se necesita para el módulo RTC DS3231?




Pregunta 9: ¿Qué permisos se deben configurar en Linux para abrir el puerto serie?




Pregunta 10: ¿Cuál es la versión de la librería DallasTemperature que se debe instalar?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Detección de palabras clave

Caso práctico: Detección de palabras clave — hero

Objetivo y caso de uso

Qué construirás: Un detector de palabras clave utilizando un Arduino Nano 33 IoT y un micrófono INMP441 I2S para procesar audio en tiempo real.

Para qué sirve

  • Detección de comandos de voz en sistemas de automatización del hogar.
  • Interacción con dispositivos IoT mediante palabras clave específicas.
  • Implementación en sistemas de asistencia personal que responden a órdenes vocales.

Resultado esperado

  • Latencia de detección de palabras clave inferior a 200 ms.
  • Precisión de detección superior al 85% en condiciones controladas.
  • Capacidad de procesar hasta 10 palabras clave simultáneamente.

Público objetivo: Desarrolladores de IoT; Nivel: Avanzado

Arquitectura/flujo: Captura de audio I2S -> Procesamiento de características -> Clasificación en tiempo real -> Respuesta a comandos.

Nivel: Avanzado

Prerrequisitos

Sistema operativo y herramientas (versiones probadas)

  • Sistema operativo
  • Ubuntu 22.04.4 LTS (x86_64). También válido en macOS 13 Ventura y Windows 11, ajustando rutas/comandos.
  • Toolchain para la placa
  • Arduino CLI v0.35.3
  • Core SAMD para Arduino: arduino:samd@1.8.14
  • Librerías Arduino:
    • Arduino_I2S@1.0.3
    • arduinoFFT@1.6.1
  • Toolchain científico (para entrenamiento de un clasificador simple)
  • Python 3.11.6
  • Paquetes:
    • numpy==1.26.4
    • scikit-learn==1.4.2
    • pyserial==3.5

Qué aprenderás y qué harás

  • Capturar audio por I2S a 16 kHz desde el micrófono INMP441 en un Arduino Nano 33 IoT.
  • Extraer características log-mel de ventana corta (MFBE) y aplicar DCT para obtener MFCC.
  • Entrenar un clasificador lineal (logistic regression) en PC con Python, exportando pesos a C++.
  • Embebido del clasificador en el firmware del Nano 33 IoT con inferencia en tiempo real.
  • Validar detecciones y afinar umbrales.

Materiales

  • 1x Arduino Nano 33 IoT (modelo exacto: ABX00032; MCU SAMD21G18A + NINA-W102; alimentación 3.3 V)
  • 1x Micrófono I2S INMP441 (breakout de 3.3 V; pines: VDD, GND, SCK/BCLK, WS/LRCLK, SD, L/R)
  • 1x Cable micro‑USB de datos para el Nano 33 IoT
  • Cables Dupont macho‑hembra para las conexiones
  • Opcional:
  • Protoboard
  • PC con puertos USB y Python 3.11

Nota sobre alimentación: el INMP441 opera a 3.3 V; no uses 5 V. El Nano 33 IoT trabaja íntegramente a 3.3 V, por lo que no necesitas conversores de nivel.

Preparación y conexión

Instalación del toolchain de Arduino CLI

  1. Instala Arduino CLI v0.35.3:
    «`bash
    # En Linux x86_64
    curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
    sudo mv bin/arduino-cli /usr/local/bin/

# Verifica versión
arduino-cli version
# Debería mostrar: arduino-cli Version: 0.35.3
2. Instala el core SAMD y librerías necesarias:bash
arduino-cli core update-index
arduino-cli core install arduino:samd@1.8.14

# Librerías:
arduino-cli lib install «Arduino_I2S@1.0.3» «arduinoFFT@1.6.1»
3. Conecta el Nano 33 IoT por USB y lista puertos:bash
arduino-cli board list
# Ejemplo de salida en Linux:
# Port Type Board Name FQBN
# /dev/ttyACM0 Serial Port (USB) Arduino Nano 33 IoT arduino:samd:nano_33_iot
«`

Notas de driver:
– Linux/macOS: dispositivo CDC ACM /dev/ttyACM o /dev/cu.usbmodem; no necesitas drivers extra.
– Windows 10/11: Windows Update instala el driver CDC automáticamente.

Conexión eléctrica (I2S)

La interfaz I2S en el Nano 33 IoT está implementada en el MCU SAMD21. Para este caso práctico se utiliza el bus I2S en modo receptor con el micrófono INMP441 (no requiere MCLK). Configuración típica a 16 kHz, 32 bits por muestra (el INMP441 emite 24 bits válidos en 32).

Conecta según la tabla:

Señal INMP441 Señal I2S Pin Arduino Nano 33 IoT Notas
VDD 3V3 3V3 (pin 3.3V) Alimentación 3.3 V
GND GND GND Tierra común
SCK (BCLK) I2S BCLK D2 Reloj de bit
WS (LRCLK) I2S LRCLK D3 Reloj de palabra/canal
SD I2S SD D4 Datos de micrófono (entrada al Nano)
L/R Selección de canal GND (sugerido) GND = canal izquierdo; VDD = derecho

Observaciones:
– El INMP441 no requiere MCLK (Master Clock), lo cual simplifica el cableado.
– Mantén los cables de I2S lo más cortos posible.
– Asegura una masa común robusta entre placa y micrófono.

Verificación de pines I2S por firmware:
– En caso de duda, puedes confirmar en tiempo de compilación usando las macros del core SAMD: PIN_I2S_SCK, PIN_I2S_FS, PIN_I2S_SD. En este caso práctico usaremos D2/D3/D4 como asignación estándar para Nano 33 IoT.

Código completo (firmware Arduino) y explicación

A continuación se presenta un firmware autocontenido que:
– Inicializa I2S a 16 kHz, 32 bits.
– Captura frames de 30 ms (480 muestras) con salto de 10 ms (160 muestras).
– Extrae 20 bandas log-mel y aplica DCT para 13 MFCC por frame.
– Mantiene una ventana de 25 frames (~250 ms) para formar un “feature map” de 25×13 = 325 features.
– Aplica un clasificador logístico (pesos que exportaremos desde Python) para detectar la palabra clave.
– Emite por Serial la probabilidad y disparos de detección.

El clasificador está separado en un archivo de cabecera “weights.h” que generaremos tras el entrenamiento. Para poder compilar desde ya, incluimos unos pesos de ejemplo con bias=0 y todos los pesos a 0 (no detectará nada) y se reemplazan más adelante.

Crea la estructura de proyecto:
– Directorio del sketch: i2s_kws_nano33iot/
– i2s_kws_nano33iot.ino
– weights.h

Contenido:

// File: i2s_kws_nano33iot.ino
#include <Arduino.h>
#include <I2S.h>           // Arduino_I2S
#include <arduinoFFT.h>    // arduinoFFT

#include "weights.h"       // Pesos del clasificador (auto-generado por Python)

// Parámetros de audio
static const uint32_t SAMPLE_RATE = 16000;     // 16 kHz
static const uint16_t BITS_PER_SAMPLE = 32;    // INMP441 -> 24 bits válidos en 32
static const uint16_t FRAME_LEN = 480;         // 30 ms a 16 kHz
static const uint16_t FRAME_HOP = 160;         // 10 ms
static const uint16_t FFT_SIZE = 512;          // Siguiente potencia de 2 >= FRAME_LEN
static const uint8_t  NUM_MEL = 20;            // Nº de bandas mel
static const uint8_t  NUM_MFCC = 13;           // Nº de coeficientes MFCC
static const uint8_t  NUM_FRAMES_STACK = 25;   // ~250 ms de contexto
static const float    PREEMPHASIS = 0.97f;

// Buffers
static int16_t  ringBuffer[FRAME_LEN];         // Ventana actual (16 bits)
static float    frameF32[FRAME_LEN];           // Copia en float
static float    fftReal[FFT_SIZE];
static float    fftImag[FFT_SIZE];
static float    melEnergies[NUM_MEL];
static float    mfcc[NUM_MFCC];
static float    featStack[NUM_FRAMES_STACK * NUM_MFCC]; // 25x13 = 325 features

// FFT
arduinoFFT FFT = arduinoFFT(fftReal, fftImag, FFT_SIZE, SAMPLE_RATE);

// Tabla de filtros mel (precomputada en setup)
static uint16_t melLowerBin[NUM_MEL];
static uint16_t melUpperBin[NUM_MEL];
static float    melWeights[NUM_MEL][FFT_SIZE/2 + 1];

// Prototipos
void computeMelFilterbank();
void frameToMFCC(const int16_t *pcm, float *out_mfcc);
void computeLogMel(const float *magSpec, float *out_mel);
void dct13(const float *in, float *out);
float logistic(const float x);
float dotProduct(const float *a, const float *b, size_t n);
void pushFrameFeatures(const float *mfcc);
void inferAndReport();

// Utilidad: lectura robusta de muestras desde I2S (descarta underflows)
bool readI2SSamples(int16_t *dst, size_t nSamples) {
  size_t count = 0;
  while (count < nSamples) {
    int32_t s = I2S.read();
    if (s == 0) {
      // read() devuelve 0 si no hay dato listo; espera breve
      delayMicroseconds(50);
      continue;
    }
    // INMP441: 24-bit en 32-bit firmado; escalar a 16-bit
    int16_t v = (int16_t)(s >> 14); // Ajuste empírico (de 32 a ~18 bits -> 16 bits)
    dst[count++] = v;
  }
  return true;
}

void setup() {
  Serial.begin(115200);
  while (!Serial) {;}

  Serial.println("Init I2S KWS (Nano 33 IoT + INMP441)");

  // Inicializa I2S receptor: modo Philips, 16kHz, 32 bits
  if (!I2S.begin(I2S_PHILIPS_MODE, SAMPLE_RATE, BITS_PER_SAMPLE)) {
    Serial.println("Error: no se pudo iniciar I2S");
    while (1) { delay(1000); }
  }

  // Nota: El I2S del Nano 33 IoT usa pines fijos. Este sketch asume:
  // D2=BCLK, D3=LRCLK, D4=SD. Revisa tu conexionado.

  // Inicializa filtros mel (triángulos sobre espectro de magnitud)
  computeMelFilterbank();

  // Inicializa buffer de características apiladas
  memset(featStack, 0, sizeof(featStack));

  Serial.println("I2S listo. Capturando...");
}

void loop() {
  // Desplazamiento de FRAME_HOP:
  // - Leer FRAME_HOP nuevas muestras
  // - Mantener una ventana actual con tamaño FRAME_LEN para extracción de MFCC
  static int16_t window[FRAME_LEN] = {0};
  static size_t writePos = 0;

  // Lee FRAME_HOP muestras nuevas
  int16_t hopBuf[FRAME_HOP];
  readI2SSamples(hopBuf, FRAME_HOP);

  // Desplaza ventana: elimina FRAME_HOP iniciales, añade FRAME_HOP al final
  memmove(window, window + FRAME_HOP, (FRAME_LEN - FRAME_HOP) * sizeof(int16_t));
  memcpy(window + (FRAME_LEN - FRAME_HOP), hopBuf, FRAME_HOP * sizeof(int16_t));

  // Extrae MFCC de la ventana actual
  frameToMFCC(window, mfcc);

  // Apila y ejecuta inferencia cuando tengamos NUM_FRAMES_STACK
  pushFrameFeatures(mfcc);
  inferAndReport();
}

void computeMelFilterbank() {
  // Definición de los límites de frecuencia
  float fMin = 20.0f;
  float fMax = SAMPLE_RATE / 2.0f;

  auto hzToMel = [](float hz) {
    return 2595.0f * log10f(1.0f + hz / 700.0f);
  };
  auto melToHz = [](float mel) {
    return 700.0f * (powf(10.0f, mel / 2595.0f) - 1.0f);
  };

  float melMin = hzToMel(fMin);
  float melMax = hzToMel(fMax);
  float melStep = (melMax - melMin) / (NUM_MEL + 1);

  // Puntos mel
  float melPts[NUM_MEL + 2];
  for (int i = 0; i < NUM_MEL + 2; ++i) {
    melPts[i] = melMin + i * melStep;
  }

  // Convertir a bins de FFT
  for (int m = 0; m < NUM_MEL + 2; ++m) {
    float hz = melToHz(melPts[m]);
    int bin = (int) floorf((FFT_SIZE + 1) * hz / SAMPLE_RATE);
    if (m > 0 && m < NUM_MEL + 1) {
      // Guardar bordes inferiores/superiores por banda
      melLowerBin[m - 1] = (uint16_t) bin;
      melUpperBin[m - 1] = (uint16_t) bin; // se corrige en el bucle siguiente
    }
  }

  // Construir filtros triangulares
  // Limpia pesos
  for (int i = 0; i < NUM_MEL; i++) {
    for (int k = 0; k <= FFT_SIZE / 2; k++) {
      melWeights[i][k] = 0.0f;
    }
  }

  for (int m = 1; m <= NUM_MEL; ++m) {
    int f_m_minus = (int) floorf((FFT_SIZE + 1) * melToHz(melPts[m - 1]) / SAMPLE_RATE);
    int f_m       = (int) floorf((FFT_SIZE + 1) * melToHz(melPts[m]) / SAMPLE_RATE);
    int f_m_plus  = (int) floorf((FFT_SIZE + 1) * melToHz(melPts[m + 1]) / SAMPLE_RATE);

    for (int k = f_m_minus; k < f_m; ++k) {
      if (k >= 0 && k <= FFT_SIZE / 2) {
        melWeights[m - 1][k] = (float)(k - f_m_minus) / (float)(f_m - f_m_minus + 1e-9f);
      }
    }
    for (int k = f_m; k < f_m_plus; ++k) {
      if (k >= 0 && k <= FFT_SIZE / 2) {
        melWeights[m - 1][k] = (float)(f_m_plus - k) / (float)(f_m_plus - f_m + 1e-9f);
      }
    }
  }
}

void frameToMFCC(const int16_t *pcm, float *out_mfcc) {
  // Pre-énfasis y ventana Hann
  for (int i = 0; i < FRAME_LEN; i++) {
    float x = (float)pcm[i] / 32768.0f;
    if (i > 0) {
      x = x - PREEMPHASIS * ((float)pcm[i - 1] / 32768.0f);
    }
    float w = 0.5f - 0.5f * cosf(2.0f * PI * i / (FRAME_LEN - 1));
    frameF32[i] = x * w;
  }

  // Relleno a FFT_SIZE
  for (int i = 0; i < FFT_SIZE; i++) {
    if (i < FRAME_LEN) {
      fftReal[i] = frameF32[i];
    } else {
      fftReal[i] = 0.0f;
    }
    fftImag[i] = 0.0f;
  }

  // FFT
  FFT.Windowing(FFT_WIN_TYP_RECTANGLE, FFT_FORWARD); // ya aplicamos ventana Hann
  FFT.Compute(FFT_FORWARD);
  FFT.ComplexToMagnitude();

  // Magnitud espectral hasta Nyquist
  // fftReal[k] contiene magnitud; ignorar bin 0 DC en log-mel
  computeLogMel(fftReal, melEnergies);

  // DCT a 13 coeficientes
  dct13(melEnergies, out_mfcc);

  // Normalización simple (opc.): media ~0
  // Aquí se puede aplicar CMVN si se desea; para simplicidad lo omitimos.
}

void computeLogMel(const float *magSpec, float *out_mel) {
  for (int m = 0; m < NUM_MEL; m++) {
    float e = 0.0f;
    for (int k = 0; k <= FFT_SIZE/2; k++) {
      e += magSpec[k] * melWeights[m][k];
    }
    out_mel[m] = logf(e + 1e-6f); // log-amplitud
  }
}

void dct13(const float *in, float *out) {
  // DCT-II ortonormal aproximada para 13 coeficientes sobre NUM_MEL entradas
  for (int n = 0; n < NUM_MFCC; n++) {
    float sum = 0.0f;
    for (int m = 0; m < NUM_MEL; m++) {
      sum += in[m] * cosf(PI * (m + 0.5f) * n / (float)NUM_MEL);
    }
    out[n] = sum; // sin normalización adicional por simplicidad
  }
}

float logistic(const float x) {
  // Evitar overflow
  if (x > 20.0f) return 1.0f;
  if (x < -20.0f) return 0.0f;
  return 1.0f / (1.0f + expf(-x));
}

void pushFrameFeatures(const float *mfcc) {
  // Desplaza hacia la izquierda un bloque de NUM_MFCC y añade al final
  memmove(featStack, featStack + NUM_MFCC, sizeof(float) * NUM_MFCC * (NUM_FRAMES_STACK - 1));
  memcpy(featStack + NUM_MFCC * (NUM_FRAMES_STACK - 1), mfcc, sizeof(float) * NUM_MFCC);
}

void inferAndReport() {
  // Inferencia basada en los features apilados (325 features)
  // Dot product + bias -> sigmoid -> prob.
  float score = dotProduct(featStack, KWS_WEIGHTS, KWS_FEATURES);
  score += KWS_BIAS;
  float prob = logistic(score);

  // Heurística de disparo: histéresis simple
  static bool triggered = false;
  static uint32_t lastTrigger = 0;
  const float TH_ON  = 0.75f;
  const float TH_OFF = 0.65f;
  const uint32_t REFRACTORY_MS = 1000;

  uint32_t now = millis();
  if (!triggered && prob > TH_ON && (now - lastTrigger) > REFRACTORY_MS) {
    triggered = true;
    lastTrigger = now;
    Serial.print("DETECCION: prob=");
    Serial.println(prob, 3);
  } else if (triggered && prob < TH_OFF) {
    triggered = false;
  }

  // Telemetría (opcional): comentar si causa latencias
  Serial.print("p=");
  Serial.println(prob, 3);
}

float dotProduct(const float *a, const float *b, size_t n) {
  float s = 0.0f;
  for (size_t i = 0; i < n; i++) s += a[i] * b[i];
  return s;
}

Archivo de pesos (temporal, será reemplazado luego por entrenamiento):

// File: weights.h
#pragma once
// Nº de características: NUM_FRAMES_STACK * NUM_MFCC = 25 * 13 = 325
#define KWS_FEATURES 325

// Pesos iniciales de marcador de posición (todo a 0.0f); serán generados por Python
static const float KWS_WEIGHTS[KWS_FEATURES] = {
  // Se sobreescribirá con pesos reales; mantener longitud 325
  #define Z0 0.0f
  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0,  Z0,Z0,Z0,Z0,Z0
  #undef Z0
};

static const float KWS_BIAS = 0.0f;

Explicación breve de las partes clave:
– Captura I2S: I2S.begin(I2S_PHILIPS_MODE, 16000, 32) configura el bus en modo receptor. INMP441 entrega 24 bits útiles; se reducen a int16 con un shift.
– Ventaneo: FRAME_LEN=480 muestras (30 ms) con hop de 160 muestras (10 ms) para solapidado.
– FFT y mel: Se aplica ventana Hann, FFT 512, se genera un banco de 20 filtros mel triangulares y se calcula log-energía.
– MFCC: DCT tipo II para 13 coeficientes por frame. Se apilan 25 frames (~250 ms).
– Clasificador: producto punto con pesos exportados + función logística; se reporta probabilidad y se aplica histéresis con periodo refractario.

Compilación, flashing y ejecución

Asumiendo que tu sketch está en ~/proyectos/i2s_kws_nano33iot:

  1. Compilar (Nano 33 IoT):
    bash
    arduino-cli compile \
    --fqbn arduino:samd:nano_33_iot \
    ~/proyectos/i2s_kws_nano33iot
  2. Subir (ajusta el puerto al listado por board list):
    bash
    # En Linux suele ser /dev/ttyACM0
    arduino-cli upload \
    -p /dev/ttyACM0 \
    --fqbn arduino:samd:nano_33_iot \
    ~/proyectos/i2s_kws_nano33iot
  3. Abrir monitor serie a 115200 baudios:
    bash
    arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

Salida esperada inicial (sin pesos reales):
– Mensaje de inicio “Init I2S KWS…” y líneas “p=0.500” fluctuando cerca de 0.5 (con pesos cero, la sigmoide de 0 produce 0.5).

Entrenamiento del clasificador y exportación de pesos

Para pasar de “esqueleto” a un KWS real, capturaremos ejemplos de la palabra clave (ej.: “hola”) y de fondo/noise, entrenaremos un clasificador logístico y exportaremos sus pesos a weights.h.

Preparación del entorno Python

cd ~/proyectos/i2s_kws_nano33iot
python3 -m venv .venv
source .venv/bin/activate
pip install numpy==1.26.4 scikit-learn==1.4.2 pyserial==3.5

Firmware de captura rápida

Usaremos el propio firmware para stream de MFCC ya calculados, simplificando dataset y entrenamiento (features ya procesadas). Añade en el loop un modo de “dump” controlado por comando serie o, más simple, crea un pequeño script Python que escuche “p=” y MFCC si lo deseas. Aquí proponemos una segunda sketch minimal para streaming de MFCC en lugar de probabilidad. Alternativamente, modifica el actual para imprimir MFCC cuando reciba ‘F’.

Para rapidez, usaremos un script Python que escucha “MFCC:” que enviaremos. Modifica temporalmente inferAndReport así:

  • Sustituye Serial.print(«p=»…) por impresión de MFCC:
// Sustituye inferAndReport por esta versión temporal para recolectar MFCC
void inferAndReport() {
  // Imprime MFCC de la última ventana apilada (25x13)
  Serial.print("MFCC:");
  for (int i = 0; i < NUM_FRAMES_STACK * NUM_MFCC; i++) {
    Serial.print(featStack[i], 6);
    if (i < NUM_FRAMES_STACK * NUM_MFCC - 1) Serial.print(',');
  }
  Serial.println();
}

Compila y sube de nuevo. Abre el monitor para verificar que salen líneas “MFCC:…”.

Script Python de captura etiquetada

Crea capture.py para etiquetar en vivo (presiona ‘k’ cuando digas la palabra, ‘n’ para ruido):

# File: capture.py
import sys, time, serial, threading
from datetime import datetime

PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
BAUD = 115200
OUT = "dataset.csv"

print(f"Abrir {PORT} @ {BAUD}")
ser = serial.Serial(PORT, BAUD, timeout=1)

label = "noise"
running = True
count = {"noise":0, "keyword":0}

def key_reader():
    global label, running
    try:
        while running:
            k = sys.stdin.read(1)
            if k == 'k':
                label = "keyword"
                print("[LABEL] keyword")
            elif k == 'n':
                label = "noise"
                print("[LABEL] noise")
            elif k == 'q':
                running = False
                break
    except Exception as e:
        print("Key thread error:", e)

threading.Thread(target=key_reader, daemon=True).start()

with open(OUT, "w") as f:
    # Cabecera
    cols = [f"f{i}" for i in range(325)]
    f.write("label," + ",".join(cols) + "\n")
    try:
        while running:
            line = ser.readline().decode(errors="ignore").strip()
            if line.startswith("MFCC:"):
                data = line.split("MFCC:")[1].strip()
                parts = data.split(",")
                if len(parts) != 325:
                    continue
                f.write(label + "," + ",".join(parts) + "\n")
                f.flush()
                count[label] += 1
                if (count["noise"] + count["keyword"]) % 20 == 0:
                    print(f"Samples -> noise: {count['noise']}, keyword: {count['keyword']}")
    except KeyboardInterrupt:
        pass

running = False
ser.close()
print("Guardado en", OUT)

Uso:

python capture.py /dev/ttyACM0
# Pulsa 'k' mientras dices "hola" 1 s antes y 1 s después para capturar ejemplos
# Pulsa 'n' para marcar ruido/fondo
# Pulsa 'q' para terminar

Objetivo mínimo de dataset:
– 300 ejemplos “keyword”
– 600 ejemplos “noise”
– Total ~900 filas

Consejo: recoge en diferentes condiciones (distancias, ruidos, voces).

Entrenamiento y exportación de pesos

Crea train_export.py:

# File: train_export.py
import numpy as np
from sklearn.linear_model import LogisticRegression

DATASET = "dataset.csv"
OUT_H = "weights.h"
FEATURES = 325

# Carga CSV
rows = []
labels = []
with open(DATASET, "r") as f:
    header = f.readline()
    for line in f:
        parts = line.strip().split(",")
        label = parts[0]
        feats = np.array([float(x) for x in parts[1:]], dtype=np.float32)
        if feats.shape[0] != FEATURES:
            continue
        rows.append(feats)
        labels.append(1 if label == "keyword" else 0)

X = np.vstack(rows)
y = np.array(labels, dtype=np.int32)

# Normalización simple por-feature (media 0, var 1)
mu = X.mean(axis=0)
sigma = X.std(axis=0) + 1e-6
Xn = (X - mu) / sigma

# Entrena logistic regression (L2, solver liblinear o saga)
clf = LogisticRegression(max_iter=1000, solver="liblinear")
clf.fit(Xn, y)

acc = clf.score(Xn, y)
print("Accuracy (train) =", acc)

w = clf.coef_[0].astype(np.float32)
b = float(clf.intercept_[0])

# Exporta a header C++ con normalización integrada: transformamos pesos a espacio original
# y = sigmoid( (x-mu)/sigma · w + b ) = sigmoid( x · (w/sigma) + (b - mu·(w/sigma)) )
ws = w / sigma
b_adj = b - (mu * ws).sum()

def as_c_array(arr, name):
    s = f"static const float {name}[{len(arr)}] = {{\n"
    line = ""
    for i, v in enumerate(arr):
        line += f"{v:.8e}f,"
        if (i+1) % 10 == 0:
            s += "  " + line + "\n"
            line = ""
    if line:
        s += "  " + line + "\n"
    s += "};\n"
    return s

with open(OUT_H, "w") as f:
    f.write("#pragma once\n")
    f.write(f"#define KWS_FEATURES {FEATURES}\n\n")
    f.write(as_c_array(ws, "KWS_WEIGHTS"))
    f.write(f"\nstatic const float KWS_BIAS = {b_adj:.8e}f;\n")

print(f"Generado {OUT_H}")

Ejecución:

python train_export.py
# Reemplazará weights.h con pesos reales y sesgo ajustado

Restablece el firmware original (inferAndReport con probabilidad y disparos), compila y sube:

arduino-cli compile --fqbn arduino:samd:nano_33_iot ~/proyectos/i2s_kws_nano33iot
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:samd:nano_33_iot ~/proyectos/i2s_kws_nano33iot
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

Ahora la salida “p=…” variará y, cuando pronuncies “hola”, deberías ver “DETECCION: prob=…”.

Validación paso a paso

  1. Verificación física:
  2. Conexiones:
    • VDD->3.3V, GND->GND
    • INMP441 SCK->D2, WS->D3, SD->D4
    • L/R->GND (canal izquierdo)
  3. Cables cortos y firmes.
  4. Inicialización:
  5. Al abrir el monitor serie a 115200, verás:
    • “Init I2S KWS (Nano 33 IoT + INMP441)”
    • “I2S listo. Capturando…”
  6. Nivel de ruido:
  7. Observa “p=…” en reposo: valores típicos alrededor de 0.1–0.4 si el modelo distingue silencio; si mal entrenado, ~0.5.
  8. Palabra clave:
  9. Pronuncia “hola” a 20–40 cm del micrófono; deberías ver “DETECCION: prob=0.80–0.99”.
  10. Repite varias veces para medir consistencia.
  11. Falsos positivos:
  12. Conversa sin decir “hola” o reproduce ruido; mide cuántas veces dispara por minuto (objetivo < 1/min).
  13. Robustez:
  14. Cambia distancia (10–80 cm), orientación del micrófono y presencia de ruido de fondo moderado.
  15. Latencia:
  16. El pipeline usa ~250 ms de contexto; la detección debería ocurrir en < 400 ms desde el inicio de la palabra.

Métricas sugeridas:
– Tasa de acierto con 50 ensayos deliberados de “hola”.
– Falsos positivos en 10 min de conversación sin la keyword.
– Probabilidad media al decir “hola” vs en silencio.

Troubleshooting

  1. No hay salida en el monitor serie
  2. Asegura el puerto correcto en arduino-cli monitor.
  3. Pulsa el botón RESET doble para entrar en bootloader y vuelve a subir.
  4. Verifica alimentación del USB y cable de datos (no solo carga).
  5. Error “no se pudo iniciar I2S”
  6. Revisa pines D2, D3, D4; evita cortocircuitos y cables sueltos.
  7. Quita otros dispositivos del bus que pudieran interferir.
  8. Reinicia la placa y el PC si el puerto USB quedó en mal estado.
  9. Probabilidades siempre ~0.5 o 0.0/1.0
  10. Verifica que weights.h fue regenerado y que el include apunta al archivo correcto.
  11. Asegura suficientes muestras y balance de clases (≥ 300 keyword, ≥ 600 noise).
  12. Revisa normalización integrada en train_export.py; no edites manualmente.
  13. Distorsión/recortes en audio
  14. Ajusta el shift de conversión (s >> 14). Si saturas, prueba >>15; si el volumen es bajo, prueba >>13.
  15. Asegura que L/R está a GND (o VDD) sólidamente; flotante puede introducir errores de canal.
  16. Ruido elevado o probabilidad inestable
  17. Usa cables más cortos y masa común robusta.
  18. Aísla de corrientes de aire y vibraciones (el INMP441 es sensible).
  19. Incrementa NUM_MEL a 26 y/o aplica media temporal de probabilidades.
  20. Subida falla con “No device found on port”
  21. Verifica que el puerto no cambió (/dev/ttyACM1).
  22. En Linux, añade tu usuario al grupo dialout: sudo usermod -aG dialout $USER y re‑inicia sesión.
  23. Memoria insuficiente o resets
  24. Reduce FFT_SIZE a 256 y ajusta FRAME_LEN a 320.
  25. Reduce NUM_FRAMES_STACK a 20 o NUM_MFCC a 10.
  26. Evita prints muy frecuentes; comenta telemetría en producción.
  27. Dataset inconsistente
  28. Alinea el tiempo: al etiquetar ‘k’, habla la keyword inmediatamente para capturar frames con señal.
  29. Graba en varias sesiones para mejorar generalización.

Mejoras/variantes

  • Sustituir el clasificador logístico por:
  • SVM lineal (exportable como vector de pesos).
  • Red MLP de 1–2 capas pequeñas con activaciones ReLU, entrenada en Python y exportada como arrays; inferencia con CMSIS‑NN.
  • Usar MFBE en vez de MFCC:
  • Eliminar DCT (dct13); directo sobre log-mel a menudo da buenos resultados y reduce cómputo.
  • TFLite Micro:
  • Entrenar un modelo DSCNN pequeño y portarlo con Arduino_TensorFlowLite (asegúrate de tamaños de tensor ajustados a RAM del SAMD21).
  • Filtrado adaptativo:
  • VAD (Voice Activity Detection) por energía/ZCR antes de pasar a MFCC para reducir cargas y falsos positivos.
  • BLE/IoT:
  • Publicar eventos de detección por BLE (NINA-W102) o MQTT via WiFi para integración domótica.
  • Optimización:
  • Usar fixed‑point Q15 y CMSIS‑DSP para FFT/DCT.
  • Bajar SAMPLE_RATE a 8 kHz para voces graves, ajustando filtros mel.
  • Multi‑keyword:
  • One‑vs‑rest con varios clasificadores logísticos o softmax con MLP.

Checklist de verificación

  • [ ] Instalé Arduino CLI v0.35.3 y el core arduino:samd@1.8.14 sin errores.
  • [ ] Instalé las librerías Arduino_I2S@1.0.3 y arduinoFFT@1.6.1.
  • [ ] Conecté el INMP441 a 3.3V y GND del Nano 33 IoT.
  • [ ] Cableé I2S: SCK->D2, WS->D3, SD->D4; L/R->GND.
  • [ ] El firmware compila y sube con FQBN arduino:samd:nano_33_iot.
  • [ ] El monitor serie muestra “I2S listo. Capturando…”.
  • [ ] Puedo capturar MFCC con capture.py y etiquetar con ‘k’/‘n’.
  • [ ] Entrené el clasificador y generé weights.h con train_export.py.
  • [ ] Recompilé y subí el firmware con los nuevos pesos.
  • [ ] Veo “DETECCION” con probabilidad > 0.75 al decir “hola”.
  • [ ] Los falsos positivos están en niveles aceptables; ajusté TH_ON/TH_OFF si fue necesario.

Con esto, tendrás un pipeline completo de i2s‑keyword‑spotting sobre el Arduino Nano 33 IoT con micrófono INMP441, usando una toolchain reproducible y un flujo de trabajo de principio a fin (captura, entrenamiento, despliegue e inferencia en tiempo real).

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el sistema operativo recomendado para el proyecto?




Pregunta 2: ¿Qué versión de Arduino CLI se debe instalar?




Pregunta 3: ¿Cuál es el modelo exacto del Arduino utilizado en el proyecto?




Pregunta 4: ¿Qué micrófono se utiliza para capturar audio?




Pregunta 5: ¿Cuál es la frecuencia de muestreo del audio capturado?




Pregunta 6: ¿Qué paquete de Python se utiliza para la regresión logística?




Pregunta 7: ¿Qué tipo de clasificador se entrena en Python?




Pregunta 8: ¿Qué se debe evitar al alimentar el micrófono INMP441?




Pregunta 9: ¿Qué librería se utiliza para el procesamiento de audio?




Pregunta 10: ¿Qué herramienta se utiliza para la inferencia en tiempo real en el Arduino?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Pesaje de colmena Zigbee Arduino, XBee, HX711

Caso práctico: Pesaje de colmena Zigbee Arduino, XBee, HX711 — hero

Objetivo y caso de uso

Qué construirás: Un sistema de pesaje para colmenas utilizando Arduino Uno R3, XBee S2C y HX711 para medir el peso y monitorear el ambiente.

Para qué sirve

  • Monitoreo del peso de colmenas en tiempo real para optimizar la producción de miel.
  • Registro de datos ambientales (temperatura y humedad) usando el sensor SHT31.
  • Transmisión de datos a través de Zigbee para una comunicación eficiente y de bajo consumo.
  • Integración con sistemas de alerta para notificar cambios significativos en el peso.

Resultado esperado

  • Precisión en la medición del peso de ±0.1 kg utilizando HX711.
  • Latencia de transmisión de datos menor a 200 ms a través de Zigbee.
  • Capacidad de enviar datos de temperatura y humedad cada 5 minutos.
  • Recepción de datos en un PC con Python y pyserial, validando al menos 95% de los paquetes.

Público objetivo: Ingenieros y entusiastas de la electrónica; Nivel: Avanzado

Arquitectura/flujo: Sensor de peso (HX711) y ambiental (SHT31) conectados a Arduino Uno R3, datos enviados a través de XBee Zigbee S2C a un coordinador en PC.

Nivel: Avanzado

Prerrequisitos

Sistema operativo y herramientas (probado)

  • Linux:
  • Ubuntu 22.04.4 LTS (64-bit)
  • Python 3.11.6 (para validación PC)
  • Digi XCTU 6.5.10 (configuración XBee)
  • Windows:
  • Windows 11 Pro 23H2
  • Python 3.11.6
  • Digi XCTU 6.5.10

Toolchain exacta para Arduino Uno (AVR)

  • Arduino CLI 0.35.3
  • Core AVR:
  • FQBN: arduino:avr:uno
  • Paquete: arduino:avr@1.8.6
  • Librerías Arduino (Library Manager vía arduino-cli lib install):
  • HX711@0.7.5 (bogde/HX711)
  • Adafruit SHT31 Library@2.2.1
  • Adafruit BusIO@1.14.5
  • Monitor serie de Arduino CLI:
  • arduino-cli monitor (integrado desde 0.34+)

Herramientas adicionales para Zigbee

  • Digi XCTU 6.5.10 para configurar los módulos XBee Zigbee S2C (EM357).
  • Un adaptador USB para XBee (por ejemplo, “XBee USB Explorer”) para el coordinador en el PC.
  • Python en el PC con pyserial para validar la recepción:
  • pyserial 3.5

Materiales

  • Nodo sensor (en colmena):
  • 1x Arduino Uno R3
  • 1x XBee Zigbee S2C (EM357)
  • 1x Shield XBee para Arduino con regulador 3.3V y nivelación de voltaje (cualquier modelo estándar compatible con UNO R3)
  • 1x HX711 (amplificador para célula de carga)
  • 1x Célula de carga (ej. 50 kg o 100 kg, tipo viga)
  • 1x SHT31 (breakout con interfaz I2C, por ejemplo Adafruit SHT31-D)
  • Cableado Dupont, tornillería, base rígida para célula de carga, conectores
  • Fuente de alimentación para el nodo (inicialmente USB, en despliegue real: batería con step-up/step-down y/o panel solar)

  • Coordinador (en PC):

  • 1x XBee Zigbee S2C (EM357) adicional (coordinador)
  • 1x Adaptador USB para XBee (XBee Explorer USB o similar)
  • PC con Ubuntu 22.04.4 LTS o Windows 11 23H2, con XCTU 6.5.10

  • Nota de coherencia:

  • El modelo principal a emplear en el nodo es exactamente: Arduino Uno R3 + XBee Zigbee S2C (EM357) + HX711 + SHT31.
  • El coordinador XBee Zigbee S2C (EM357) adicional se usa para la validación y recepción de datos Zigbee en el PC, no altera el modelo del nodo sensor.

Preparación y conexión

Consideraciones de alimentación y niveles lógicos

  • El XBee Zigbee S2C funciona a 3.3V y puede demandar picos de corriente >200 mA durante transmisión. Por eso, use un Shield XBee con regulador 3.3V y level shifting. Evite alimentar el XBee directamente desde el pin 3.3V del UNO (capacidad limitada).
  • El HX711 funciona a 5V típicamente (verifique su módulo).
  • El SHT31 (breakout) suele ser 3–5V tolerant (si es Adafruit, integra level shifting); confirme el modelo y su rango de alimentación. Aquí alimentaremos a 5V para simplificar.

Mapeo de pines y conexiones

La siguiente tabla detalla las conexiones para el nodo:

Componente Señal/Pin Arduino Uno R3 Notas
XBee S2C DIN (entrada XBee) D3 (TX SoftwareSerial) A través del Shield XBee (nivelado)
XBee S2C DOUT (salida XBee) D2 (RX SoftwareSerial) A través del Shield XBee (nivelado)
XBee S2C VCC 3.3V (del Shield) Regulador del Shield
XBee S2C GND GND Masa común
HX711 VCC 5V Verifique su módulo
HX711 GND GND Masa común
HX711 DT (DOUT) D4 Entrada de datos
HX711 SCK D5 Reloj
Célula carga E+ / E- HX711 E+ / E- Excitación
Célula carga A+ / A- HX711 A+ / A- Señal
SHT31 VIN 5V Si su breakout lo permite
SHT31 GND GND Masa común
SHT31 SDA A4 I2C
SHT31 SCL A5 I2C
  • Consola de depuración: USB del Arduino (Serial a 115200 baudios).
  • Canal Zigbee: XBee en modo transparente (AT) a 9600 baudios, conectado al UNO vía SoftwareSerial en D2 (RX) y D3 (TX).

Preparación de XBee (coordinador y nodo)

  • Use XCTU 6.5.10 para configurar ambos XBee S2C (EM357):
  • Coordinador (en el PC):
  • Función: Zigbee Coordinator AT (XB24C, “Zigbee TH Reg”, AT)
  • BD (Baud Rate): 9600
  • PAN ID (ID): por ejemplo 0x7A7A
  • Canal (CH): opcional o automático
  • AP (API Mode): 0 (modo transparente)
  • CE (Coordinator Enable): 1
  • NI (Node Identifier): COORD
  • Escribir cambios (Write).
  • Anote SH y SL (dirección 64-bit).
  • Nodo (en el UNO):
  • Función: Zigbee Router AT (XB24C, “Zigbee TH Reg”, AT)
  • BD (Baud Rate): 9600
  • PAN ID (ID): 0x7A7A (igual al coordinador)
  • AP: 0 (transparente)
  • CE: 0
  • DH/DL: ponga la dirección 64-bit del coordinador (DH=SH del coordinador, DL=SL del coordinador)
  • JV: 1 (permitir rejoin si el coordinador reinicia)
  • NI: BEE_NODE
  • AI: compruebe 0x00 tras asociar (Association Indication OK)
  • Escribir cambios (Write).

  • Tras configurar el nodo, monte el XBee S2C en el Shield, y el Shield sobre el UNO R3.

Código completo (Arduino C++) con explicación

El siguiente sketch lee peso (HX711) y ambiente (SHT31), filtra, formatea una línea CSV y la envía vía Zigbee (XBee en modo transparente) y en paralelo por el puerto USB para depuración. Incluye comandos por serial para tare, factor de calibración y periodo, y persistencia en EEPROM.

// zigbee_beehive_weight_sensing.ino
// Hardware: Arduino Uno R3 + XBee Zigbee S2C (EM357) + HX711 + SHT31
// Toolchain: Arduino CLI 0.35.3, arduino:avr@1.8.6
// Librerías: HX711@0.7.5, Adafruit SHT31 Library@2.2.1, Adafruit BusIO@1.14.5

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_SHT31.h>
#include <HX711.h>
#include <SoftwareSerial.h>
#include <EEPROM.h>

// Pines
static const uint8_t PIN_HX711_DOUT = 4;
static const uint8_t PIN_HX711_SCK  = 5;
static const uint8_t PIN_XBEE_RX    = 2; // RX del UNO (desde DOUT del XBee)
static const uint8_t PIN_XBEE_TX    = 3; // TX del UNO (hacia DIN del XBee)

// Objetos
HX711 scale;
Adafruit_SHT31 sht31 = Adafruit_SHT31();
SoftwareSerial xbee(PIN_XBEE_RX, PIN_XBEE_TX);

// Configuración de medidas
volatile unsigned long samplePeriodMs = 5000; // periodo de muestreo
volatile long calibrationFactor = 2280; // factor de ejemplo; calibrar para su célula/puente
volatile int hx711Averages = 10; // promediado interno HX711

// EEPROM layout
// addr 0..3: signature 'BZWS' (Beehive Zigbee Weight Sensing)
// addr 4..7: calibrationFactor (int32)
// addr 8..11: samplePeriodMs (uint32)
const int EEPROM_ADDR_SIGNATURE = 0;
const int EEPROM_ADDR_CAL       = 4;
const int EEPROM_ADDR_PERIOD    = 8;

void eepromWriteLong(int addr, long value) {
  EEPROM.put(addr, value);
}

long eepromReadLong(int addr) {
  long v = 0;
  EEPROM.get(addr, v);
  return v;
}

void eepromWriteULong(int addr, unsigned long value) {
  EEPROM.put(addr, value);
}

unsigned long eepromReadULong(int addr) {
  unsigned long v = 0;
  EEPROM.get(addr, v);
  return v;
}

bool eepromHasSignature() {
  char sig[4];
  for (int i = 0; i < 4; ++i) sig[i] = EEPROM.read(EEPROM_ADDR_SIGNATURE + i);
  return (sig[0]=='B' && sig[1]=='Z' && sig[2]=='W' && sig[3]=='S');
}

void eepromWriteSignature() {
  EEPROM.write(EEPROM_ADDR_SIGNATURE + 0, 'B');
  EEPROM.write(EEPROM_ADDR_SIGNATURE + 1, 'Z');
  EEPROM.write(EEPROM_ADDR_SIGNATURE + 2, 'W');
  EEPROM.write(EEPROM_ADDR_SIGNATURE + 3, 'S');
}

void saveConfigToEEPROM() {
  eepromWriteSignature();
  eepromWriteLong(EEPROM_ADDR_CAL, calibrationFactor);
  eepromWriteULong(EEPROM_ADDR_PERIOD, samplePeriodMs);
}

void loadConfigFromEEPROM() {
  if (eepromHasSignature()) {
    calibrationFactor = eepromReadLong(EEPROM_ADDR_CAL);
    samplePeriodMs = eepromReadULong(EEPROM_ADDR_PERIOD);
  }
}

void printHelp() {
  Serial.println(F("Comandos (USB@115200):"));
  Serial.println(F("  h             : ayuda"));
  Serial.println(F("  t             : tare (poner a cero)"));
  Serial.println(F("  c <factor>    : set calibracion (p.ej. c 2280)"));
  Serial.println(F("  p <ms>        : set periodo muestreo en ms (p.ej. p 5000)"));
  Serial.println(F("  a <n>         : set promediado HX711 (1..20)"));
  Serial.println(F("  s             : muestreo inmediato"));
  Serial.println(F("  w             : guardar en EEPROM"));
  Serial.println(F("  r             : recargar de EEPROM"));
  Serial.println(F("Formato TX: CSV: ts_ms,weight_kg,tempC,humRH,status"));
}

bool setupSHT31() {
  if (!sht31.begin(0x44)) { // 0x44 por defecto; 0x45 si A0 soldado
    Serial.println(F("ERROR: SHT31 no encontrado en 0x44"));
    return false;
  }
  // Opcional: sht31.heater(true/false)
  return true;
}

void setupHX711() {
  scale.begin(PIN_HX711_DOUT, PIN_HX711_SCK);
  scale.set_scale(); // Inicialmente sin factor
  delay(100);
  scale.tare(20);    // Establecer tara inicial con N lecturas
  scale.set_scale((float)calibrationFactor);
}

void sendLine(const char* line) {
  xbee.println(line);   // Hacia Zigbee (modo transparente)
  Serial.println(line); // Depuracion en USB
}

void performSampleAndSend() {
  // Lectura HX711
  float weight = scale.get_units(hx711Averages); // unidades en función del factor
  // Lecturas SHT31
  float t = sht31.readTemperature();
  float h = sht31.readHumidity();
  // Estado
  const char* status = "OK";
  if (isnan(t) || isnan(h)) status = "SHT31_ERR";

  // Línea CSV
  char line[128];
  unsigned long ts = millis();
  // CSV: ts_ms,weight_kg,tempC,humRH,status
  snprintf(line, sizeof(line), "%lu,%.3f,%.2f,%.2f,%s", ts, weight, t, h, status);
  sendLine(line);
}

void handleSerialCommands() {
  if (!Serial.available()) return;
  String cmd = Serial.readStringUntil('\n');
  cmd.trim();
  if (cmd.length() == 0) return;

  if (cmd == "h") {
    printHelp();
  } else if (cmd == "t") {
    Serial.println(F("Tare..."));
    scale.tare(20);
    Serial.println(F("OK"));
  } else if (cmd.startsWith("c ")) {
    long f = cmd.substring(2).toInt();
    calibrationFactor = f;
    scale.set_scale((float)calibrationFactor);
    Serial.print(F("Calibracion=")); Serial.println(calibrationFactor);
  } else if (cmd.startsWith("p ")) {
    unsigned long p = (unsigned long)cmd.substring(2).toInt();
    if (p >= 500 && p <= 60000UL) {
      samplePeriodMs = p;
      Serial.print(F("Periodo(ms)=")); Serial.println(samplePeriodMs);
    } else {
      Serial.println(F("ERROR: periodo fuera de rango (500..60000)"));
    }
  } else if (cmd.startsWith("a ")) {
    int n = cmd.substring(2).toInt();
    if (n >= 1 && n <= 20) {
      hx711Averages = n;
      Serial.print(F("Promediado HX711=")); Serial.println(hx711Averages);
    } else {
      Serial.println(F("ERROR: promediado (1..20)"));
    }
  } else if (cmd == "s") {
    performSampleAndSend();
  } else if (cmd == "w") {
    saveConfigToEEPROM();
    Serial.println(F("EEPROM guardada"));
  } else if (cmd == "r") {
    loadConfigFromEEPROM();
    scale.set_scale((float)calibrationFactor);
    Serial.print(F("EEPROM: cal=")); Serial.print(calibrationFactor);
    Serial.print(F(" period=")); Serial.println(samplePeriodMs);
  } else {
    Serial.println(F("Comando desconocido. 'h' para ayuda."));
  }
}

void setup() {
  Serial.begin(115200);
  xbee.begin(9600);

  Serial.println(F("zigbee-beehive-weight-sensing (UNO R3 + XBee S2C + HX711 + SHT31)"));
  loadConfigFromEEPROM();

  if (!setupSHT31()) {
    Serial.println(F("Continuando sin SHT31 (status=SHT31_ERR)"));
  }

  setupHX711();

  Serial.print(F("Cal=")); Serial.print(calibrationFactor);
  Serial.print(F(" Period(ms)=")); Serial.print(samplePeriodMs);
  Serial.print(F(" Avg=")); Serial.println(hx711Averages);

  printHelp();
}

void loop() {
  static unsigned long last = 0;
  unsigned long now = millis();
  if ((now - last) >= samplePeriodMs) {
    last = now;
    performSampleAndSend();
  }
  handleSerialCommands();
}

Explicación breve de partes clave

  • SoftwareSerial en D2/D3: evita ocupar Serial hardware (USB) del UNO para debug, y nos permite tener el XBee S2C independiente a 9600 baudios en modo transparente.
  • HX711:
  • scale.set_scale(calibrationFactor): transforma la lectura del ADC en “unidades” (kg si calibramos adecuadamente).
  • hx711Averages controla cuántas muestras internas se promedian en la función get_units().
  • SHT31:
  • Se inicializa en la dirección I2C 0x44 (común). Si su breakout usa 0x45, cambie en el código.
  • Persistencia en EEPROM:
  • Guarda factor de calibración y periodo (firma “BZWS”) para conservarlos entre reinicios.
  • Interfaz de comandos:
  • Comandos simples por USB para tare, calibración, periodo, etc., muy útil en campo.

Compilación, “flash” y ejecución

Asegúrese de conectar el Arduino Uno R3 por USB. Identifique el puerto serie (ejemplos: /dev/ttyACM0 en Linux, COM5 en Windows).

Preparación de proyecto

  • Estructura de carpetas (Linux/macOS; en Windows use rutas equivalentes):
mkdir -p ~/projects/zigbee_beehive_weight_sensing
nano ~/projects/zigbee_beehive_weight_sensing/zigbee_beehive_weight_sensing.ino
# Pegue el sketch completo anterior y guarde

Instalación de toolchain y librerías (Arduino CLI)

# 1) Instalar Arduino CLI (si no lo tiene) y asegúrese de usar la 0.35.3
arduino-cli version
# Debería mostrar: arduino-cli Version: 0.35.3

# 2) Actualizar índices de cores y librerías
arduino-cli core update-index

# 3) Instalar el core AVR exacto
arduino-cli core install arduino:avr@1.8.6

# 4) Instalar librerías exactas
arduino-cli lib install "HX711@0.7.5" "Adafruit SHT31 Library@2.2.1" "Adafruit BusIO@1.14.5"

Compilar y subir al UNO R3

  • Linux:
# Lista de placas para encontrar el puerto (opc.)
arduino-cli board list

# Compilar
arduino-cli compile --fqbn arduino:avr:uno ~/projects/zigbee_beehive_weight_sensing

# Subir (ajuste el puerto si es distinto)
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:uno ~/projects/zigbee_beehive_weight_sensing
  • Windows (PowerShell, ejemplo puerto COM5):
arduino-cli board list

arduino-cli compile --fqbn arduino:avr:uno $env:USERPROFILE\projects\zigbee_beehive_weight_sensing

arduino-cli upload -p COM5 --fqbn arduino:avr:uno $env:USERPROFILE\projects\zigbee_beehive_weight_sensing

Visualizar depuración por USB

# Linux
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

Finalice con Ctrl+C.

Validación paso a paso

Objetivo: confirmar que el nodo “zigbee-beehive-weight-sensing” transmite peso y ambiente por Zigbee y que el coordinador en PC recibe líneas CSV.

1) Verifique alimentación y enlaces:
– El Shield XBee está firmemente insertado.
– GND común entre todos.
– HX711 cableado correcto: E+/E- al excitador, A+/A- a señal de la célula (polaridad correcta).
– SHT31 conectado a A4/A5.

2) Verifique XBee en XCTU:
– Conecte el XBee coordinador al PC con el adaptador USB.
– En XCTU 6.5.10, agregue el puerto del XBee coordinador, lea parámetros.
– Confirme:
– Función: Zigbee Coordinator AT
– PAN ID = 0x7A7A (o el que definió)
– BD=9600, AP=0
– AI=0x00 (si ya hay nodos; si no, aparecerá 0x21 hasta que el nodo se asocie)
– Inserte/encienda el nodo UNO con su XBee.
– En XCTU, busque dispositivos en red (Network Working Mode) y verifique que aparezca “BEE_NODE”.

3) Monitor USB del UNO:
– Ejecute arduino-cli monitor a 115200.
– Debe ver un banner y ayuda de comandos. Ejemplo:
– “zigbee-beehive-weight-sensing (UNO R3 + XBee S2C + HX711 + SHT31)”
– “Cal=2280 Period(ms)=5000 Avg=10”
– “Comandos (USB@115200): …”
– En ausencia de SHT31, verá “SHT31_ERR” en el campo status.

4) Calibración (imprescindible para peso real):
– Con la colmena/viga descargada, ejecute: “t” y Enter (tare).
– Coloque un peso patrón conocido (p.ej., 5.000 kg) sobre la plataforma.
– Observe la salida CSV en USB: “ts_ms,weight_kg,tempC,humRH,status”. Ajuste “c ” hasta que weight_kg se aproxime al valor real:
– Comando ejemplo: “c 2100” o “c 2450” y Enter, reintente hasta converger.
– Cuando esté satisfecho, ejecute “w” para guardar en EEPROM.

5) Verificación de transmisión Zigbee en el PC:
– Reciba desde el XBee coordinador. Dos opciones:
a) Uso de consola serial de XCTU:
– Abra una consola serial a 9600 baudios sobre el puerto del coordinador.
– Deben llegar líneas CSV periódicas: “12345,12.345,27.15,62.10,OK”
b) Uso de Python (pyserial) para registrar datos:

# Linux/Windows, instale pyserial 3.5
python -m pip install --user pyserial==3.5

Y el script:

# logger_zigbee_beehive.py
# Python 3.11.6 + pyserial 3.5
import sys, time, serial

if len(sys.argv) < 3:
    print("Uso: python logger_zigbee_beehive.py <PORT> <BAUD>")
    print("Ej:  python logger_zigbee_beehive.py COM7 9600   (Windows)")
    print("     python logger_zigbee_beehive.py /dev/ttyUSB0 9600  (Linux)")
    sys.exit(1)

port = sys.argv[1]
baud = int(sys.argv[2])

with serial.Serial(port, baud, timeout=1) as ser:
    print(f"Escuchando {port}@{baud} ... Ctrl+C para salir")
    while True:
        try:
            line = ser.readline().decode('utf-8', errors='ignore').strip()
            if line:
                ts = int(time.time())
                print(f"{ts},{line}")
        except KeyboardInterrupt:
            break
  • Ejecútelo:
    • Windows: python logger_zigbee_beehive.py COM7 9600
    • Linux: python3 logger_zigbee_beehive.py /dev/ttyUSB0 9600
  • Debe ver líneas con timestamp del PC + CSV del nodo.

6) Validación funcional:
– Cambie la carga sobre la célula (añada/retire masa). Verifique que weight_kg cambia coherentemente.
– Soplar ligeramente o tocar el sensor SHT31 debe alterar humedad/temperatura (leve).
– status debe ser “OK”; si SHT31 falla, verá “SHT31_ERR”.

7) Estabilidad:
– Observación de varios minutos: el peso debe fluctuar poco (ruido bajo). Si hay ruido visible:
– Aumente hx711Averages: “a 15”.
– Aumente samplePeriodMs: “p 10000”.

Troubleshooting (errores típicos y solución)

1) XBee no se asocia (AI != 0x00 en XCTU):
– Causa: PAN ID distinto, canal distinto, potencia insuficiente, antena mal, distancia excesiva.
– Solución: Asegure ID idéntico, AP=0 en ambos, BD=9600, CE=1 en el coordinador, JV=1 en el router, reinicie el nodo. Acerque los módulos. Compruebe alimentación estable en el Shield.

2) No llega nada al coordinador, pero el UNO imprime por USB:
– Causa: Baud rate en XBee distinto (p.ej., 115200 vs 9600), o SoftwareSerial en pines diferentes a los del Shield.
– Solución: Fije BD=9600 en XCTU para ambos módulos. Verifique que el Shield enruta DIN/DOUT a D2/D3 (jumpers). Si no, ajuste el sketch a los pines reales del Shield.

3) Reseteos aleatorios del UNO al transmitir:
– Causa: Caídas de tensión por picos de consumo del XBee.
– Solución: Use Shield XBee con regulador de calidad, fuente USB/5V estable. Añada condensador de 100 µF–470 µF cerca del XBee. Evite alimentar XBee desde el pin 3.3V del UNO sin Shield.

4) Lecturas de peso negativas o saturadas:
– Causa: Cables A+/A- invertidos o mala conexión en HX711; factor de calibración no ajustado.
– Solución: Revise polaridad A+/A-, apriete terminales. Tare (“t”) con la plataforma descargada. Ajuste factor con “c ”.

5) SHT31 no responde (status=SHT31_ERR):
– Causa: Dirección I2C distinta (0x45), cableado invertido SDA/SCL, ausencia de alimentación.
– Solución: Compruebe A4=SDA, A5=SCL, GND común, VIN=5V (o 3.3V según breakout). Si su placa usa 0x45, modifique sht31.begin(0x45).

6) La consola USB muestra caracteres, pero el coordinador (XBee) recibe basura:
– Causa: Confusión entre USB (115200) y XBee (9600).
– Solución: Recuerde que USB va a 115200; Zigbee (XBee) va a 9600. Ajuste baud del receptor al del XBee.

7) arduino-cli upload falla con “permission denied” o no encuentra el puerto:
– Causa: En Linux faltan permisos de dialout; puerto incorrecto.
– Solución: Agregue su usuario a “dialout”: sudo usermod -a -G dialout $USER; reabra sesión. Use arduino-cli board list para confirmar /dev/ttyACM0.

8) Ruido excesivo en medida de peso:
– Causa: Montaje mecánico con vibraciones o célula sin pre-carga; interferencias.
– Solución: Aisle vibraciones, fije bien la célula, use promediado HX711 (a 10–15), incremente periodo, añada filtro de media móvil en firmware.

Mejoras y variantes

  • Sincronización temporal real:
  • Añada un RTC (DS3231) por I2C para timestamp con fecha/hora real.
  • Baja potencia:
  • Migrar a alimentación por batería + panel solar. Use modos de sleep del ATmega328P y despierte por watchdog para muestrear cada 1–5 min. El XBee puede configurarse con ciclos de sueño (SP/ST) y rutas “pin sleep” si el diseño lo permite.
  • Fiabilidad de enlace:
  • Cambie a API Mode (AP=1/2) con tramas ZB Transmit; añade ACK, reintentos y direccionamiento explícito. En ese caso, valore usar una librería XBee específica para Arduino o construir frames manualmente.
  • Gestión de eventos:
  • Transmitir solo si variación de peso supera un umbral (delta) o cada N minutos, para ahorrar energía/ancho de banda.
  • Integridad de datos:
  • Añada CRC al final de la línea CSV (p.ej., CRC-8/16) y validación en el receptor.
  • Multisensor:
  • Añada sensor de peso adicional (doble viga) para detectar desplazamientos. Integre sensores de entrada/salida (IR) para conteo de abejas correlacionando con variaciones de peso.
  • Envoltorio y condiciones ambientales:
  • Diseñar caja estanca con desecante y respiradero para el SHT31 (membrana PTFE), y pasacables adecuados para la célula de carga.
  • Backend:
  • En el PC, reenviar datos a un broker MQTT o base de datos (InfluxDB/TimescaleDB), y dashboard (Grafana).

Checklist de verificación (marque cada ítem)

  • [ ] He instalado Arduino CLI 0.35.3 y el core arduino:avr@1.8.6.
  • [ ] He instalado las librerías exactas: HX711@0.7.5, Adafruit SHT31 Library@2.2.1, Adafruit BusIO@1.14.5.
  • [ ] He creado el sketch en ~/projects/zigbee_beehive_weight_sensing/ con el código proporcionado.
  • [ ] He cableado HX711 (DOUT→D4, SCK→D5), SHT31 (SDA→A4, SCL→A5), XBee (DOUT→D2, DIN→D3) mediante Shield.
  • [ ] He configurado los XBee en XCTU 6.5.10: Coordinador AT (CE=1), Router AT (DH/DL=addr del Coordinador), ambos BD=9600, PAN ID igual.
  • [ ] He compilado y subido el sketch al UNO con FQBN arduino:avr:uno.
  • [ ] En el monitor USB (115200) veo ayuda y líneas CSV periódicas.
  • [ ] He realizado tare (“t”) y calibración (“c ”) con un peso patrón, y guardado en EEPROM (“w”).
  • [ ] En el PC, con XCTU o Python (pyserial 3.5), recibo las líneas CSV desde el XBee coordinador.
  • [ ] Las lecturas de peso y SHT31 varían como se espera cuando cambio la carga o altero el entorno.

Apéndice: Notas de diseño para zigbee-beehive-weight-sensing

  • Diseño mecánico:
  • La célula de carga debe trabajar en su eje designado, con pre-carga mínima y rigidez lateral. Cualquier deformación lateral introduce offset y ruido.
  • Escala y calibración:
  • Use al menos dos puntos de calibración (0 kg y peso patrón cercano al rango operativo típico) para estimar linealidad.
  • Inmunidad:
  • Mantenga cables de señal de la célula retorcidos y cortos; separe del módulo XBee para minimizar acoplo RF.
  • Seguridad energética:
  • Si usa batería, añada protección contra sobredescarga y medición de voltaje (divisor resistivo a una entrada analógica, con muestreo ocasional).

Con este caso práctico, ha construido un nodo de sensado de peso para colmena con ENV por Zigbee, empleando exactamente Arduino Uno R3 + XBee Zigbee S2C (EM357) + HX711 + SHT31, compilado con Arduino CLI y verificado extremo a extremo con un coordinador XBee en el PC.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión de Ubuntu recomendada para el sistema operativo?




Pregunta 2: ¿Qué herramienta se usa para la configuración de los módulos XBee?




Pregunta 3: ¿Qué versión de Python se menciona para la validación en PC?




Pregunta 4: ¿Cuál es la librería de Arduino para el sensor de peso?




Pregunta 5: ¿Qué tipo de célula de carga se menciona en el artículo?




Pregunta 6: ¿Qué adaptador se sugiere para el coordinador en el PC?




Pregunta 7: ¿Qué versión de la herramienta Arduino CLI se menciona?




Pregunta 8: ¿Cuál es el FQBN para Arduino Uno?




Pregunta 9: ¿Qué tipo de regulador se menciona para el Shield XBee?




Pregunta 10: ¿Qué método se usa para validar la recepción en Python?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: FFT vibraciones RS485 con Arduino y ADXL355

Caso práctico: FFT vibraciones RS485 con Arduino y ADXL355 — hero

Objetivo y caso de uso

Qué construirás: Un monitor de vibraciones FFT robusto utilizando Arduino Mega 2560 y ADXL355 para la transmisión de datos en tiempo real a través de RS-485.

Para qué sirve

  • Monitoreo de vibraciones en maquinaria industrial para detectar fallos.
  • Control de calidad en procesos de manufactura mediante análisis de vibraciones.
  • Aplicaciones en mantenimiento predictivo para evitar paradas no programadas.
  • Integración en sistemas de automatización para la supervisión remota de equipos.

Resultado esperado

  • Transmisión de datos de vibración en tiempo real con latencias menores a 100 ms.
  • Frecuencia de muestreo de vibraciones a 1 kHz para análisis FFT.
  • Mensajes de alerta enviados a través de RS-485 al detectar vibraciones anómalas.
  • Capacidad de enviar hasta 10 paquetes de datos por segundo a través de la red.

Público objetivo: Ingenieros y técnicos en automatización; Nivel: Avanzado

Arquitectura/flujo: Arduino Mega 2560 <-> ADXL355 <-> W5500 <-> RS-485

Nivel: Avanzado

Prerrequisitos

Sistema operativo y herramientas

  • Sistema operativo base (elige uno y mantén coherencia):
  • Linux: Ubuntu 22.04 LTS (Jammy) x86_64
  • Toolchain de Arduino (CLI, no GUI):
  • Arduino CLI v0.35.3 (linux-amd64)
  • Core AVR: arduino:avr@1.8.6
  • Librerías Arduino:
    • Ethernet@2.0.2 (para W5500)
    • arduinoFFT@1.6.0
    • SPI (incluida en el core)
  • Python 3.10 (para validación opcional) con:
  • pyserial==3.5
  • Adaptador USB–RS485 (para validación del bus RS485)

Permisos y preparación del entorno (Linux)

  • Añade tu usuario a dialout para acceso serie:
  • sudo usermod -aG dialout «$USER»
  • Cierra sesión y vuelve a entrar.
  • Directorio de trabajo limpio (por ejemplo, $HOME/proyectos/fft-vibration-monitor-rs485).

Red local

  • Red IPv4 básica con rango 192.168.1.0/24 (o adapta IP estática en el código).
  • Sin servidor DHCP estrictamente necesario si usas IP estática.

Materiales

  • Placa principal: Arduino Mega 2560 (ATmega2560).
  • Shield de red: Ethernet Shield W5500 (compatibilidad Arduino oficial, CS en D10).
  • Acelerómetro triaxial: ADXL355 (interfaz SPI, alimentación 3.3 V).
  • Transceptor RS485: MAX485 (modo half-duplex).
  • Nivelador de lógica bidireccional 5 V ↔ 3.3 V para SPI del ADXL355 (p. ej., TXB0104 o módulo BSS138 de 4 canales).
  • Resistencias y pasivos:
  • Terminación RS485: 120 Ω (colocar en el extremo de la línea, cerca del MAX485 si es fin de línea).
  • Resistencias de polarización (bias) RS485 en el bus (si tu red no las tiene): típicamente 680 Ω–1 kΩ entre A–Vcc y B–GND en un único punto.
  • Fuente de alimentación estable 5 V para Arduino (USB o externa) y 3.3 V para el ADXL355 (puede provenir del 3.3 V del Mega o del Shield; verificar capacidad de corriente).
  • Cables Dupont y cable par trenzado para la línea RS485 (A/B).
  • Adaptador USB–RS485 para el PC (para validación).
  • Opcional: base o soporte para el sensor y un pequeño motor o vibrador para generar vibraciones reproducibles.

Nota: El conjunto es exactamente “Arduino Mega 2560 + Ethernet Shield W5500 + ADXL355 + MAX485” y toda la guía asume estos cuatro elementos.

Preparación y conexión

Reglas generales de cableado

  • Mantén GND común entre todos los módulos.
  • El ADXL355 es 3.3 V-only. Nunca apliques 5 V a sus pines de lógica. Usa nivelador para MOSI, SCK y CS. La línea MISO del ADXL355 a 3.3 V suele ser interpretada como HIGH por el Mega, pero es buena práctica encaminarla a través del nivelador si el módulo lo requiere.
  • Todos los dispositivos SPI comparten SCK/MOSI/MISO; cada uno debe tener su propia línea CS (Chip Select). Asegúrate de poner en HIGH los CS de los dispositivos que no estés usando en cada transacción.
  • El Ethernet Shield W5500 usa el bus SPI por el conector ICSP y CS en D10. La SD del shield usa CS en D4 (mantenla en HIGH si no se usa).
  • RS485 (MAX485) es half-duplex: controla las líneas DE/RE con un pin digital para alternar transmisión/recepción.

Mapa de pines y conexiones

Tabla de cableado resumido:

Módulo Señal Arduino Mega 2560 Notas
W5500 (Shield) SPI ICSP (SCK/MISO/MOSI) Se conecta por el header ICSP del Shield
W5500 (Shield) CS D10 Mantener HIGH cuando SPI se use con otros dispositivos
W5500 (Shield) SD CS D4 Mantener HIGH si no se usa la SD
ADXL355 (SPI) VCC 3.3 V Alimentación 3.3 V
ADXL355 (SPI) GND GND Tierra común
ADXL355 (SPI) SCK D52 (SCK) ↔ nivelador SPI compartido, va al nivelador hacia el sensor
ADXL355 (SPI) MOSI D51 (MOSI) ↔ nivelador SPI compartido, 5 V→3.3 V
ADXL355 (SPI) MISO D50 (MISO) (3.3 V) 3.3 V suele ser aceptado; opcional nivelador
ADXL355 (SPI) CS D7 ↔ nivelador CS dedicado para el ADXL355
ADXL355 (INT) DRDY D3 (INT1) Señal de “data ready” (opcional pero recomendable)
MAX485 VCC 5 V Alimentación del transceptor
MAX485 GND GND Tierra común
MAX485 RO (Receiver Out) D19 (RX1) UART1 RX del Mega
MAX485 DI (Driver In) D18 (TX1) UART1 TX del Mega
MAX485 /RE y DE D2 (control) Une /RE y DE, controla con D2
MAX485 A/B Línea RS485 Conectar a bus y poner 120 Ω si eres extremo
Arduino Mega USB PC Para cargar firmware y depurar por Serial

Notas avanzadas:
– Si usas DRDY del ADXL355, podrás muestrear con jitter mínimo y exactitud de ODR (muy recomendable para FFT).
– Mantén los cables SPI cortos y ordenados para reducir EMI.
– Coloca el ADXL355 firmemente sobre la estructura cuyas vibraciones deseas medir (acoplamiento mecánico firme).

Código completo (C++ para Arduino Mega 2560)

A continuación, un sketch monolítico que:
– Inicializa W5500 con IP estática.
– Inicializa el ADXL355 en SPI (modo medición, rango ±2 g).
– Toma 256 muestras a 1 kHz del eje Z (opcionalmente por DRDY).
– Calcula FFT con arduinoFFT y obtiene picos dominantes.
– Expone los resultados por RS485 (comandos de texto) y por HTTP (endpoint /status).
– Evita conflictos SPI con selección adecuada de CS.

Características del protocolo RS485:
– Velocidad: 115200 8N1 en Serial1 (pines 18/19).
– Control de dirección (D2): HIGH para transmitir, LOW para recibir.
– Comandos (terminados en ‘
‘):
– ID?
– GET:PEAKS
– GET:RMS
– GET:FFT (devuelve magnitudes de N/2 bins como CSV reducido, opcional)

Bloque 1/2 – Sketch principal:

/*
  fft-vibration-monitor-rs485.ino
  Dispositivo: Arduino Mega 2560 + Ethernet Shield W5500 + ADXL355 + MAX485
  Toolchain: Arduino CLI v0.35.3, core arduino:avr@1.8.6
  Librerías: Ethernet@2.0.2, arduinoFFT@1.6.0, SPI (core)
*/

#include <SPI.h>
#include <Ethernet.h>
#include <arduinoFFT.h>

// ------------------------ Configuración de pines ------------------------
static const uint8_t PIN_CS_W5500   = 10; // CS Ethernet
static const uint8_t PIN_CS_SD      = 4;  // CS SD en el Shield
static const uint8_t PIN_CS_ADXL    = 7;  // CS del ADXL355
static const uint8_t PIN_ADXL_DRDY  = 3;  // DRDY -> INT1 (opcional)
static const uint8_t PIN_RS485_DIR  = 2;  // DE y /RE del MAX485 unidos -> D2

// ------------------------ Red (Ethernet W5500) --------------------------
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0x01 };
IPAddress ip(192, 168, 1, 177);
EthernetServer server(80);

// ------------------------ ADXL355 (SPI) ---------------------------------
// Registro y constantes (ver datasheet ADXL355)
#define ADXL355_REG_DEVID_AD   0x00
#define ADXL355_REG_DEVID_MST  0x01
#define ADXL355_REG_PARTID     0x02
#define ADXL355_REG_REVID      0x03
#define ADXL355_REG_STATUS     0x04
#define ADXL355_REG_TEMP2      0x06
#define ADXL355_REG_TEMP1      0x07
#define ADXL355_REG_XDATA3     0x08
#define ADXL355_REG_XDATA2     0x09
#define ADXL355_REG_XDATA1     0x0A
#define ADXL355_REG_YDATA3     0x0B
#define ADXL355_REG_YDATA2     0x0C
#define ADXL355_REG_YDATA1     0x0D
#define ADXL355_REG_ZDATA3     0x0E
#define ADXL355_REG_ZDATA2     0x0F
#define ADXL355_REG_ZDATA1     0x10
#define ADXL355_REG_FILTER     0x28
#define ADXL355_REG_RANGE      0x2C
#define ADXL355_REG_POWER_CTL  0x2D
#define ADXL355_REG_RESET      0x2F

// Modo SPI: CPOL=0, CPHA=0 (Mode 0), MSB first
SPISettings spiADXL(5000000, MSBFIRST, SPI_MODE0); // 5 MHz (ajustable)

// Escala (LSB/g) aproximada del ADXL355 en ±2g (ver datasheet)
static const float ADXL355_LSB_PER_G = 256000.0f;

// ------------------------ FFT y muestreo --------------------------------
static const uint16_t FS_HZ      = 1000;  // Frecuencia de muestreo efectiva
static const uint16_t N_SAMPLES  = 256;   // Longitud de la FFT (potencia de 2)
static const float    INV_FS     = 1.0f / FS_HZ;

double vReal[N_SAMPLES];
double vImag[N_SAMPLES];

arduinoFFT FFT = arduinoFFT(vReal, vImag, N_SAMPLES, FS_HZ);

// Buffer de adquisición
volatile uint16_t sampleIndex = 0;
volatile bool bufferReady = false;
volatile float bufZ[N_SAMPLES]; // Aceleración (g) eje Z

// Métricas
volatile float lastRMS = 0.0f;
volatile float lastPeakFreq = 0.0f;

// Resultados de picos (para respuesta)
static const uint8_t NUM_TOP_PEAKS = 8;
float topFreq[NUM_TOP_PEAKS];
float topMag[NUM_TOP_PEAKS];

// ------------------------ Utilidades SPI/CS -----------------------------
inline void csHighAll() {
  digitalWrite(PIN_CS_W5500, HIGH);
  digitalWrite(PIN_CS_SD, HIGH);
  digitalWrite(PIN_CS_ADXL, HIGH);
}

uint8_t adxl355_read8(uint8_t reg) {
  uint8_t val;
  csHighAll();
  digitalWrite(PIN_CS_ADXL, LOW);
  SPI.beginTransaction(spiADXL);
  // Lectura: bit 7 = 1 indica lectura, dirección en bits 6..0
  SPI.transfer(0x80 | (reg & 0x7F));
  val = SPI.transfer(0x00);
  SPI.endTransaction();
  digitalWrite(PIN_CS_ADXL, HIGH);
  return val;
}

void adxl355_write8(uint8_t reg, uint8_t val) {
  csHighAll();
  digitalWrite(PIN_CS_ADXL, LOW);
  SPI.beginTransaction(spiADXL);
  // Escritura: bit 7 = 0
  SPI.transfer(reg & 0x7F);
  SPI.transfer(val);
  SPI.endTransaction();
  digitalWrite(PIN_CS_ADXL, HIGH);
}

int32_t adxl355_read20(uint8_t regMSB) {
  // Lee 20 bits firmados (en 3 bytes, donde los 4 bits LSB del tercer byte son significativos)
  uint8_t b3, b2, b1;
  int32_t raw = 0;
  csHighAll();
  digitalWrite(PIN_CS_ADXL, LOW);
  SPI.beginTransaction(spiADXL);
  SPI.transfer(0x80 | (regMSB & 0x7F)); // dirección de XDATA3/YDATA3/ZDATA3
  b3 = SPI.transfer(0x00);
  b2 = SPI.transfer(0x00);
  b1 = SPI.transfer(0x00);
  SPI.endTransaction();
  digitalWrite(PIN_CS_ADXL, HIGH);

  raw = ((int32_t)b3 << 12) | ((int32_t)b2 << 4) | ((b1 >> 4) & 0x0F);
  // Extensión de signo de 20 bits
  if (raw & 0x80000) {
    raw |= 0xFFF00000;
  }
  return raw;
}

bool adxl355_init() {
  // Verifica IDs
  uint8_t devid_ad  = adxl355_read8(ADXL355_REG_DEVID_AD);
  uint8_t devid_mst = adxl355_read8(ADXL355_REG_DEVID_MST);
  uint8_t partid    = adxl355_read8(ADXL355_REG_PARTID);
  // Valores típicos esperados: 0xAD, 0x1D, 0xED
  if (devid_ad != 0xAD || devid_mst != 0x1D || partid != 0xED) {
    return false;
  }

  // Reset suave (opcional)
  adxl355_write8(ADXL355_REG_RESET, 0x52); // Key 'R'

  delay(20);

  // Standby para configurar (bit 0 de POWER_CTL = 0)
  // Según datasheet, POWER_CTL[0]=0 -> Standby, [0]=1 -> Measurement
  uint8_t pwr = adxl355_read8(ADXL355_REG_POWER_CTL);
  pwr &= ~0x01; // asegurar Standby
  adxl355_write8(ADXL355_REG_POWER_CTL, pwr);

  // Rango ±2g (ver datasheet: RANGE bits 1:0 seleccionan rango)
  // 0x01: ±2g (según hoja de datos; validar si tu módulo usa diferente mapeo)
  adxl355_write8(ADXL355_REG_RANGE, 0x01);

  // ODR 1000 Hz (aprox.). En ADXL355_REG_FILTER, bits 3:0 seleccionan ODR/LPF.
  // Un valor típico para ~1 kHz es 0x05 (consultar tablas en hoja de datos).
  // Ajusta si necesitas ODR preciso.
  adxl355_write8(ADXL355_REG_FILTER, 0x05);

  // Measurement mode
  pwr = adxl355_read8(ADXL355_REG_POWER_CTL);
  pwr |= 0x01; // bit 0 a 1 -> Measurement
  adxl355_write8(ADXL355_REG_POWER_CTL, pwr);

  delay(10);
  return true;
}

float adxl355_readZ_g() {
  int32_t raw = adxl355_read20(ADXL355_REG_ZDATA3);
  // Conversión a g (aprox.)
  return ((float)raw) / ADXL355_LSB_PER_G;
}

// ------------------------ RS485 (Serial1) -------------------------------
void rs485_setRx() { digitalWrite(PIN_RS485_DIR, LOW);  }
void rs485_setTx() { digitalWrite(PIN_RS485_DIR, HIGH); }
void rs485_println(const String &s) {
  rs485_setTx();
  Serial1.print(s);
  Serial1.print('\n');
  Serial1.flush();
  rs485_setRx();
}

// ------------------------ Temporización de muestreo ---------------------
void setupTimer1_1kHz() {
  // Timer1 CTC a 1 kHz: f_clk = 16 MHz, prescaler 1, OCR1A = 15999
  noInterrupts();
  TCCR1A = 0;
  TCCR1B = 0;
  TCNT1  = 0;
  OCR1A = 15999; // 16e6 / 1e3 - 1
  TCCR1B |= (1 << WGM12); // CTC
  TCCR1B |= (1 << CS10);  // prescaler 1
  TIMSK1 |= (1 << OCIE1A);
  interrupts();
}

ISR(TIMER1_COMPA_vect) {
  if (bufferReady) return; // espera a que procesen
  // Lectura directa del eje Z a ~1 kHz
  float z_g = adxl355_readZ_g();
  bufZ[sampleIndex] = z_g;
  sampleIndex++;
  if (sampleIndex >= N_SAMPLES) {
    sampleIndex = 0;
    bufferReady = true;
  }
}

// ------------------------ Procesamiento FFT -----------------------------
void computeFFTAndMetrics() {
  // Copiar el buffer a vReal/vImag y aplicar ventana Hann
  for (uint16_t i = 0; i < N_SAMPLES; i++) {
    double w = 0.5 * (1.0 - cos(2.0 * PI * i / (N_SAMPLES - 1)));
    vReal[i] = (double)bufZ[i] * w;
    vImag[i] = 0.0;
  }

  // RMS (dominio tiempo)
  double sum2 = 0.0;
  for (uint16_t i = 0; i < N_SAMPLES; i++) sum2 += vReal[i] * vReal[i];
  lastRMS = sqrt(sum2 / N_SAMPLES);

  // FFT
  FFT.windowing(vReal, N_SAMPLES, FFT_WIN_TYP_RECTANGLE, FFT_FORWARD); // ya aplicamos Hann, pero dejamos sin ventana aquí
  FFT.compute(vReal, vImag, N_SAMPLES, FFT_FORWARD);
  FFT.complexToMagnitude(vReal, vImag, N_SAMPLES);

  // Encontrar picos en 0..Fs/2
  // Ignora bin 0 (DC)
  uint16_t startBin = 1;
  uint16_t endBin = (N_SAMPLES / 2) - 1;

  // Inicializa arrays de top N picos
  for (uint8_t k = 0; k < NUM_TOP_PEAKS; k++) {
    topFreq[k] = 0.0f;
    topMag[k]  = 0.0f;
  }

  // Búsqueda simple de picos
  double maxMag = 0.0;
  uint16_t maxBin = 0;

  for (uint16_t bin = startBin; bin <= endBin; bin++) {
    double mag = vReal[bin];
    // Peak global
    if (mag > maxMag) {
      maxMag = mag;
      maxBin = bin;
    }
    // Inserción ordenada en top N
    for (uint8_t k = 0; k < NUM_TOP_PEAKS; k++) {
      if (mag > topMag[k]) {
        // Desplaza hacia abajo
        for (int8_t j = NUM_TOP_PEAKS - 1; j > (int8_t)k; j--) {
          topMag[j]  = topMag[j - 1];
          topFreq[j] = topFreq[j - 1];
        }
        topMag[k]  = mag;
        topFreq[k] = (float)bin * ((float)FS_HZ / (float)N_SAMPLES);
        break;
      }
    }
  }

  lastPeakFreq = (float)maxBin * ((float)FS_HZ / (float)N_SAMPLES);
}

// ------------------------ HTTP /status ----------------------------------
void handleHttpClient(EthernetClient &client) {
  // Lectura simple de la primera línea
  String req = client.readStringUntil('\n');
  if (req.indexOf("GET /status") >= 0 || req.indexOf("GET / ") >= 0) {
    // Respuesta JSON simple
    String body = "{";
    body += "\"device\":\"fft-vibration-monitor-rs485\",";
    body += "\"board\":\"Arduino Mega 2560\",";
    body += "\"fs\":" + String(FS_HZ) + ",";
    body += "\"n\":" + String(N_SAMPLES) + ",";
    body += "\"rms_g\":" + String(lastRMS, 6) + ",";
    body += "\"peak_freq_hz\":" + String(lastPeakFreq, 2) + ",";
    body += "\"top_peaks\":[";
    for (uint8_t k = 0; k < NUM_TOP_PEAKS; k++) {
      body += "{\"f\":" + String(topFreq[k], 2) + ",\"a\":" + String(topMag[k], 6) + "}";
      if (k < NUM_TOP_PEAKS - 1) body += ",";
    }
    body += "]";
    body += "}\n";

    client.println("HTTP/1.1 200 OK");
    client.println("Content-Type: application/json");
    client.print("Content-Length: ");
    client.println(body.length());
    client.println("Connection: close");
    client.println();
    client.print(body);
  } else {
    client.println("HTTP/1.1 404 Not Found");
    client.println("Content-Length: 0");
    client.println("Connection: close");
    client.println();
  }
}

// ------------------------ Comandos RS485 --------------------------------
String cmdBuf;
void handleRS485() {
  while (Serial1.available() > 0) {
    char c = (char)Serial1.read();
    if (c == '\r') continue;
    if (c == '\n') {
      String line = cmdBuf;
      cmdBuf = "";
      line.trim();
      if (line == "ID?") {
        rs485_println("ID,ArduinoMega2560,ADXL355,W5500,MAX485");
      } else if (line == "GET:RMS") {
        rs485_println("RMS_G," + String(lastRMS, 6));
      } else if (line == "GET:PEAKS") {
        // Responde pares f,a separados por punto y coma
        String resp = "PEAKS";
        for (uint8_t k = 0; k < NUM_TOP_PEAKS; k++) {
          resp += ",";
          resp += String(topFreq[k], 2);
          resp += ",";
          resp += String(topMag[k], 6);
        }
        rs485_println(resp);
      } else if (line == "GET:FFT") {
        // Envía magnitudes de 0..N/2-1 (corta si quieres ahorrar ancho de banda)
        rs485_println("FFT,Fs=" + String(FS_HZ) + ",N=" + String(N_SAMPLES));
        String row = "";
        for (uint16_t bin = 0; bin < (N_SAMPLES / 2); bin++) {
          row += String(vReal[bin], 6);
          if (bin < (N_SAMPLES / 2) - 1) row += ",";
        }
        rs485_println(row);
      } else {
        rs485_println("ERR,UNKNOWN_CMD");
      }
    } else {
      if (cmdBuf.length() < 128) cmdBuf += c;
    }
  }
}

// ------------------------ Setup / Loop ----------------------------------
void setup() {
  pinMode(PIN_CS_W5500, OUTPUT);
  pinMode(PIN_CS_SD, OUTPUT);
  pinMode(PIN_CS_ADXL, OUTPUT);
  pinMode(PIN_RS485_DIR, OUTPUT);
  pinMode(PIN_ADXL_DRDY, INPUT); // opcional si se conecta DRDY

  csHighAll();
  rs485_setRx();

  Serial.begin(115200);   // Depuración por USB
  Serial1.begin(115200);  // RS485 (MAX485)

  // SPI
  SPI.begin();

  // Ethernet
  Ethernet.init(PIN_CS_W5500);
  Ethernet.begin(mac, ip);
  delay(100);
  server.begin();

  Serial.print("IP: ");
  Serial.println(Ethernet.localIP());

  // Inicializa ADXL355
  if (!adxl355_init()) {
    Serial.println("Error: ADXL355 no detectado (IDs no coinciden).");
  } else {
    Serial.println("ADXL355 OK");
  }

  // Timer de muestreo (1 kHz)
  setupTimer1_1kHz();

  Serial.println("Setup completo.");
}

void loop() {
  // Procesar buffer si listo
  if (bufferReady) {
    noInterrupts();
    bufferReady = false;
    interrupts();
    computeFFTAndMetrics();
  }

  // RS485
  handleRS485();

  // HTTP
  EthernetClient client = server.available();
  if (client) {
    // Esperar datos y atender
    unsigned long t0 = millis();
    while (client.connected() && millis() - t0 < 100) {
      if (client.available()) {
        handleHttpClient(client);
        break;
      }
    }
    delay(1);
    client.stop();
  }
}

Breve explicación de partes clave:
– Selección de CS: csHighAll asegura que solo un dispositivo SPI esté activo a la vez. El W5500 y la SD del shield quedan deseleccionados durante transacciones con el ADXL355.
– adxl355_read20: el ADXL355 entrega 20 bits por eje en 3 bytes; se realiza sign-extend apropiado a 32 bits.
– ODR: se configura en 0x05 para obtener ~1 kHz; si necesitas frecuencias exactas o diferentes, consulta la tabla de ODR/LPF del datasheet (puedes ajustar en ADXL355_REG_FILTER).
– Timer1: genera una IRQ a 1 kHz para muestrear de forma estable sin jitter del loop.
– FFT: se aplica ventana Hann previa al cálculo para reducir leakage; se busca el pico global y se extraen los top N picos.
– RS485: se usa D2 para conmutar el MAX485 entre TX y RX; se adoptan comandos de texto sencillos.
– Ethernet: expone /status con JSON mínimo para supervisión remota.

Bloque 2/2 – Script de validación (Python, PC) para RS485:

# validate_rs485.py
# Requiere: Python 3.10 + pyserial==3.5
# Uso:
#   python3 validate_rs485.py /dev/ttyUSB0 115200
# Conecta el adaptador USB-RS485 al bus A/B junto con el MAX485 del Mega.

import sys
import serial
import time

def send_cmd(ser, cmd):
    ser.write((cmd + "\n").encode("ascii"))
    ser.flush()

def read_line(ser, timeout=2.0):
    ser.timeout = timeout
    line = ser.readline().decode("ascii", errors="ignore").strip()
    return line

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Uso: python3 validate_rs485.py <puerto> <baud>")
        sys.exit(1)

    port = sys.argv[1]
    baud = int(sys.argv[2])

    with serial.Serial(port, baudrate=baud, bytesize=8, parity='N', stopbits=1) as ser:
        time.sleep(0.2)
        send_cmd(ser, "ID?")
        print("-> ID?")
        print("<- " + read_line(ser))

        send_cmd(ser, "GET:RMS")
        print("-> GET:RMS")
        print("<- " + read_line(ser))

        send_cmd(ser, "GET:PEAKS")
        print("-> GET:PEAKS")
        print("<- " + read_line(ser))

        send_cmd(ser, "GET:FFT")
        print("-> GET:FFT (cabecera)")
        print("<- " + read_line(ser))
        print("-> GET:FFT (datos)")
        print("<- " + read_line(ser))

Compilación, carga y ejecución (Arduino CLI)

Comandos exactos y ordenados:

1) Instala Arduino CLI v0.35.3 (si no lo tienes)
– Linux (x86_64):
– wget https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz -O /tmp/arduino-cli.tar.gz
– sudo tar -xzf /tmp/arduino-cli.tar.gz -C /usr/local/bin –strip-components=1 arduino-cli

2) Verifica versión:
– arduino-cli version
– Debe mostrar: arduino-cli Version: 0.35.3

3) Prepara el core AVR:
– arduino-cli core update-index
– arduino-cli core install arduino:avr@1.8.6

4) Prepara un directorio de sketch:
– mkdir -p $HOME/proyectos/fft-vibration-monitor-rs485
– cd $HOME/proyectos/fft-vibration-monitor-rs485
– Crea el archivo fft-vibration-monitor-rs485.ino con el código C++ anterior.

5) Instala librerías exactas:
– arduino-cli lib install «Ethernet@2.0.2»
– arduino-cli lib install «arduinoFFT@1.6.0»

6) Identifica el puerto serie del Mega:
– arduino-cli board list
– Localiza tu Arduino Mega 2560 y anota el puerto (ej.: /dev/ttyACM0)

7) Compila para Arduino Mega 2560 (FQBN: arduino:avr:mega):
– arduino-cli compile –fqbn arduino:avr:mega –warnings all –build-path build .

8) Sube el firmware:
– arduino-cli upload -p /dev/ttyACM0 –fqbn arduino:avr:mega –input-dir build

9) Monitorea logs (opcional, USB a 115200):
– arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200

10) Validación RS485 desde PC (con adaptador USB–RS485):
– pip3 install pyserial==3.5
– python3 validate_rs485.py /dev/ttyUSB0 115200

11) Validación HTTP:
– curl -s http://192.168.1.177/status | jq .
– Si no tienes jq, usa:
– curl -s http://192.168.1.177/status

Validación paso a paso

1) Verificación de IDs del ADXL355:
– Abre el monitor serie por USB (115200).
– Al iniciar, deberías ver:
– “IP: 192.168.1.177”
– “ADXL355 OK”
– “Setup completo.”
– Si aparece “ADXL355 no detectado”, revisa SPI/CS y niveles lógicos.

2) Muestreo y FFT:
– El dispositivo muestrea 256 puntos a 1 kHz (ventana Hann) y calcula FFT.
– No hay UI visual, pero:
– Enviando GET:RMS por RS485, deberías recibir valores ~0.005–0.05 g si el sensor está quieto (ruido térmico + vibración ambiente).
– Enviando GET:PEAKS, con el sensor quieto, el pico dominante puede estar cerca de DC; con un motor pequeño o golpe seco, verás picos a su frecuencia fundamental y armónicos.

3) Prueba RS485:
– Conecta el adaptador USB–RS485 del PC al bus A/B (A→A, B→B).
– Ejecuta el script Python:
– Debes ver:
– “ID,ArduinoMega2560,ADXL355,W5500,MAX485”
– “RMS_G,0.00xxxx”
– “PEAKS, f1,a1, f2,a2, …” (8 picos)
– El comando GET:FFT devuelve cabecera y una línea larga con magnitudes.

4) Prueba HTTP /status:
– curl http://192.168.1.177/status
– Debe retornar un JSON con: device, fs, n, rms_g, peak_freq_hz y top_peaks.
– Repite la petición mientras haces vibrar el sensor; peak_freq_hz se moverá hacia la frecuencia dominante observada.

5) Validación de integridad del bus RS485:
– Si usas línea larga, instala terminación 120 Ω en ambos extremos.
– Verifica que solo exista un par de resistencias de polarización (bias) en todo el bus (en un único punto).

6) Consistencia de SPI:
– Asegúrate de que D10 y D4 estén en HIGH cuando el ADXL355 sea el dispositivo activo, y que D7 esté en HIGH cuando el W5500 sea activo. El sketch ya gestiona esto con csHighAll().

Troubleshooting

1) ADXL355 no responde (IDs incorrectos):
– Síntomas: “ADXL355 no detectado (IDs no coinciden)”.
– Causas probables:
– CS incorrecto: verifica que el ADXL355 esté en D7 y que D10 y D4 estén HIGH durante la transacción.
– Sin nivelador de lógica: si alimentas con 3.3 V y pines de 5 V sin adaptar, el sensor puede dañarse o no responder.
– Cableado SPI incorrecto (MOSI/MISO invertidos).
– Solución: verifica mapeo, nivelador, continuidad y tensiones.

2) Ethernet deja de funcionar al iniciar muestreo:
– Síntomas: /status no responde tras unos segundos.
– Causas: conflicto SPI por CS mal gestionado o ISR muy pesada.
– Solución: confirma csHighAll() antes de operar con ADXL355; reduce la frecuencia SPI si fuera necesario (p. ej., 2 MHz).

3) FFT inconsistente (picos varían mucho):
– Causas: muestreo no estable, ODR no coincide con FS, vibración insuficiente o aliasing.
– Solución:
– Ajusta ADXL355_REG_FILTER para ODR ~ FS (1 kHz).
– Usa DRDY del ADXL355 con attachInterrupt para muestreo exacto por “data ready”.
– Asegura fijación mecánica rígida del sensor (evita foam o cinta blanda).

4) RS485 responde con errores o no responde:
– Causas: sin control de dirección (DE/RE), baudrate distinto, terminación/bias deficientes.
– Solución:
– Verifica que D2 conmute DE/RE (LOW para Rx, HIGH para Tx).
– Asegura 115200 8N1 en ambos lados.
– Añade 120 Ω en extremos y bias en un único punto.

5) Medidas saturadas (g muy altos):
– Causas: rango inadecuado (±2 g) frente a vibraciones fuertes.
– Solución: cambia el rango del ADXL355 (RANGE) a ±4 g o ±8 g (ver datasheet) y actualiza el factor LSB/g.

6) Datos de FFT “planos” (todo ~0):
– Causas: lectura del eje incorrecta (registro mal, bytes mal ensamblados), CS del sensor bajo permanentemente.
– Solución:
– Verifica adxl355_read20: orden de bytes y extensión de signo.
– Comprueba que el pin CS del ADXL355 esté alto en reposo y solo bajo durante la transacción.

7) HTTP bloquea RS485 o viceversa:
– Causas: uso intensivo del loop sin gestionar tiempos; cliente HTTP no libera conexión.
– Solución:
– Mantén timeouts cortos en HTTP (como en el sketch).
– No hagas prints excesivos en Serial.
– Evita operaciones de bloqueo largas dentro del loop.

8) Ruido excesivo en espectro:
– Causas: acoplamiento mecánico pobre, cables largos, interferencias EMI.
– Solución:
– Usa cable apantallado para el sensor si la distancia lo requiere.
– Asegura masa común.
– Filtra en banda (LPF/HPF del ADXL355 vía FILTER) o aplica más promediado.

Mejoras y variantes

  • Sincronización por DRDY:
  • Conecta DRDY del ADXL355 al pin D3 y usa attachInterrupt para leer muestra justo cuando el sensor la tenga lista. Desactiva el Timer1 o úsalo como watchdog. Mejorará la coherencia temporal y la ubicación de picos en frecuencia.

  • Cambiar ventana de FFT:

  • Prueba Blackman, Hamming o Flat Top (si implementas manualmente) para diferentes compromisos entre resolución y amplitud de pico.

  • Publicación UDP/MQTT por Ethernet:

  • Añade un cliente MQTT (p. ej., PubSubClient) o UDP broadcast con las métricas (RMS, pico principal). Esto facilita integración en SCADA/IIoT.

  • Protocolo Modbus RTU por RS485:

  • Estructura registros para RMS, pico, ODR, estado, etc., y usa un stack Modbus RTU esclavo. Esto estandariza la integración.

  • Promediado espectral:

  • Realiza varios bloques de N_SAMPLES, promedia magnitudes (Welch) y reduce varianza. Aumenta estabilidad de picos.

  • Configuración remota:

  • Implementa comandos por RS485/HTTP para cambiar N_SAMPLES, FS, rango del sensor, IP estática y número de picos a reportar.

  • Ejes múltiples:

  • Procesa X/Y/Z y reporta vector RMS y picos por eje. Aumenta el costo computacional; considera N=128 por eje para mantener tiempos.

Checklist de verificación

  • [ ] Toolchain exacta instalada:
  • [ ] Arduino CLI v0.35.3
  • [ ] Core arduino:avr@1.8.6
  • [ ] Librerías: Ethernet@2.0.2, arduinoFFT@1.6.0

  • [ ] Cableado correcto y coherente:

  • [ ] W5500 en Shield con CS D10 y SD CS D4 (alto si no se usa).
  • [ ] ADXL355 a 3.3 V, SPI con nivelador y CS en D7.
  • [ ] DRDY del ADXL355 a D3 (opcional).
  • [ ] MAX485: RO→D19 (RX1), DI→D18 (TX1), DE/RE→D2, A/B al bus.

  • [ ] RS485 preparado:

  • [ ] Terminación 120 Ω en los extremos del bus.
  • [ ] Bias en un único punto (si el bus lo requiere).
  • [ ] Adaptador USB–RS485 en el PC y polaridad A/B correcta.

  • [ ] Compilación y carga:

  • [ ] arduino-cli core update-index / install realizados.
  • [ ] arduino-cli lib install con versiones exactas.
  • [ ] Compilado con FQBN arduino:avr:mega y subido sin errores.

  • [ ] Arranque correcto:

  • [ ] Monitor USB muestra IP y “ADXL355 OK”.
  • [ ] /status responde por HTTP.

  • [ ] Validación funcional:

  • [ ] GET:RMS devuelve un valor coherente (quieto vs. vibrando).
  • [ ] GET:PEAKS muestra frecuencias lógicas cuando se activa un vibrador/motor.
  • [ ] GET:FFT devuelve cabecera y datos.

  • [ ] Estabilidad espectral:

  • [ ] Picos consistentes al repetir medición.
  • [ ] Sin bloqueos al alternar RS485/HTTP.

Con este caso práctico has construido un monitor de vibraciones FFT robusto sobre RS485 utilizando exactamente el combo “Arduino Mega 2560 + Ethernet Shield W5500 + ADXL355 + MAX485”, compilado y desplegado con Arduino CLI (core arduino:avr@1.8.6), y validado tanto por RS485 como por HTTP. Esta base es extensible hacia protocolos industriales (Modbus RTU/TCP) y a técnicas de análisis espectral más avanzadas.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el sistema operativo base recomendado para el proyecto?




Pregunta 2: ¿Qué versión de Arduino CLI se debe utilizar?




Pregunta 3: ¿Qué librería se utiliza para la comunicación Ethernet en este proyecto?




Pregunta 4: ¿Cuál es el modelo del acelerómetro utilizado?




Pregunta 5: ¿Qué tipo de transceptor RS485 se menciona en los requisitos?




Pregunta 6: ¿Qué comando se debe usar para añadir un usuario al grupo 'dialout'?




Pregunta 7: ¿Qué tipo de conexión se requiere para el ADXL355?




Pregunta 8: ¿Cuál es la resistencia de terminación recomendada para RS485?




Pregunta 9: ¿Qué voltaje de alimentación se requiere para el ADXL355?




Pregunta 10: ¿Cuál es el directorio de trabajo recomendado para el proyecto?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Acceso NFC con ESP32, PN532 y e‑paper 2.9

Caso práctico: Acceso NFC con ESP32, PN532 y e‑paper 2.9 — hero

Objetivo y caso de uso

Qué construirás: Un terminal de control de acceso NFC avanzado utilizando ESP32 y PN532, que muestra información en una pantalla E-Paper de 2.9″.

Para qué sirve

  • Control de acceso en edificios mediante identificación NFC.
  • Gestión de listas de control de acceso (ACLs) para diferentes usuarios.
  • Visualización de información de acceso en tiempo real en la pantalla E-Paper.
  • Integración con sistemas de notificación mediante MQTT para alertas de acceso.

Resultado esperado

  • Tiempo de respuesta de acceso inferior a 500 ms.
  • Capacidad para gestionar hasta 1000 usuarios en la base de datos de ACLs.
  • Latencia de comunicación entre ESP32 y PN532 menor a 50 ms.
  • Actualización de la pantalla E-Paper cada vez que se registra un acceso, con un tiempo de refresco de 1 segundo.

Público objetivo: Desarrolladores avanzados; Nivel: Avanzado

Arquitectura/flujo: ESP32 <-> PN532 <-> E-Paper <-> MQTT

Nivel: Avanzado

Prerrequisitos

Sistema operativo y utilidades

  • Windows 11 23H2, macOS 14.5 (Sonoma) o Ubuntu 22.04.5 LTS (x86_64).
  • Python 3.11.6 instalado y en PATH (recomendado usar pyenv/pipx en Linux/macOS).
  • Cable micro‑USB de datos (no solo carga).

Toolchain concreta (versiones exactas)

  • PlatformIO Core 6.1.13
  • PlatformIO Platform: espressif32 @ 6.6.0
  • Framework Arduino-ESP32: 2.0.14
  • Toolchain GCC Xtensa (ESP32): 11.2.0 (xtensa-esp32-elf-gcc 11.2.0, binutils 2.37)
  • esptool.py 4.6
  • OpenOCD (para depuración opcional): 0.12.0-esp32-20230921

Comando de verificación de versiones propuestas (tras instalar PlatformIO):
– pio –version → PlatformIO Core, versión 6.1.13
– pio pkg update; pio pkg list (dentro del proyecto) → mostrará las versiones de platform/framework/toolchain usadas.
– python –version → Python 3.11.6

Controladores USB (según tu placa)

  • NodeMCU-32S suele integrar CP210x (Silicon Labs). Instalar:
  • Windows: “CP210x Universal Windows Driver” (v11 o superior).
  • macOS: no suele requerirse desde 10.15+, pero es recomendable instalar el paquete de Silicon Labs si no aparece el puerto.
  • Linux: kernel reciente incluye cp210x. Verifica permisos de /dev/ttyUSBx o /dev/tty.SLAB_USBtoUART.
  • Algunas variantes traen CH34x:
  • Windows: CH341SER.EXE (WCH).
  • Linux/macOS: soportado en kernel modernos, pero puede requerir permisos/udev.

Notas de Linux (opcional):
– Agrega una regla udev para acceso sin sudo:
– Crear /etc/udev/rules.d/99-esp32.rules con:
– SUBSYSTEM==»tty», ATTRS{idVendor}==»10c4″, ATTRS{idProduct}==»ea60″, MODE:=»0666″
– sudo udevadm control –reload-rules && sudo udevadm trigger

Materiales

  • ESP32 NodeMCU-32S (modelo exacto de placa “NodeMCU-32S”).
  • Waveshare 2.9″ E‑Paper (controlador SSD1680) + cableado Dupont hembra-hembra.
  • Módulo PN532 NFC (Waveshare o Adafruit/Genérico) con interfaz SPI habilitada.
  • Tarjetas y/o llaveros NFC tipo A (MIFARE Classic/Ultralight/NTAG).
  • Fuente de alimentación: USB 5V (PC o cargador estable de 1A); el regulador onboard del NodeMCU-32S alimentará 3V3 para PN532 y E‑Paper.
  • Opcional: protoboard, resistencias pull-up si tu PN532 lo requiere para IRQ (no usaremos IRQ en este caso), soportes/adhesivos.

Comentarios de consumo:
– Waveshare 2.9″ e‑paper: ~26 mA durante refresco activo; ~<1 mA reposo.
– PN532: 50–80 mA durante poll activo.
– NodeMCU-32S puede alimentar ambos desde 3V3 onboard con margen si la entrada 5V es estable.

Preparación y conexión

Este caso práctico usa un bus SPI compartido (VSPI del ESP32) para E‑Paper y PN532, cada uno con su propio pin CS. El SSD1680 no requiere MISO. El PN532 sí usa MISO.

  • Bus SPI (VSPI) del ESP32 NodeMCU-32S:
  • SCLK → GPIO18
  • MOSI → GPIO23
  • MISO → GPIO19 (solo PN532)
  • Pines auxiliares E‑Paper:
  • CS, DC, RST, BUSY
  • Pines PN532:
  • SS (CS)
  • (Opcional) RSTO/IRQ: no usado en este ejemplo (polling).

Tabla de mapeo de pines y alimentación:

Módulo Señal/Pin ESP32 NodeMCU-32S Notas
Waveshare 2.9″ E‑Paper (SSD1680) VCC 3V3 Alimentación 3.3V
GND GND Tierra común
DIN (MOSI) GPIO23 Datos SPI
CLK (SCLK) GPIO18 Reloj SPI
CS GPIO5 Chip Select E‑Paper
DC (Data/Command) GPIO17 Línea DC
RST GPIO16 Reset de panel
BUSY GPIO4 Estado ocupado del panel
PN532 NFC (SPI) VCC 3V3 Alimentación 3.3V (no usar 5V en lógica)
GND GND Tierra común
SCK GPIO18 Comparte bus SPI
MOSI GPIO23 Comparte bus SPI
MISO GPIO19 Entrada MISO para PN532
SS (CS) GPIO15 Chip Select PN532
LED onboard (opcional) LED GPIO2 Indicador de acceso (on: concedido; parpadeo: denegado)

Consejos:
– Usa cables cortos y firmes. Evita falsos contactos en BUSY y DC del E‑Paper, que provocan bloqueos.
– No cruces 5V con señales lógicas. Todo va a 3.3V.
– Asegura masa común entre los módulos y el ESP32.

Código completo (Arduino/ESP32 con PlatformIO) y explicación

A continuación se muestra un proyecto completo con:
– Whitelist de UIDs basada en SHA‑256 (no se guardan UIDs en claro).
– NTP para hora real vía WiFi.
– Registro de eventos en LittleFS (CSV).
– Renderizado en E‑Paper con GxEPD2 (SSD1680).
– Lógica de control de acceso (éxito/denegado) y mitigación de ghosting (refresco completo periódico).

platformio.ini

Bloquea versiones de plataforma, framework y librerías.

; platformio.ini
[env:nodemcu-32s]
platform = espressif32 @ 6.6.0
board = nodemcu-32s
framework = arduino

; Toolchain y ajustes de carga/monitor
upload_speed = 921600
monitor_speed = 115200
monitor_filters = direct, time
board_build.flash_mode = dio

; Versiones de librerías
lib_deps =
  zinggjm/GxEPD2 @ 1.5.8
  adafruit/Adafruit PN532 @ 1.3.3
  adafruit/Adafruit BusIO @ 1.14.1
  adafruit/Adafruit GFX Library @ 1.11.9

; Opcional: reducir warnings verbosos
build_flags =
  -DCORE_DEBUG_LEVEL=0

; Asegurar framework Arduino-ESP32 versión 2.0.14
platform_packages =
  framework-arduinoespressif32@~3.20014.0
  toolchain-xtensa-esp32@~11.2.0
  tool-esptoolpy@~1.40500.0

Notas:
– framework-arduinoespressif32@3.20014.0 corresponde a Arduino-ESP32 2.0.14 en nomenclatura PlatformIO.
– toolchain-xtensa-esp32 ~11.2.0 alinea con gcc 11.2.0.

src/main.cpp

#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <esp_sntp.h>
#include <FS.h>
#include <LittleFS.h>

// E-Paper (SSD1680) con GxEPD2
#include <GxEPD2_BW.h>
#include <Fonts/FreeMonoBold12pt7b.h>
#include <Fonts/FreeMonoBold9pt7b.h>

// PN532 (SPI)
#include <Adafruit_PN532.h>

// ===== Configuración WiFi/NTP =====
static const char* WIFI_SSID     = "TU_SSID";
static const char* WIFI_PASSWORD = "TU_PASSWORD";
static const char* NTP_SERVER    = "pool.ntp.org";
static const long  GMT_OFFSET    = 0;          // ajustar según zona horaria
static const int   DST_OFFSET    = 0;          // horario de verano si aplica

// ===== Pines (ESP32 NodeMCU-32S) =====
static const int PIN_EPD_CS   = 5;
static const int PIN_EPD_DC   = 17;
static const int PIN_EPD_RST  = 16;
static const int PIN_EPD_BUSY = 4;

static const int PIN_PN532_SS = 15;

static const int PIN_LED      = 2;  // LED onboard

// ===== Objetos globales =====
GxEPD2_BW<GxEPD2_290, GxEPD2_290::HEIGHT> display(GxEPD2_290(PIN_EPD_CS, PIN_EPD_DC, PIN_EPD_RST, PIN_EPD_BUSY));
Adafruit_PN532 pn532(PIN_PN532_SS);

// ===== Whitelist (hash SHA-256 hex de UIDs) =====
// Para generar hashes, ver script Python más abajo o función utilitaria.
static const char* WHITELIST_SHA256[] = {
  // Ejemplos (reemplaza con los tuyos):
  // "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  // "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
};
static const size_t WHITELIST_COUNT = sizeof(WHITELIST_SHA256)/sizeof(WHITELIST_SHA256[0]);

// ===== Utilidades =====
String bytesToHex(const uint8_t* data, size_t len) {
  static const char* hex = "0123456789abcdef";
  String out; out.reserve(len * 2);
  for (size_t i = 0; i < len; ++i) {
    out += hex[(data[i] >> 4) & 0x0F];
    out += hex[data[i] & 0x0F];
  }
  return out;
}

// SHA-256 usando mbedTLS del core ESP32
#include "mbedtls/md.h"

bool sha256_hex(const uint8_t* data, size_t len, char out_hex[65]) {
  mbedtls_md_context_t ctx;
  const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
  if (!info) return false;
  mbedtls_md_init(&ctx);
  if (mbedtls_md_setup(&ctx, info, 0) != 0) { mbedtls_md_free(&ctx); return false; }
  if (mbedtls_md_starts(&ctx) != 0) { mbedtls_md_free(&ctx); return false; }
  if (mbedtls_md_update(&ctx, data, len) != 0) { mbedtls_md_free(&ctx); return false; }
  uint8_t hash[32];
  if (mbedtls_md_finish(&ctx, hash) != 0) { mbedtls_md_free(&ctx); return false; }
  mbedtls_md_free(&ctx);
  static const char* hex = "0123456789abcdef";
  for (int i = 0; i < 32; ++i) {
    out_hex[2*i]   = hex[(hash[i] >> 4) & 0xF];
    out_hex[2*i+1] = hex[hash[i] & 0xF];
  }
  out_hex[64] = '\0';
  return true;
}

bool inWhitelist(const char* sha256_hex) {
  for (size_t i = 0; i < WHITELIST_COUNT; ++i) {
    if (strcasecmp(sha256_hex, WHITELIST_SHA256[i]) == 0) return true;
  }
  return false;
}

// Tiempo legible
String nowString() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 3000)) return String("1970-01-01 00:00:00");
  char buf[32];
  strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
  return String(buf);
}

// Registro en LittleFS
bool appendLog(const String& line) {
  if (!LittleFS.begin(true)) return false;
  File f = LittleFS.open("/access_log.csv", FILE_APPEND);
  if (!f) return false;
  bool ok = f.print(line);
  f.close();
  return ok;
}

// Renderizado en E‑Paper
void drawAccessScreen(bool granted, const String& uid_hex, const String& timestamp, bool partial = false) {
  if (partial) {
    display.setPartialWindow(0, 0, display.width(), display.height());
  } else {
    display.setFullWindow();
  }
  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);
    display.setRotation(1);
    display.setTextColor(GxEPD_BLACK);
    display.setFont(&FreeMonoBold12pt7b);

    int16_t x = 6, y = 26;
    display.setCursor(x, y);
    display.print("nfc-epaper-access-control");

    display.setFont(&FreeMonoBold9pt7b);
    y += 28;
    display.setCursor(x, y);
    display.print("UID: ");
    display.print(uid_hex);

    y += 24;
    display.setCursor(x, y);
    display.print("Time: ");
    display.print(timestamp);

    y += 30;
    display.setFont(&FreeMonoBold12pt7b);
    display.setCursor(x, y);
    if (granted) {
      display.print("ACCESS GRANTED");
    } else {
      display.print("ACCESS DENIED ");
    }

    // Pie de página
    display.setFont(&FreeMonoBold9pt7b);
    y += 28;
    display.setCursor(x, y);
    display.print("Panel: Waveshare 2.9\" (SSD1680)");
  } while (display.nextPage());
}

// Conectividad WiFi + NTP
void initWiFiNTP() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.print("[WiFi] Conectando a "); Serial.println(WIFI_SSID);
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
    Serial.print(".");
    if (millis() - t0 > 15000) break;
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("[WiFi] OK. IP: "); Serial.println(WiFi.localIP());
    configTzTime("UTC0", NTP_SERVER); // se puede ajustar con TZ
    // Espera bloqueo NTP
    struct tm timeinfo;
    for (int i = 0; i < 20; ++i) {
      if (getLocalTime(&timeinfo, 500)) break;
      delay(500);
    }
    Serial.println("[NTP] Sincronizado (si red disponible).");
  } else {
    Serial.println("[WiFi] No conectado. Continuando sin NTP.");
  }
}

// Inicialización de periféricos
void initEPaper() {
  display.init(115200);  // velocidad SPI para E‑Paper
  display.setRotation(1);
  // Primera pantalla
  drawAccessScreen(false, "----", nowString(), false);
}

void initPN532() {
  pn532.begin();
  uint32_t versiondata = pn532.getFirmwareVersion();
  if (!versiondata) {
    Serial.println("[PN532] No se detecto el PN532. Verifica cableado y SS.");
  } else {
    Serial.print("[PN532] Chip PN5"); Serial.println((versiondata>>24) & 0xFF, HEX);
    Serial.print("[PN532] Firmware: "); Serial.print((versiondata>>16) & 0xFF, DEC);
    Serial.print("."); Serial.println((versiondata>>8) & 0xFF, DEC);
    pn532.SAMConfig(); // modo normal, permite lectura pasiva
  }
}

void blinkDenied() {
  for (int i = 0; i < 3; ++i) {
    digitalWrite(PIN_LED, HIGH); delay(120);
    digitalWrite(PIN_LED, LOW); delay(120);
  }
}

void solidGranted() {
  digitalWrite(PIN_LED, HIGH); delay(800);
  digitalWrite(PIN_LED, LOW);
}

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

  Serial.println("\n[nfc-epaper-access-control] Inicio");
  initWiFiNTP();
  if (!LittleFS.begin(true)) {
    Serial.println("[FS] LittleFS montado (formateado en primera vez si era necesario).");
  }

  initEPaper();
  initPN532();

  Serial.println("[Sistema] Listo. Acerca credencial NFC tipo A al PN532.");
}

void loop() {
  static uint32_t lastFullRefresh = 0;
  static int opCount = 0;

  uint8_t uid[8];
  uint8_t uidLength = 0;

  // Lee una tarjeta (timeout corto para mantener fluidez)
  bool success = pn532.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength, 50);
  if (!success) {
    // Refresco completo cada ~60s para mitigar ghosting
    if (millis() - lastFullRefresh > 60000) {
      drawAccessScreen(false, "----", nowString(), false);
      lastFullRefresh = millis();
    }
    delay(10);
    return;
  }

  String uid_hex = bytesToHex(uid, uidLength);
  char sha_hex[65];
  if (!sha256_hex(uid, uidLength, sha_hex)) {
    Serial.println("[Crypto] Error SHA-256.");
    blinkDenied();
    return;
  }

  bool granted = inWhitelist(sha_hex);

  String ts = nowString();
  // Render parcial para respuesta rápida
  drawAccessScreen(granted, uid_hex, ts, true);
  if (granted) solidGranted(); else blinkDenied();

  // Log CSV: timestamp,uid_hex,sha256_hex,granted
  String line = ts + "," + uid_hex + "," + String(sha_hex) + "," + (granted ? "1" : "0") + "\n";
  if (!appendLog(line)) {
    Serial.println("[FS] Error escribiendo /access_log.csv");
  }

  Serial.print("[NFC] UID="); Serial.print(uid_hex);
  Serial.print(" SHA256="); Serial.print(sha_hex);
  Serial.print(" Access="); Serial.println(granted ? "GRANTED" : "DENIED");

  opCount++;
  // Forzar refresco completo cada 10 operaciones para borrar ghosting
  if (opCount % 10 == 0) {
    drawAccessScreen(granted, uid_hex, ts, false);
    lastFullRefresh = millis();
  }

  delay(600); // pequeña pausa para evitar múltiples lecturas idénticas instantáneas
}

Explicación breve de partes clave:
– Whitelist mediante SHA‑256: evita exponer UIDs en claro en firmware/FS; puedes generar los hashes desde un PC y pegarlos en WHITELIST_SHA256.
– PN532 por SPI: usa el bus VSPI compartido con el E‑Paper; cada dispositivo tiene su CS (GPIO5 para E‑Paper y GPIO15 para PN532).
– E‑Paper con GxEPD2 (SSD1680): renderiza texto con fuentes GFX. Se usan actualizaciones parciales para respuesta fluida y periódicas completas para eliminar ghosting.
– NTP via WiFi: se sincroniza hora al inicio si hay red; se usa en logs y en pantalla.
– LittleFS: almacena un CSV con los registros de accesos.
– LED onboard: feedback simple de acceso concedido/denegado.

Script auxiliar (opcional) para generar SHA‑256 de UIDs

Úsalo en tu PC para convertir UIDs (en hex) a hash SHA‑256 y pegarlos en WHITELIST_SHA256.

# uid2sha256.py
import sys, binascii, hashlib

def main():
    if len(sys.argv) < 2:
        print("Uso: python uid2sha256.py <uid_hex> [<uid_hex> ...]")
        print("Ejemplo: python uid2sha256.py 04a224b9c13280")
        sys.exit(1)
    for uid_hex in sys.argv[1:]:
        uid_hex = uid_hex.lower().strip()
        try:
            uid_bytes = binascii.unhexlify(uid_hex)
        except Exception as e:
            print(f"UID inválido: {uid_hex} ({e})")
            continue
        sha = hashlib.sha256(uid_bytes).hexdigest()
        print(f"UID={uid_hex}  SHA256={sha}")

if __name__ == "__main__":
    main()

Ejemplo:
– python uid2sha256.py 04a224b9c13280
– Copia el sha256 resultante a WHITELIST_SHA256 en main.cpp.

Compilación / flash / ejecución

Usaremos PlatformIO en modo CLI para reproducibilidad total.

1) Crear proyecto
– mkdir nfc-epaper-access-control
– cd nfc-epaper-access-control
– pio project init –board nodemcu-32s

2) Sustituir/crear archivos
– Copia el contenido de platformio.ini (arriba) a ./platformio.ini
– Crea ./src/main.cpp con el código mostrado

3) Instalar dependencias y compilar
– pio run

4) Enumerar puertos serie
– pio device list
– Windows: COM3/COMx (CP210x/CH340)
– Linux: /dev/ttyUSB0 (CP210x) o /dev/ttyACM0
– macOS: /dev/tty.SLAB_USBtoUART

5) Grabar firmware
– pio run -t upload –upload-port
– Ejemplo Windows: pio run -t upload –upload-port COM5
– Ejemplo Linux/macOS: pio run -t upload –upload-port /dev/ttyUSB0

6) Abrir monitor serie
– pio device monitor -b 115200 –port

7) Verificación de paquetes (opcional)
– pio pkg list
– pio pkg show framework-arduinoespressif32
– pio pkg show platformio/toolchain-xtensa-esp32

Notas:
– Si “Failed to connect to ESP32”, mantén pulsado BOOT y suéltalo cuando empiece el upload (o pulsa EN/RESET tras iniciar el proceso).
– Asegúrate de haber editado WIFI_SSID y WIFI_PASSWORD en main.cpp (puedes dejarlo sin red; el proyecto funciona igual y solo no tendrá hora real).

Validación paso a paso

1) Alimentación y conexión:
– Conecta el NodeMCU-32S al PC.
– Verifica en la tabla de pines que E‑Paper y PN532 están conectados a 3V3 y GND correctamente, y los pines CS/DC/RST/BUSY/SS según la tabla.

2) Arranque y logs por serie:
– En el monitor (115200), debes ver:
– [nfc-epaper-access-control] Inicio
– [WiFi] Conectando a (si configurado)
– [NTP] Sincronizado (si la red responde)
– [FS] LittleFS montado…
– [PN532] Chip PN5xx / Firmware x.y
– [Sistema] Listo. Acerca credencial NFC tipo A al PN532.

3) Pantalla E‑Paper inicial:
– Debe mostrar título, UID —-, la hora (si hay NTP) y el pie “Waveshare 2.9″ (SSD1680)”.

4) Presenta una tarjeta/lavero NFC:
– Acerca a ~2–4 cm del PN532.
– En menos de 1 s, la pantalla debe actualizarse parcialmente mostrando:
– UID: en hex
– Hora
– ACCESS GRANTED si su SHA‑256 está en whitelist
– ACCESS DENIED en caso contrario
– LED en GPIO2:
– Encendido fijo ~800 ms si concedido
– 3 parpadeos cortos si denegado

5) Registro de acceso:
– Reinicia y entra en modo “FS inspect” (pequeña rutina temporal o lee con LittleFS a través de un sketch; alternativamente, añade temporalmente un bloque que imprima /access_log.csv en setup).
– Debe existir /access_log.csv con entradas tipo:
– 2025-05-01 12:34:56,04a224b9c13280,248d6a…,1

6) Prueba ghosting:
– Ojo a posibles sombras tras ~10–20 operaciones parciales seguidas.
– El firmware fuerza un refresco completo cada 10 lecturas o ~60 s para limpiar ghosting.

7) Pruebas sin WiFi:
– Si no hay red, el sistema funciona igual. La hora será “1970-01-01 00:00:00” hasta que haya NTP.

8) Prueba de whitelist:
– Genera el SHA‑256 del UID con el script Python y añádelo al arreglo WHITELIST_SHA256.
– Compila y sube. Vuelve a probar: ahora esa tarjeta debe recibir ACCESS GRANTED.

Troubleshooting (errores típicos y soluciones)

1) No aparece puerto serie / no sube firmware
– Windows: instala driver CP210x (o CH340). Cambia cable USB por uno de datos.
– Linux: agrega tu usuario a grupo dialout, udev rules, o usa sudo temporalmente.
– “Failed to connect to ESP32”: mantén BOOT presionado al iniciar upload y suelta cuando empiece, o pulsa EN/RESET justo después.
– Revisa que el switch de alimentación de la placa (si lo tuviera) esté en ON.

2) “[PN532] No se detecto el PN532”
– Verifica que el PN532 está en modo SPI (algunas placas tienen switch/puentes SEL).
– Comprueba SS en GPIO15 y el cableado SCK/MOSI/MISO compartiendo VSPI.
– Alimentación: usa 3V3 estable; evita cables flojos.
– Asegúrate de no usar IRQ si el sketch no lo configura (nuestro ejemplo usa polling).

3) E‑Paper bloqueado en “BUSY” (no actualiza)
– BUSY mal cableado (GPIO4 en este diseño). Verifica continuidad y pin correcto.
– DC/RST invertidos: revisa DC (GPIO17) y RST (GPIO16).
– Usa el modelo adecuado de GxEPD2: GxEPD2_290 corresponde a SSD1680 2.9″ 296×128.
– Alimentación insuficiente durante refresco: intenta refresco con USB directo a PC o cargador 5V 1A.

4) Ghosting persistente en la pantalla
– Aumenta frecuencia de refrescos completos (por ejemplo cada 5 operaciones).
– Evita texto en posiciones que cambian píxel a píxel en parciales excesivos.
– Llama a drawAccessScreen(…, false) después de una serie de parciales intensos.

5) “Brownout detector triggered”
– Fuente USB débil o cable demasiado largo/fino. Cambia a puerto USB de mayor potencia o cargador 5V 2A.
– Evita alimentar otros módulos de alto consumo desde el 3V3 si no es necesario.

6) No se sincroniza NTP
– Revisa SSID/clave; verifica que el firewall permite NTP/UDP 123.
– Cambia servidor NTP (por ejemplo “time.google.com”, “es.pool.ntp.org”).
– Asegura buena potencia de señal WiFi.

7) No se imprime/crea access_log.csv
– Asegúrate de LittleFS.begin(true) en setup; se formatea automáticamente si no existe.
– Comprueba espacio: aunque improbable en pruebas, evita generar archivos demasiado grandes sin depuración.

8) El UID se muestra pero nunca “GRANTED”
– Verifica que el SHA‑256 del UID está correcto:
– Cuidado con mayúsculas/minúsculas; nuestro código usa hex minúscula.
– Asegúrate de no añadir espacios/nuevas líneas al copiar el hash.
– Algunas tarjetas tienen UIDs de longitud distinta (4/7 bytes). El hash depende exactamente de los bytes y el orden; usa el script provisto.

Mejoras / variantes

  • Persistencia de whitelist:
  • Cargar/guardar desde LittleFS en JSON/CSV para no recompilar al añadir tarjetas.
  • Herramienta de administración por puerto serie o web (microservidor HTTP en el ESP32).
  • Seguridad avanzada:
  • HMAC‑SHA256 con “salt” y clave única por instalación; evita incluso ataques de diccionario sobre UIDs.
  • Generar códigos de un solo uso (OTP) mostrados temporalmente en E‑Paper tras verificación NFC.
  • UX del panel:
  • Añadir iconografía o inversos de color en respuestas (negro sobre blanco y viceversa).
  • Temporizador de autoapagado y despertar por botón (profundizar en consumo).
  • Red y backend:
  • Sincronizar logs a un servidor remoto (HTTPS con WiFiClientSecure).
  • Integración con MQTT para auditoría centralizada.
  • Hardware:
  • Añadir un relé/SSR controlado por ESP32 al conceder acceso (con las debidas protecciones).
  • Añadir buzzer piezoactivo para feedback audible (GPIO dedicado).
  • Modo offline robusto:
  • RTC externo (p.ej., DS3231) para mantener hora sin NTP.
  • Cifrado de whitelist en flash y bloqueo por “secure boot”/flash encryption (ecosistema ESP-IDF).

Checklist de verificación

  • [ ] He instalado PlatformIO Core 6.1.13 y Python 3.11.6.
  • [ ] He creado el proyecto con board = nodemcu-32s y platform = espressif32 @ 6.6.0.
  • [ ] He pegado platformio.ini con librerías y paquetes en las versiones indicadas.
  • [ ] He cableado correctamente el E‑Paper (SSD1680) a GPIO23/18/5/17/16/4 y 3V3/GND.
  • [ ] He cableado el PN532 (SPI) a GPIO23/18/19/15 y 3V3/GND, y está en modo SPI.
  • [ ] He configurado WIFI_SSID/WIFI_PASSWORD (opcional) en main.cpp.
  • [ ] He generado y añadido el SHA‑256 de al menos un UID a WHITELIST_SHA256.
  • [ ] La compilación pio run finaliza sin errores.
  • [ ] La carga pio run -t upload se completa; el monitor serie muestra el banner de inicio.
  • [ ] Al acercar un tag NFC se muestra UID y “ACCESS GRANTED/DENIED” en el E‑Paper.
  • [ ] Se registra el evento en /access_log.csv con timestamp, UID y resultado.
  • [ ] He verificado refrescos completos periódicos (ghosting controlado).

Con este caso práctico “nfc-epaper-access-control” has integrado lectura NFC con PN532, render en e‑paper Waveshare 2.9″ (SSD1680), y control de acceso con verificación criptográfica en un ESP32 NodeMCU-32S, cuidando reproducibilidad con PlatformIO y un pipeline de build/flash/monitor determinista.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión recomendada de Python para el proyecto?




Pregunta 2: ¿Qué herramienta se utiliza para la verificación de versiones en PlatformIO?




Pregunta 3: ¿Cuál es la versión de PlatformIO Core recomendada?




Pregunta 4: ¿Qué controlador USB suele integrar el NodeMCU-32S?




Pregunta 5: ¿Qué comando se utiliza para actualizar los paquetes en PlatformIO?




Pregunta 6: ¿Cuál es la versión de la Toolchain GCC Xtensa para ESP32 requerida?




Pregunta 7: ¿Qué versión de OpenOCD es opcional para la depuración?




Pregunta 8: ¿Qué versión del Framework Arduino-ESP32 se debe usar?




Pregunta 9: ¿Qué archivo se debe crear para agregar una regla udev en Linux?




Pregunta 10: ¿Cuál es la versión mínima recomendada del controlador CP210x para Windows?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Control PID de motor DC AS5600 + TB6612 ESP32

Caso práctico: Control PID de motor DC AS5600 + TB6612 ESP32 — hero

Objetivo y caso de uso

Qué construirás: Un sistema de control de velocidad PID para un motor DC utilizando un ESP32 DevKitC, un controlador TB6612FNG y un sensor AS5600 para la retroalimentación de posición.

Para qué sirve

  • Control preciso de la velocidad de un motor DC en aplicaciones robóticas.
  • Regulación de la posición en sistemas de automatización industrial.
  • Implementación de sistemas de control en vehículos eléctricos pequeños.
  • Optimización de procesos en impresoras 3D para mantener la velocidad constante.

Resultado esperado

  • Latencia de control inferior a 50 ms en la respuesta del motor.
  • Estabilidad del sistema con un error de seguimiento menor al 5% en condiciones de carga variable.
  • Capacidad de manejar hasta 2 A de corriente sin sobrecalentamiento en el TB6612FNG.
  • Frecuencia de actualización de datos del sensor AS5600 de 100 Hz para un control preciso.

Público objetivo: Ingenieros y desarrolladores de sistemas embebidos; Nivel: Avanzado

Arquitectura/flujo: Sensor AS5600 → ESP32 DevKitC → Controlador TB6612FNG → Motor DC

Nivel: Avanzado

Prerrequisitos

Sistema operativo y herramientas

  • Windows 11 23H2 / Ubuntu 22.04.4 LTS / macOS 14 (Sonoma). Se recomienda 64 bits.
  • Python 3.11.x (solo si usas CLI de PlatformIO o scripts auxiliares).
  • Git 2.44+ (opcional, para versionado del proyecto).
  • Drivers USB del ESP32 DevKitC (según el chip USB-serie del módulo):
  • CP210x (Silicon Labs) v10.1.10 (Windows) / integrado en macOS.
  • CH340 (WCH) v3.6 (Windows) si tu DevKitC fuese variante con CH34x.

Toolchain exacta (PlatformIO)

  • PlatformIO Core: 6.1.16
  • VSCode: 1.95.x con extensión PlatformIO IDE 3.3.x (recomendado para entorno gráfico).
  • Plataforma ESP32 en PlatformIO:
  • platform-espressif32: 6.5.0
  • framework-arduinoespressif32: 3.20014.0 (Arduino-ESP32 2.0.14)
  • toolchain-xtensa32: 2.80400.0 (xtensa-esp32-elf-gcc 8.4.0)
  • tool-esptoolpy: 1.40501.0 (esptool.py 4.5.1)

Verificación rápida de versiones:
– PlatformIO Core:
– pio –version → Debe mostrar 6.1.16
– Paquetes (una vez inicializado el proyecto):
– pio pkg list -e esp32dev → Debe listar versiones fijadas arriba

Nota: fijaremos estas versiones explícitamente en platformio.ini para máxima reproducibilidad.

Materiales

  • 1x ESP32 DevKitC (módulo ESP32-WROOM-32).
  • 1x Driver dual H-Bridge TB6612FNG (usaremos el canal A).
  • 1x Encoder magnético AS5600 (módulo I2C, dirección 0x36).
  • 1x Motor DC con imán en el eje para el AS5600.
  • 1x Fuente de alimentación para motor: 6–12 V DC, 2 A (según el motor).
  • Jumpers Dupont macho-macho.
  • 2x Resistencias de 4.7 kΩ (pull-ups I2C a 3.3 V).
  • Protoboard (opcional, recomendado).
  • 1x Cable USB micro/USB-C (según tu DevKitC).
  • Cables para conectar el motor a AO1/AO2 del TB6612FNG.

Observaciones:
– Todo el control de velocidad (pid-dc-motor-encoder-velocity) se implementa con ESP32 DevKitC + TB6612FNG + AS5600; no se requieren otros sensores.
– Mantén la masa común: GND de ESP32, TB6612FNG y AS5600 deben estar unidos.

Preparación y conexión

Recomendaciones generales

  • AS5600 en modo I2C a 3.3 V. No mezclar 5 V en el bus I2C del ESP32.
  • El TB6612FNG requiere dos alimentaciones:
  • VM: alimentación del motor (6–12 V típico).
  • VCC: lógica del driver (3.3 V del ESP32).
  • Conecta STBY del TB6612FNG a un GPIO del ESP32 (habilitaremos/inhabilitaremos el puente H por software).
  • PWM a 20 kHz mediante LEDC del ESP32 para minimizar ruido audible.
  • SDA/SCL con pull-ups a 3.3 V de 4.7 kΩ si tu módulo AS5600 no los incluye.

Tabla de conexiones

Componente Pin Conectar a ESP32 DevKitC GPIO/Alimentación Notas
TB6612FNG VM Fuente motor + 6–12 V según motor
TB6612FNG VCC 3.3 V 3V3 Lógica a 3.3 V
TB6612FNG GND GND GND Masa común con ESP32 y AS5600
TB6612FNG STBY GPIO33 GPIO33 Alto para habilitar
TB6612FNG AIN1 GPIO26 GPIO26 Dirección A
TB6612FNG AIN2 GPIO27 GPIO27 Dirección A
TB6612FNG PWMA GPIO25 GPIO25 (LEDC) PWM velocidad A
TB6612FNG AO1 Terminal motor 1 Salida a motor
TB6612FNG AO2 Terminal motor 2 Salida a motor
AS5600 (I2C) VCC 3.3 V 3V3 No usar 5 V
AS5600 (I2C) GND GND GND Masa común
AS5600 (I2C) SDA SDA GPIO21 Pull-up 4.7 kΩ a 3.3 V si hace falta
AS5600 (I2C) SCL SCL GPIO22 Pull-up 4.7 kΩ a 3.3 V si hace falta

Notas adicionales:
– Si tu módulo AS5600 incluye resistencias pull-up integradas, no añadiras externas.
– Asegúrate de polaridad correcta en VM y VCC. No mezclar VM (motor) con 3.3 V (lógica).

Código completo

A continuación se presenta un proyecto funcional con:
– platformio.ini con versiones exactas de la toolchain.
– src/main.cpp con un bucle de control de velocidad a 1 kHz usando FreeRTOS, lectura del AS5600 por I2C, PID de velocidad y modulación PWM con LEDC hacia el TB6612FNG.
– Interfaz serie para ajuste online de setpoint y ganancias PID.

platformio.ini

; Proyecto PlatformIO para ESP32 DevKitC + TB6612FNG + AS5600
; Toolchain EXACTA fijada para reproducibilidad

[env:esp32dev]
platform = espressif32@6.5.0
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 460800
monitor_filters = time

; Fijamos paquetes exactos:
platform_packages =
  tool-esptoolpy@1.40501.0
  toolchain-xtensa32@2.80400.0
  framework-arduinoespressif32@3.20014.0

build_flags =
  -DCORE_DEBUG_LEVEL=1
  -DARDUINO_USB_CDC_ON_BOOT=0

; Puerto serie (opcional; en Windows puede ser COMx)
; upload_port = /dev/ttyUSB0
; monitor_port = /dev/ttyUSB0

src/main.cpp

#include <Arduino.h>
#include <Wire.h>

// ===================== Hardware mapping (ESP32 DevKitC + TB6612FNG + AS5600) =====================
static constexpr uint8_t PIN_TB_STBY = 33;
static constexpr uint8_t PIN_TB_PWMA = 25; // LEDC PWM
static constexpr uint8_t PIN_TB_AIN1 = 26;
static constexpr uint8_t PIN_TB_AIN2 = 27;

static constexpr uint8_t PIN_I2C_SDA = 21;
static constexpr uint8_t PIN_I2C_SCL = 22;

static constexpr uint8_t AS5600_ADDR = 0x36;
static constexpr uint8_t AS5600_REG_ANGLE_H = 0x0E; // High/Low bytes 12-bit angle
static constexpr uint8_t AS5600_REG_ANGLE_L = 0x0F;
static constexpr uint8_t AS5600_REG_STATUS  = 0x0B; // Magnet detection

// ===================== PWM (LEDC) =====================
static constexpr uint32_t PWM_FREQ_HZ = 20000; // 20 kHz
static constexpr uint8_t  PWM_CHANNEL = 0;
static constexpr uint8_t  PWM_TIMER_BITS = 12; // duty 0..4095
static constexpr uint16_t PWM_MAX_DUTY = (1u << PWM_TIMER_BITS) - 1;

// ===================== Control rates =====================
static constexpr uint32_t CTRL_HZ = 1000;     // 1 kHz control loop
static constexpr float    CTRL_DT = 1.0f / CTRL_HZ;

// ===================== PID =====================
struct PID {
  float kp{0.8f};
  float ki{25.0f};
  float kd{0.000f};
  float i{0.0f};
  float prevErr{0.0f};
  float outMin{-1.0f};
  float outMax{+1.0f};
  float awTau{0.1f}; // anti-windup back-calculation factor
} pid;

// Setpoint en RPM (interfaz usuario). Internamente trabajaremos en rad/s
volatile float g_setpoint_rpm = 180.0f;

// Filtro simple de velocidad (exponencial)
float vel_alpha = 0.2f; // 0..1, mayor = más rápido, menos filtrado

// Datos compartidos para telemetría
volatile float g_meas_rpm = 0.0f;
volatile float g_cmd_u = 0.0f; // salida normalizada -1..+1
volatile float g_error_rpm = 0.0f;
volatile bool  g_magnet_ok = false;

// ===================== Utilidades AS5600 =====================
bool i2cReadBytes(uint8_t dev, uint8_t reg, uint8_t *buf, size_t len) {
  Wire.beginTransmission(dev);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return false; // repeated start
  size_t n = Wire.requestFrom((int)dev, (int)len);
  if (n != len) return false;
  for (size_t i = 0; i < len; ++i) {
    buf[i] = Wire.read();
  }
  return true;
}

uint16_t as5600ReadAngle12() {
  uint8_t buf[2] = {0};
  if (!i2cReadBytes(AS5600_ADDR, AS5600_REG_ANGLE_H, buf, 2)) {
    return 0;
  }
  uint16_t raw = ((uint16_t)buf[0] << 8) | buf[1];
  return (raw & 0x0FFF); // 12-bit
}

bool as5600MagnetDetected() {
  uint8_t s = 0;
  if (!i2cReadBytes(AS5600_ADDR, AS5600_REG_STATUS, &s, 1)) return false;
  // STATUS bits: MD=Magnet detected(5), ML=too weak(3), MH=too strong(4)
  bool md = s & (1 << 5);
  bool tooWeak = s & (1 << 3);
  bool tooStrong = s & (1 << 4);
  // Consideramos OK si MD y no ML/MH
  return md && !tooWeak && !tooStrong;
}

// ===================== Conversión ángulo/velocidad =====================
// 4096 ticks por vuelta; velocidad en RPM a partir de delta_tics / dt.
static constexpr float TICKS_PER_REV = 4096.0f;
static constexpr float TWO_PI = 6.283185307179586f;

float ticksPerSecToRPS(float tps) {
  return tps / TICKS_PER_REV; // rev/s
}
float rpsToRPM(float rps) {
  return rps * 60.0f;
}
float rpmToRPS(float rpm) {
  return rpm / 60.0f;
}

// ===================== TB6612FNG Motor Driver =====================
void motorStandby(bool enable) {
  digitalWrite(PIN_TB_STBY, enable ? HIGH : LOW);
}

void motorDrive(float u) {
  // u en [-1, +1]
  float uu = constrain(u, -1.0f, +1.0f);
  int duty = (int)(fabsf(uu) * PWM_MAX_DUTY);

  // Compensación de zona muerta (~8 %) para arrancar motores pequeños
  const int dead_zone = (int)(0.08f * PWM_MAX_DUTY);
  if (duty > 0) duty = max(duty, dead_zone);

  if (uu >= 0.0f) {
    digitalWrite(PIN_TB_AIN1, HIGH);
    digitalWrite(PIN_TB_AIN2, LOW);
  } else {
    digitalWrite(PIN_TB_AIN1, LOW);
    digitalWrite(PIN_TB_AIN2, HIGH);
  }
  ledcWrite(PWM_CHANNEL, duty);
}

// ===================== PID compute =====================
float pidCompute(PID &c, float set_rps, float meas_rps, float dt) {
  float err = set_rps - meas_rps;
  // Proporcional
  float p = c.kp * err;
  // Derivativa (sobre error)
  float d = c.kd * (err - c.prevErr) / dt;
  // Integrativa con anti-windup por back-calculation
  float u_unsat = p + c.i + d;
  float u_sat = constrain(u_unsat, c.outMin, c.outMax);
  float aw = c.awTau * (u_sat - u_unsat); // si satura, corrige integrador
  c.i += c.ki * err * dt + aw;
  c.i = constrain(c.i, c.outMin, c.outMax);
  c.prevErr = err;
  return constrain(p + c.i + d, c.outMin, c.outMax);
}

// ===================== Tarea de control (1 kHz) =====================
TaskHandle_t g_ctrlTask = nullptr;

void controlTask(void *arg) {
  // Estado para unwrap de ángulo
  int32_t accumTicks = 0;
  uint16_t prev = as5600ReadAngle12();
  accumTicks = prev;
  uint32_t last_us = micros();

  // Filtro de velocidad
  float vel_rps_filt = 0.0f;

  // Programación periódica
  TickType_t lastWake = xTaskGetTickCount();

  for (;;) {
    vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1)); // 1 ms

    bool magnetOk = as5600MagnetDetected();
    g_magnet_ok = magnetOk;

    uint16_t now = as5600ReadAngle12();
    // unwrap
    int16_t delta = (int16_t)now - (int16_t)prev;
    if (delta > 2048) delta -= 4096;
    if (delta < -2048) delta += 4096;
    accumTicks += delta;
    prev = now;

    uint32_t now_us = micros();
    float dt = (now_us - last_us) / 1e6f;
    if (dt < 1e-6f) dt = CTRL_DT; // fallback
    last_us = now_us;

    // Velocidad en ticks/seg
    float tps = (float)delta / dt;
    float rps = ticksPerSecToRPS(tps);
    // Filtro exponencial
    vel_rps_filt = vel_alpha * rps + (1.0f - vel_alpha) * vel_rps_filt;

    // Setpoint y PID
    float set_rps = rpmToRPS(g_setpoint_rpm);
    // Seguridad si no hay imán correcto: poner setpoint 0 y salida 0
    float u = 0.0f;
    if (magnetOk) {
      u = pidCompute(pid, set_rps, vel_rps_filt, dt);
    } else {
      pid.i = 0.0f; pid.prevErr = 0.0f; u = 0.0f;
    }
    g_cmd_u = u;

    // Aplicar al motor
    motorDrive(u);

    // Telemetría compartida (no crítica, precisión best-effort)
    g_meas_rpm = rpsToRPM(vel_rps_filt);
    g_error_rpm = g_setpoint_rpm - g_meas_rpm;
  }
}

// ===================== Interfaz serie simple =====================
void printHelp() {
  Serial.println(F("# Comandos:"));
  Serial.println(F("#   sp <rpm>        : fija setpoint (RPM)"));
  Serial.println(F("#   kp <val>        : Kp"));
  Serial.println(F("#   ki <val>        : Ki"));
  Serial.println(F("#   kd <val>        : Kd"));
  Serial.println(F("#   alpha <0..1>    : filtro velocidad (exponencial)"));
  Serial.println(F("#   stop            : setpoint = 0"));
  Serial.println(F("#   status          : imprime estado/gains"));
  Serial.println(F("# Formato log: t_ms, set_rpm, meas_rpm, err_rpm, u, magnet_ok"));
}

void printStatus() {
  Serial.printf("# Kp=%.5f Ki=%.5f Kd=%.5f alpha=%.3f SP=%.2f RPM\r\n",
                pid.kp, pid.ki, pid.kd, vel_alpha, g_setpoint_rpm);
}

void handleSerial() {
  if (!Serial.available()) return;
  String line = Serial.readStringUntil('\n');
  line.trim();
  if (line.length() == 0) return;

  if (line.startsWith("sp ")) {
    float v = line.substring(3).toFloat();
    g_setpoint_rpm = v;
    Serial.printf("# SP=%.2f RPM\r\n", g_setpoint_rpm);
  } else if (line.startsWith("kp ")) {
    pid.kp = line.substring(3).toFloat();
    printStatus();
  } else if (line.startsWith("ki ")) {
    pid.ki = line.substring(3).toFloat();
    printStatus();
  } else if (line.startsWith("kd ")) {
    pid.kd = line.substring(3).toFloat();
    printStatus();
  } else if (line.startsWith("alpha ")) {
    vel_alpha = constrain(line.substring(6).toFloat(), 0.0f, 1.0f);
    printStatus();
  } else if (line == "stop") {
    g_setpoint_rpm = 0.0f;
    Serial.println("# SP=0");
  } else if (line == "status") {
    printStatus();
  } else if (line == "help" || line == "?") {
    printHelp();
  } else {
    Serial.println("# Comando no reconocido. Escribe 'help'.");
  }
}

// ===================== Setup/Loop =====================
void setup() {
  pinMode(PIN_TB_STBY, OUTPUT);
  pinMode(PIN_TB_AIN1, OUTPUT);
  pinMode(PIN_TB_AIN2, OUTPUT);

  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 400000); // I2C @ 400 kHz

  Serial.begin(115200);
  delay(200);
  Serial.println("# ESP32 DevKitC + TB6612FNG + AS5600 - PID velocity control");
  Serial.println("# Toolchain: PlatformIO Core 6.1.16, espressif32 6.5.0, arduino-esp32 2.0.14");
  printHelp();

  // Configurar PWM (LEDC)
  ledcSetup(PWM_CHANNEL, PWM_FREQ_HZ, PWM_TIMER_BITS);
  ledcAttachPin(PIN_TB_PWMA, PWM_CHANNEL);

  // Motor standby inicialmente deshabilitado
  motorStandby(false);
  delay(50);
  motorStandby(true);

  // Crear tarea de control en core 1 con prioridad media-alta
  xTaskCreatePinnedToCore(controlTask, "ctrl", 4096, nullptr, 3, &g_ctrlTask, 1);
}

void loop() {
  static uint32_t lastPrint = 0;
  handleSerial();

  uint32_t now = millis();
  if (now - lastPrint >= 20) { // ~50 Hz de logging
    lastPrint = now;
    // Log sencillo: t_ms, set_rpm, meas_rpm, err_rpm, u, magnet_ok
    Serial.printf("%lu, %.2f, %.2f, %.2f, %.3f, %d\r\n",
                  (unsigned long)now,
                  g_setpoint_rpm,
                  g_meas_rpm,
                  g_error_rpm,
                  g_cmd_u,
                  g_magnet_ok ? 1 : 0);
  }
}

Script opcional para graficar (local)

Si te interesa visualizar la respuesta en tiempo real (no es obligatorio), puedes usar este script Python (requiere pyserial y matplotlib). Ajusta el puerto serie.

# tools/plot_pid.py
import serial, time
import matplotlib.pyplot as plt
from collections import deque

PORT = "/dev/ttyUSB0"   # o COMx en Windows
BAUD = 115200

ser = serial.Serial(PORT, BAUD, timeout=0.1)
time.sleep(2)

buf_t, buf_sp, buf_meas = deque(maxlen=1000), deque(maxlen=1000), deque(maxlen=1000)
plt.ion()
fig, ax = plt.subplots()
ln_sp, = ax.plot([], [], label='Setpoint RPM')
ln_me, = ax.plot([], [], label='Medida RPM')
ax.set_xlabel('t (s)')
ax.set_ylabel('RPM')
ax.legend()
ax.grid(True)

t0 = time.time()
while True:
    try:
        line = ser.readline().decode(errors='ignore').strip()
        if ',' in line and not line.startswith('#'):
            parts = [p.strip() for p in line.split(',')]
            if len(parts) >= 3:
                t_ms = float(parts[0])
                sp = float(parts[1])
                me = float(parts[2])
                buf_t.append((t_ms/1000.0) - (t0))
                buf_sp.append(sp)
                buf_meas.append(me)
                ln_sp.set_data(list(buf_t), list(buf_sp))
                ln_me.set_data(list(buf_t), list(buf_meas))
                ax.relim(); ax.autoscale_view()
                plt.pause(0.01)
    except KeyboardInterrupt:
        break

Compilación, grabación y ejecución

Inicialización del proyecto

  • Crea el proyecto con PlatformIO (en una carpeta vacía):
  • pio project init –board esp32dev
  • Sustituye el contenido de platformio.ini por el proporcionado arriba (fija versiones).
  • Crea src/main.cpp con el código proporcionado.
  • Opcional: añade tools/plot_pid.py si usarás la gráfica.

Comandos exactos

  • Compilar:
  • pio run
  • Subir firmware al ESP32 DevKitC:
  • pio run -t upload
  • Abrir monitor serie (115200 baudios):
  • pio device monitor -b 115200
  • Todo encadenado:
  • pio run -t upload && pio device monitor -b 115200

Verificación de paquetes y versiones instaladas para este entorno:
– pio pkg list -e esp32dev

Ejemplo de salida esperada (resumen):
– framework-arduinoespressif32 3.20014.0
– toolchain-xtensa32 2.80400.0
– tool-esptoolpy 1.40501.0
– platform-espressif32 6.5.0

Validación paso a paso

1) Comprobación eléctrica previa

  • Con motor desconectado (AO1/AO2 sin motor), enciende el sistema con VM presente y ESP32 por USB.
  • Monitor serie a 115200. Debes ver:
  • Línea de bienvenida con “ESP32 DevKitC + TB6612FNG + AS5600 – PID velocity control”.
  • Toolchain listada.
  • Ayuda con comandos.

Si no hay errores, conecta el motor a AO1/AO2 del TB6612FNG y coloca el imán del AS5600 alineado con el eje (distancia y centrado adecuados).

2) Estado del imán (AS5600)

  • Observa el último campo del log periódico: “magnet_ok”.
  • 1: AS5600 detecta imán en rango válido.
  • 0: demasido débil/fuerte o no detectado.
  • Si aparece 0:
  • Reajusta la distancia entre sensor y magneto.
  • Asegura alimentación 3.3 V adecuada y pull-ups I2C.
  • Consulta Troubleshooting.

3) Primer giro con setpoint bajo

  • Introduce por monitor serie:
  • sp 60
  • Observa:
  • El motor debe empezar a girar suavemente.
  • Log aproximado:
    • t_ms, set_rpm, meas_rpm, err_rpm, u, magnet_ok
    • 1234, 60.00, 5.00, 55.00, 0.25, 1
    • … (debe converger hacia meas_rpm ≈ 60)

Si hay un leve retardo de arranque, es normal: la compensación de zona muerta se encarga de vencer el rozamiento.

4) Escalón a velocidad media

  • Cambia a:
  • sp 180
  • Esperado:
  • Aumento de “u” en el log, un pico de error y convergencia en menos de 1–2 s (dependiendo del motor).
  • Overshoot razonable si Kp alto (p.ej., +5–15 %). Si es excesivo, reduce Kp o aumenta D.
  • Estabilidad:
  • El valor meas_rpm debe mantenerse cercano al setpoint bajo cargas leves.
  • Aplica una carga con el dedo (con cuidado) y observa cómo el PID compensa elevando “u”.

5) Ajuste de ganancias PID

  • Si el sistema:
  • Oscila: reduce Kp o aumenta Kd ligeramente (p.ej., kd 0.0005, luego 0.001).
  • Es lento: aumenta Kp gradualmente (0.8 → 1.2 → 1.5) y luego sube Ki (25 → 35).
  • Tiene mucho error estacionario: aumenta Ki en pasos pequeños (±5) con cuidado.
  • Recuerda:
  • Demasiada Ki provoca oscilaciones y saturación (u pega en ±1).
  • Usa kd con moderación si el ruido de velocidad es elevado. Aumenta alpha (p.ej., alpha 0.3) para filtrar más rápido.

6) Validación cuantitativa

  • Paso de 0 → 120 RPM → 240 RPM, mide:
  • Tiempo de establecimiento: < 1.5 s (objetivo).
  • Overshoot: < 10–15 %.
  • Error en régimen: < 3–5 % sin carga.
  • Log representativo:
  • En reposo: “u” ~ 0, meas_rpm ~ 0.
  • A 120 RPM: error se acerca a 0, “u” se estabiliza.
  • Con carga transitoria: “u” sube y luego regresa a su valor precedente al retirar la carga.

7) Prueba de parada controlada

  • stop
  • Esperado:
  • setpoint = 0.
  • El motor desacelera hasta detenerse, con “u” → 0.
  • Sin imán (magnet_ok = 0), el control corta salida por seguridad y resetea integrador.

8) Opcional: gráfica

  • Ejecuta:
  • python3 tools/plot_pid.py
  • Cambia setpoints y observa en tiempo real setpoint/medida.

Troubleshooting

1) Motor no gira
– Verifica STBY del TB6612FNG está en alto (GPIO33 configurado y en HIGH).
– Comprueba VM (motor supply) presente y GND común con ESP32.
– Revisa AIN1/AIN2 invertidos o desconectados. Invierte los cables del motor si gira al revés.
– Aumenta setpoint (p.ej., sp 120) para vencer rozamiento si el motor es duro.

2) I2C no responde (velocidad medida 0 y magnet_ok = 0)
– Chequea pull-ups a 3.3 V en SDA/SCL (4.7 kΩ), o confirma que el módulo AS5600 ya los trae.
– Verifica el cableado SDA→GPIO21, SCL→GPIO22.
– Asegura VCC del AS5600 a 3.3 V (no 5 V).
– Revisa que la dirección 0x36 es correcta (evitar módulos clon con otra address rara).

3) Lecturas de velocidad erráticas (picos o ruido excesivo)
– Aumenta alpha (p.ej., alpha 0.3–0.5) para filtrar la velocidad.
– Reduce kd a 0 si no necesitas término derivativo.
– Verifica alineación y distancia del imán; evita vibraciones mecánicas.

4) Sobreoscilación persistente
– Reduce kp y/o ki. Empieza con kd = 0 y añade derivativa en incrementos pequeños (0.0005, 0.001).
– Confirma que la frecuencia de control (1 kHz) es estable (no satures el puerto serie con logs; ya está a ~50 Hz).

5) Ruido audible/pérdidas de par a bajas velocidades
– Asegura PWM a 20 kHz (LEDC configurado como en el código): build limpio y verificación en runtime.
– Ajusta dead_zone (8 %) si tu motor requiere más duty para arrancar.

6) Reseteos del ESP32 al iniciar el motor
– La fuente de motor produce caídas de VM/GND: usa una fuente estable y separa el 5V/3.3V de lógica del ESP32.
– Mantén GND común y añade condensadores de bulk (p.ej., 470–1000 µF) cerca del TB6612FNG.

7) Velocidad se invierte con setpoints positivos
– Invierte AIN1/AIN2 en el código (o invierte el cableado del motor).
– Verifica la lógica de motorDrive(u): u>=0 debe corresponder al sentido que consideras “positivo”.

8) No se puede flashear (puerto no aparece)
– Instala driver CP210x/CH34x correcto y usa un cable USB de datos.
– Selecciona el puerto correcto en platformio.ini (upload_port/monitor_port).
– Pulsa BOOT/EN del DevKitC si fuese necesario (algunas placas requieren pulsos manuales).

Mejoras/variantes

  • Sustituir LEDC por MCPWM del ESP32 para PWM de alta resolución y sincronización avanzada (no imprescindible para TB6612FNG).
  • Añadir feedforward (u_ff = kff * set_rps) para reducir esfuerzo del integrador y mejorar respuesta ante cargas.
  • Almacenamiento de parámetros PID en NVS (no volátil) para mantener ajustes tras reinicios.
  • Estimación más robusta de velocidad:
  • Promediado móvil de delta ticks con ventana deslizante.
  • Filtro de mediana para spikes.
  • Filtro de Kalman si el entorno es muy ruidoso.
  • Limitador de rampa del setpoint (slew rate) para evitar peticiones bruscas al motor.
  • Supervisión de corriente (añadiendo un shunt + ADC) para protección térmica o de sobrecarga.
  • Cambio de objetivo: control cascada posición-velocidad (outer loop de posición con inner loop de velocidad).
  • Integración con ROS2/micro-ROS para telemetría remota y control superior.

Checklist de verificación

  • [ ] Toolchain instalada: PlatformIO Core 6.1.16, espressif32 6.5.0, arduino-esp32 2.0.14 (3.20014.0).
  • [ ] Drivers USB instalados (CP210x/CH34x) y puerto serie visible.
  • [ ] platformio.ini idéntico al del tutorial (versiones fijadas).
  • [ ] Cableado revisado:
  • [ ] TB6612FNG VM a fuente motor, VCC a 3.3 V, GND común.
  • [ ] STBY a GPIO33, AIN1→GPIO26, AIN2→GPIO27, PWMA→GPIO25.
  • [ ] AS5600 SDA→GPIO21, SCL→GPIO22, VCC 3.3 V y GND; pull-ups presentes.
  • [ ] Subida de firmware sin errores (pio run -t upload).
  • [ ] Monitor serie a 115200 con mensajes de inicio y “help”.
  • [ ] magnet_ok = 1 en reposo (AS5600 detecta imán correctamente).
  • [ ] sp 60 hace girar el motor estable cerca de 60 RPM.
  • [ ] sp 180 responde con subida de velocidad y estabiliza sin oscilaciones severas.
  • [ ] stop detiene el motor y “u” → 0.
  • [ ] Ajuste de Kp/Ki/Kd probado para optimizar respuesta de tu motor.

Con este caso práctico de nivel avanzado, has implementado un control PID de velocidad completo basado en sensores magnéticos absolutos (AS5600) y un puente H TB6612FNG, ejecutado sobre un ESP32 DevKitC con una toolchain totalmente especificada para reproducibilidad. El pipeline de medición (I2C 400 kHz, unwrapping de 12 bits), estimación de velocidad filtrada, lazo de control a 1 kHz y modulación PWM a 20 kHz permite un control fino y silencioso de motores DC en aplicaciones embebidas avanzadas.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué sistema operativo se recomienda para utilizar con el ESP32 DevKitC?




Pregunta 2: ¿Cuál es la versión mínima de Git recomendada?




Pregunta 3: ¿Qué driver USB es necesario si el módulo tiene un chip CP210x?




Pregunta 4: ¿Cuál es la versión de PlatformIO Core recomendada?




Pregunta 5: ¿Qué tipo de motor se menciona en el artículo?




Pregunta 6: ¿Qué resistencia se utiliza como pull-up para el I2C?




Pregunta 7: ¿Qué fuente de alimentación se recomienda para el motor?




Pregunta 8: ¿Cuál es la dirección I2C del módulo AS5600?




Pregunta 9: ¿Qué extensión se recomienda para usar con VSCode?




Pregunta 10: ¿Cuál es la versión del framework-arduinoespressif32 recomendada?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme: