DEV Community

xiao Mayer
xiao Mayer

Posted on

Building a ROS 2 Distance Sensor Node for Mobile Robot Obstacle Avoidance

When building differential-drive mobile robots or autonomous ground vehicles (AGVs), reliable distance feedback is crucial for navigation and safety. While full 2D/3D LiDAR pucks are standard for simultaneous localization and mapping (SLAM), single-point optical distance sensors provide a lightweight, high-refresh-rate, and cost-effective alternative for forward obstacle tripwires, wall-following, and precision docking.

In this tutorial, we will build a production-ready ROS 2 Python node (laser_range_node) that interfaces with an industrial UART laser distance sensor and streams standard ROS 2 messages.


System Architecture

The setup consists of:

  • Host Computer: Raspberry Pi 4 / 5 or Jetson Nano running Ubuntu 22.04 with ROS 2 Humble.
  • Optical Sensor: An OEM phase-shift laser distance module (such as the compact serial units from lasersensor.net) configured for continuous high-speed output.
  • Communication Interface: Direct 3.3V TTL UART connection to the board's GPIO pins.

In ROS 2, distance data from a single-beam sensor should be published using the standardized sensor_msgs/msg/Range message interface. This enables seamless plug-and-play compatibility with standard navigation stacks (Nav2) and diagnostic visualizers in RViz.


Hardware Connections

Connect the sensor directly to the controller's serial bus:

Sensor Pin Controller Pin Function
VCC 3.3V Pin Power supply (Logic level 3.3V)
GND Ground Pin Common Ground
TXD RXD (e.g., GPIO 15) Sensor transmit -> Host receive
RXD TXD (e.g., GPIO 14) Host transmit -> Sensor receive

ROS 2 Package Setup

First, source your ROS 2 environment and create a dedicated workspace:


bash
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python laser_range_driver --dependencies rclpy sensor_msgs

Ensure your current user has permission to read the UART bus without root privileges:
sudo usermod -aG dialout $USER
Driver Implementation
Inside laser_range_driver/laser_range_driver/laser_node.py, create the publisher node:
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Range
import serial
import time

class LaserRangeNode(Node):
    def __init__(self):
        super().__init__('laser_range_node')

        # Declare configurable parameters
        self.declare_parameter('port', '/dev/serial0')
        self.declare_parameter('baudrate', 19200)
        self.declare_parameter('frame_id', 'laser_range_link')
        self.declare_parameter('min_range', 0.03)
        self.declare_parameter('max_range', 40.0)
        self.declare_parameter('publish_rate', 20.0)

        port = self.get_parameter('port').get_parameter_value().string_value
        baudrate = self.get_parameter('baudrate').get_parameter_value().integer_value
        self.frame_id = self.get_parameter('frame_id').get_parameter_value().string_value
        self.min_range = self.get_parameter('min_range').get_parameter_value().double_value
        self.max_range = self.get_parameter('max_range').get_parameter_value().double_value
        publish_rate = self.get_parameter('publish_rate').get_parameter_value().double_value

        self.publisher_ = self.create_publisher(Range, '/distance/laser_range', 10)

        # Open serial port
        try:
            self.ser = serial.Serial(port=port, baudrate=baudrate, timeout=0.1)
            self.get_logger().info(f'Connected to laser sensor on {port} at {baudrate} baud.')
        except serial.SerialException as e:
            self.get_logger().error(f'Failed to open serial port: {e}')
            return

        self.timer = self.create_timer(1.0 / publish_rate, self.read_and_publish)

    def read_and_publish(self):
        # Trigger single-shot command frame: AA 00 00 20 00 01 00 21
        cmd = bytes([0xAA, 0x00, 0x00, 0x20, 0x00, 0x01, 0x00, 0x21])
        self.ser.write(cmd)

        time.sleep(0.015)
        response = self.ser.read(9)

        if len(response) == 9 and response[0] == 0xAA and response[3] == 0x22:
            # Checksum verification (modulo 256)
            if (sum(response[:-1]) & 0xFF) == response[-1]:
                raw_mm = int.from_bytes(response[4:8], byteorder='big')
                distance_meters = raw_mm / 1000.0

                # Assemble standard sensor_msgs/Range
                msg = Range()
                msg.header.stamp = self.get_clock().now().to_msg()
                msg.header.frame_id = self.frame_id
                msg.radiation_type = Range.INFRARED  # Laser/Optical type
                msg.field_of_view = 0.002            # Collimated narrow beam
                msg.min_range = self.min_range
                msg.max_range = self.max_range
                msg.range = float(distance_meters)

                self.publisher_.publish(msg)
            else:
                self.get_logger().warn('Checksum error on incoming sensor frame.')

    def destroy_node(self):
        if hasattr(self, 'ser') and self.ser.is_open:
            self.ser.close()
        super().destroy_node()

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

if __name__ == '__main__':
    main()
Building and Running the Node
Add the entry point into setup.py:

Python
entry_points={
    'console_scripts': [
        'laser_node = laser_range_driver.laser_node:main'
    ],
},
Build the workspace:

Bash
cd ~/ros2_ws
colcon build --packages-select laser_range_driver
source install/setup.bash
Run the driver node:

Bash
ros2 run laser_range_driver laser_node
Verify incoming topic data in another terminal:

Bash
ros2 topic echo /distance/laser_range
Visualizing in RViz 2
To see the laser beam live in the robot's coordinate frame:

Launch RViz:

Bash
rviz2
Set Fixed Frame to laser_range_link.

Click Add -> By topic -> select /distance/laser_range -> Range.

You will see a narrow cone/line extending dynamically as the target distance shifts.

Practical Engineering Considerations
Optical Filtering: When operating in well-lit factories or outdoor sunlight, narrow-band optical bandpass filters prevent sunlight saturation from triggering out-of-range errors.

Low-Reflectance Surfaces: If tracking dark plastic totes or black machinery, lower the sampling rate or switch to continuous high-gain measurement mode to ensure reliable signal integration.

Coordinate Transforms (TF2): Always publish a static transform linking laser_range_link to your robot's base_link so the navigation costmaps accurately register obstacle positions.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)