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.
Top comments (0)