Objective and use case
What you’ll build: A ROS 2 Humble joystick teleoperation stack that drives a differential UGV from a USB gamepad through joy → teleop_twist_joy → diff_drive_controller on a Raspberry Pi 4 with a Waveshare 2‑CH CAN HAT (MCP2515).
This pipeline will publish real-time cmd_vel commands, achieving <40 ms input-to-wheel latency at >50 Hz command rates while streaming feedback over CAN.
Why it matters / Use cases
- Safe manual testing of a UGV chassis: Drive at low speeds (e.g., <0.3 m/s) to verify wheel wiring, motor direction, and encoder polarity before enabling autonomous Nav2.
- Operator-in-the-loop field trials: Teleoperate the robot through warehouse aisles or corridors while recording rosbag2 at 30–60 Hz for later SLAM, calibration, and controller tuning.
- Fallback/manual override for autonomy: Maintain a robust joystick override path so the operator can safely regain control within <100 ms if navigation behavior degrades.
- Educational ROS 2 control demo: Show end-to-end flow from joystick axes to URDF-based differential drive, exposing message types, controllers, and CAN interfaces on a single Pi 4.
Expected outcome
- UGV responds smoothly to joystick commands at 50–100 Hz with stable wheel velocities and <10% missed command cycles over a 10-minute test run.
- End-to-end joystick-to-wheel latency measured at <40 ms on the Raspberry Pi 4, with
ros2 topic hzconfirming consistentcmd_velrates. - Raspberry Pi 4 CPU usage <45% and GPU usage <10% during teleop, leaving headroom for additional nodes (logging, visualization, basic perception).
- Log files (rosbag2) captured for at least 30 minutes of teleop without dropped messages, suitable for replaying and testing navigation stacks later.
Audience: ROS 2 and robotics developers integrating low-cost compute with UGV hardware; Level: Intermediate (comfortable with ROS 2 basics, Linux, and some hardware setup).
Architecture/flow: USB gamepad → joy node (sensor_msgs/Joy at 50–100 Hz) → teleop_twist_joy (maps axes/buttons to geometry_msgs/Twist) → diff_drive_controller (ROS 2 control) → CAN interface on Waveshare MCP2515 HAT → motor drivers and wheel encoders, with URDF + controller config providing kinematics and frame transforms.
Prerequisites
- OS and hardware
- Ubuntu Server 22.04 64‑bit on Raspberry Pi 4 Model B 4GB (aarch64).
- Basic headless access (SSH) to the Pi.
- Basic Linux shell knowledge (editing files with
nano/vim, usingapt, etc.). - ROS 2
- ROS 2 Humble pre-installed or installed following the commands below.
- Networking
- Pi and any visualization laptop are on the same network.
- Joystick/gamepad
- Any USB gamepad recognized as
/dev/input/js0(e.g., Xbox / generic USB).
Materials (with exact model)
| Item | Exact model / spec | Purpose |
|---|---|---|
| SBC | Raspberry Pi 4 Model B 4GB | Main ROS 2 compute unit |
| CAN interface HAT | Waveshare 2-CH CAN HAT (MCP2515) | CAN bus interface (future motor controller link) |
| microSD card | ≥ 32 GB, Class 10 | Ubuntu 22.04 64‑bit installation |
| Power supply | 5V / 3A USB‑C | Power for Raspberry Pi 4 |
| Gamepad / joystick | USB gamepad (e.g., Logitech F310, Xbox controller) | Teleoperation input |
| UGV chassis / motors (generic) | Two DC motors with encoders + motor driver (CAN or other) | Differential drive base |
| Cables | Jumper wires (female-female, etc.) | CAN HAT SPI + CAN bus connection |
| Computer for RViz (optional) | Ubuntu 22.04 with ROS 2 Humble desktop | Visualization, mapping, navigation tools |
Setup / Connection
1. Base OS and ROS 2 Humble installation
All commands run on the Raspberry Pi 4 via terminal/SSH.
- Update system:
sudo apt update
sudo apt upgrade -y
- Add ROS 2 sources and keys (Humble on Ubuntu 22.04):
sudo apt install -y software-properties-common
sudo add-apt-repository universe -y
sudo apt install -y curl
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.asc \
-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
- Install ROS 2 core and required stacks:
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
- Add ROS 2 environment to your shell:
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
source ~/.bashrc
2. Hardware: Waveshare 2-CH CAN HAT (MCP2515)
For this Basic teleop case, the CAN HAT will be prepared but we won’t implement full motor CAN control yet. Setup is useful for later expansions.
-
Mount the HAT on the 40‑pin header of the Raspberry Pi (aligned with pin 1).
-
Enable SPI and CAN MCP2515 overlays
Edit /boot/firmware/config.txt (or /boot/config.txt depending on your image):
sudo nano /boot/firmware/config.txt
Add lines near the bottom:
dtparam=spi=on
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
dtoverlay=spi-bcm2835
If the HAT offers a second channel can1, consult Waveshare docs and add an additional overlay if needed.
- Reboot:
sudo reboot
- Verify CAN interface:
dmesg | grep -i mcp2515
ip -details -statistics link show can0
To bring CAN up (example 500 kbps):
sudo ip link set can0 up type can bitrate 500000
ip link show can0
For this project we mainly focus on ROS 2 teleop; motor-CAN integration is a future improvement.
3. Create ROS 2 workspace
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws
Initialize the workspace:
colcon build
source install/setup.bash
echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc
Full Code
We will create a minimal robot description + control configuration and a launch setup that enables joystick teleoperation.
1. Create a robot description package
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_cmake ugv_beast_description
Directory structure:
ugv_beast_description/
CMakeLists.txt
package.xml
urdf/
config/
launch/
Create directories:
mkdir -p ugv_beast_description/urdf
mkdir -p ugv_beast_description/config
mkdir -p ugv_beast_description/launch
1.1 URDF with diff drive and ros2_control
Create ugv_beast_description/urdf/ugv_beast.urdf.xacro:
nano ugv_beast_description/urdf/ugv_beast.urdf.xacro
Paste:
<?xml version="1.0"?>
<robot name="ugv_beast" xmlns:xacro="http://www.ros.org/wiki/xacro">
<!-- Parameters: adjust for your chassis -->
<xacro:property name="wheel_radius" value="0.05"/> <!-- 5 cm -->
<xacro:property name="track_width" value="0.30"/> <!-- 30 cm between wheels -->
<xacro:property name="wheel_width" value="0.03"/> <!-- 3 cm -->
<xacro:property name="base_length" value="0.30"/>
<xacro:property name="base_width" value="0.25"/>
<xacro:property name="base_height" value="0.05"/>
<!-- Links -->
<link name="base_link">
<inertial>
<origin xyz="0 0 0.05" rpy="0 0 0"/>
<mass value="5.0"/>
<inertia ixx="0.1" ixy="0.0" ixz="0.0"
iyy="0.1" iyz="0.0" izz="0.1"/>
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<box size="${base_length} ${base_width} ${base_height}"/>
</geometry>
<material name="blue">
<color rgba="0 0 1 1"/>
</material>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<box size="${base_length} ${base_width} ${base_height}"/>
</geometry>
</collision>
</link>
<!-- Left wheel -->
<link name="left_wheel_link">
<visual>
<origin xyz="0 0 0" rpy="0 1.5708 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 1.5708 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
</collision>
</link>
<!-- Right wheel -->
<link name="right_wheel_link">
<visual>
<origin xyz="0 0 0" rpy="0 1.5708 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
<material name="black"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 1.5708 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
</collision>
</link>
<!-- Joints -->
<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>
<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>
<!-- ros2_control hardware interface (placeholder) -->
<ros2_control name="ugv_beast_control" type="system">
<hardware>
<!-- Replace with your hardware plugin later (e.g., CAN motor drivers) -->
<plugin>ros2_control_demo_hardware/GenericSystem</plugin>
</hardware>
<joint name="left_wheel_joint">
<command_interface name="velocity"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="right_wheel_joint">
<command_interface name="velocity"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<controller name="diff_drive_controller" 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">${track_width}</param>
<param name="wheel_radius">${wheel_radius}</param>
<param name="cmd_vel_timeout">0.5</param>
<param name="publish_rate">50.0</param>
<param name="use_stamped_vel">false</param>
<param name="enable_odom_tf">true</param>
</controller>
</ros2_control>
</robot>
Wheel calibration note:
– If the robot drives faster than expected, your real wheel_radius is larger than configured (increase value).
– If it drives slower, radius is smaller (decrease value).
– If it turns tighter/looser than commanded (angular error), adjust track_width: larger width → slower rotation, smaller width → faster rotation.
1.2 diff_drive_controller YAML
Create ugv_beast_description/config/diff_drive.yaml:
nano ugv_beast_description/config/diff_drive.yaml
diff_drive_controller:
ros__parameters:
publish_rate: 50.0
command_interface: "velocity"
left_wheel_names: ["left_wheel_joint"]
right_wheel_names: ["right_wheel_joint"]
wheel_separation: 0.30
wheel_radius: 0.05
use_stamped_vel: false
cmd_vel_timeout: 0.5
base_frame_id: "base_link"
odom_frame_id: "odom"
enable_odom_tf: true
pose_covariance_diagonal: [0.001, 0.001, 999999.0, 999999.0, 999999.0, 0.01]
twist_covariance_diagonal: [0.001, 0.001, 999999.0, 999999.0, 999999.0, 0.01]
2. Create a bringup/teleop package
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python ugv_beast_bringup
Directory structure:
ugv_beast_bringup/
package.xml
setup.py
resource/
ugv_beast_bringup/
__init__.py
config/
launch/
Create subdirs:
mkdir -p ugv_beast_bringup/config
mkdir -p ugv_beast_bringup/launch
2.1 teleop_twist_joy config
Create ugv_beast_bringup/config/teleop_joy.yaml:
nano ugv_beast_bringup/config/teleop_joy.yaml
teleop_twist_joy_node:
ros__parameters:
axis_linear: 1 # Left stick vertical
scale_linear: 0.3 # m/s
axis_angular: 0 # Left stick horizontal
scale_angular: 1.0 # rad/s
enable_button: 4 # LB
enable_turbo_button: 5 # RB
scale_linear_turbo: 0.6
scale_angular_turbo: 2.0
Adjust axes/button indices using ros2 run joy joy_node and ros2 topic echo /joy if your gamepad mapping differs.
2.2 Robot localization EKF config
Create ugv_beast_bringup/config/ekf.yaml:
nano ugv_beast_bringup/config/ekf.yaml
ekf_filter_node:
ros__parameters:
frequency: 30.0
sensor_timeout: 0.1
two_d_mode: true
publish_tf: true
map_frame: map
odom_frame: odom
base_link_frame: base_link
world_frame: odom
odom0: /odom
odom0_config: [true, true, false,
false, false, true,
false, false, false,
false, false, false,
false, false, false]
odom0_differential: false
odom0_queue_size: 10
imu0: /imu/data
imu0_config: [false, false, false,
true, true, true,
false, false, false,
false, false, false,
false, false, false]
imu0_differential: false
imu0_queue_size: 10
process_noise_covariance: [0.05, 0, 0, 0, 0, 0,
0, 0.05, 0, 0, 0, 0,
0, 0, 0.0, 0, 0, 0,
0, 0, 0, 0.1, 0, 0,
0, 0, 0, 0, 0.1, 0,
0, 0, 0, 0, 0, 0.1]
initial_estimate_covariance: [1e-9, 0, 0, 0, 0, 0,
0, 1e-9, 0, 0, 0, 0,
0, 0, 1e6, 0, 0, 0,
0, 0, 0, 1e6, 0, 0,
0, 0, 0, 0, 1e6, 0,
0, 0, 0, 0, 0, 1e-9]
For this hands‑on basic case, IMU is optional; you can still use the EKF later when you add an IMU.
2.3 Launch file: robot + control + teleop
Create ugv_beast_bringup/launch/ugv_joy_teleop.launch.py:
nano ugv_beast_bringup/launch/ugv_joy_teleop.launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from ament_index_python.packages import get_package_share_directory
from launch.substitutions import PathJoinSubstitution
def generate_launch_description():
description_pkg = get_package_share_directory('ugv_beast_description')
bringup_pkg = get_package_share_directory('ugv_beast_bringup')
robot_description_file = PathJoinSubstitution(
[description_pkg, 'urdf', 'ugv_beast.urdf.xacro'])
diff_drive_config = PathJoinSubstitution(
[description_pkg, 'config', 'diff_drive.yaml'])
teleop_joy_config = PathJoinSubstitution(
[bringup_pkg, 'config', 'teleop_joy.yaml'])
ekf_config = PathJoinSubstitution(
[bringup_pkg, 'config', 'ekf.yaml'])
robot_state_publisher = Node(
package='robot_state_publisher',
executable='robot_state_publisher',
name='robot_state_publisher',
output='screen',
parameters=[{'use_sim_time': False,
'robot_description': open(str(robot_description_file.perform(None))).read()}]
)
# diff_drive_controller via ros2_control_node
controller_manager = Node(
package='controller_manager',
executable='ros2_control_node',
parameters=[{'use_sim_time': False},
{'robot_description': open(str(robot_description_file.perform(None))).read()},
diff_drive_config],
output='screen'
)
# Load and start diff_drive_controller
diff_controller_spawner = Node(
package='controller_manager',
executable='spawner',
arguments=['diff_drive_controller'],
output='screen'
)
# Joy node
joy_node = Node(
package='joy',
executable='joy_node',
name='joy_node',
output='screen'
)
# Teleop Twist Joy
teleop_twist_joy = Node(
package='teleop_twist_joy',
executable='teleop_node',
name='teleop_twist_joy_node',
parameters=[teleop_joy_config],
remappings=[('/cmd_vel', '/diff_drive_controller/cmd_vel_unstamped')],
output='screen'
)
# Robot localization EKF
ekf_node = Node(
package='robot_localization',
executable='ekf_node',
name='ekf_filter_node',
output='screen',
parameters=[ekf_config]
)
return LaunchDescription([
robot_state_publisher,
controller_manager,
diff_controller_spawner,
joy_node,
teleop_twist_joy,
ekf_node
])
Note: here we remap /cmd_vel from teleop_twist_joy to the controller’s expected topic /diff_drive_controller/cmd_vel_unstamped (a common convention for diff_drive_controller in Humble).
2.4 Package metadata
Edit ugv_beast_description/package.xml and add dependencies:
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<depend>urdf</depend>
<depend>xacro</depend>
<depend>hardware_interface</depend>
<depend>controller_manager</depend>
<depend>diff_drive_controller</depend>
<exec_depend>robot_state_publisher</exec_depend>
Edit ugv_beast_description/CMakeLists.txt to install URDF and config:
cmake_minimum_required(VERSION 3.8)
project(ugv_beast_description)
find_package(ament_cmake REQUIRED)
install(DIRECTORY urdf config
DESTINATION share/${PROJECT_NAME}
)
ament_package()
Edit ugv_beast_bringup/package.xml:
<buildtool_depend>ament_python</buildtool_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<exec_depend>robot_state_publisher</exec_depend>
<exec_depend>controller_manager</exec_depend>
<exec_depend>diff_drive_controller</exec_depend>
<exec_depend>joy</exec_depend>
<exec_depend>teleop_twist_joy</exec_depend>
<exec_depend>robot_localization</exec_depend>
<exec_depend>ugv_beast_description</exec_depend>
Edit ugv_beast_bringup/setup.py:
from setuptools import setup
import os
from glob import glob
package_name = 'ugv_beast_bringup'
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']),
(os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='your_name',
maintainer_email='you@example.com',
description='UGV Beast bringup with joystick teleop',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [],
},
)
Build / Run commands
1. Build workspace
cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash
If you added source ~/ros2_ws/install/setup.bash to ~/.bashrc, it will be loaded automatically in new terminals.
2. Run the UGV teleop launch
Plug your USB gamepad into the Raspberry Pi.
ros2 launch ugv_beast_bringup ugv_joy_teleop.launch.py
Expected console output:
– robot_state_publisher started.
– ros2_control_node started.
– spawner diff_drive_controller success.
– joy_node started, recognizes joystick.
– teleop_twist_joy_node started.
– ekf_filter_node started.
Step‑by‑step Validation
Step 1: Confirm joystick detection
In another terminal:
ros2 topic list | grep joy
You should see /joy. Check messages:
ros2 topic echo /joy
Move sticks and press buttons; values should change in real time.
If not:
– Check ls /dev/input/js*.
– Verify ros2 run joy joy_node logs for errors.
Step 2: Verify /cmd_vel from teleop_twist_joy
In a new terminal:
ros2 topic list | grep cmd_vel
Look for /cmd_vel and /diff_drive_controller/cmd_vel_unstamped.
Echo the cmd_vel:
ros2 topic echo /cmd_vel
Hold the enable_button (LB) and push the left stick forward:
– linear.x should be positive (e.g., ~0.3).
– angular.z ~0.
Push stick left:
– angular.z should be positive/negative depending on your mapping (typically left = positive).
Measure rate:
ros2 topic hz /cmd_vel
You should see ~20–50 Hz.
Success criterion: joystick movement while holding LB produces Twist messages with expected signs and at stable rate.
Step 3: Confirm diff_drive_controller subscription and odom
Check if controller is loaded:
ros2 control list_controllers
Should list diff_drive_controller in active state.
Check odometry:
ros2 topic list | grep odom
ros2 topic echo /odom
You should see nav_msgs/msg/Odometry with pose and twist. While moving with joystick, the pose changes.
Step 4: Inspect TF tree
Install tf2_tools:
sudo apt install -y ros-humble-tf2-tools
Generate frames diagram:
ros2 run tf2_tools view_frames
This creates frames.pdf in the current directory. Copy it to your laptop and open. You should see frames:
odombase_linkleft_wheel_linkright_wheel_link
Step 5: Visualize in RViz (optional, from a laptop)
On your laptop with ROS 2 Humble:
- Source ROS:
source /opt/ros/humble/setup.bash
- Set environment for multi‑machine (replace
PI_IPwith Raspberry Pi IP):
export ROS_DOMAIN_ID=0
export ROS_DISCOVERY_SERVER=
export ROS_LOCALHOST_ONLY=0
export ROS_TRANSPORT=tcp
# In most simple setups ROS 2 discovery is multicast-based; ensure same LAN/subnet.
- Run RViz:
rviz2
- Set Fixed Frame to
odom. - Add:
RobotModel(using/robot_description).TF.Odometry(/odom).
Drive with joystick and verify robot moves in RViz consistent with physical motion and odometry.
Step 6: Basic mapping with slam_toolbox (teleop driving)
On the Raspberry Pi (with UGV teleop still running and a 2D LiDAR attached, e.g., /dev/ttyUSB0, configured with rplidar_ros as /scan):
- Install rplidar_ros and set up udev rules (example):
sudo apt install -y ros-humble-rplidar-ros
Create udev rule /etc/udev/rules.d/99-rplidar.rules:
sudo nano /etc/udev/rules.d/99-rplidar.rules
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE:="0666", SYMLINK+="rplidar"
Reload:
sudo udevadm control --reload-rules
sudo udevadm trigger
Now LiDAR appears as /dev/rplidar. Launch:
ros2 launch rplidar_ros rplidar.launch.py serial_port:=/dev/rplidar
- Bring up slam_toolbox online:
ros2 launch slam_toolbox online_async_launch.py
-
Drive the robot with joystick around the room. Watch RViz (add
Mapdisplay on/map). -
Save the map when complete:
ros2 run nav2_map_server map_saver_cli -f ~/ugv_beast_map
Check files:
ls ~/ugv_beast_map*
You should see .pgm + .yaml. Map quality improves with slower, overlapping passes and minimal jerky motions.
Success criterion: produced map files and acceptable visual map in RViz.
Troubleshooting
Joystick issues
- No
/joytopic - Ensure
joy_nodestarted (checkros2 node list). - Confirm device:
ls /dev/input/js*. - Permissions: try running
sudo ros2 run joy joy_nodeto check if it’s a permission issue. - Buttons/axes mismatched
- Run
ros2 topic echo /joyand note which axis index changes for forward/back/left/right. - Adjust
axis_linearandaxis_angularandenable_buttoninteleop_joy.yaml.
diff_drive_controller not active
- Run:
ros2 control list_controllers
If diff_drive_controller is in unconfigured or inactive:
– Check ros2_control_node logs for errors (bad joint names, duplicated parameters).
– Confirm URDF joint names exactly match configuration.
Robot drives backwards or turns wrong way
- If pushing joystick forward moves robot backward:
- Reverse
linear.xsign by either:- Reversing motor wiring, or
- Setting
scale_linear: -0.3inteleop_joy.yaml.
- If turning left rotates robot right:
- Set
scale_angular: -1.0or swap wheel direction in your motor control layer.
Odometry drift / inaccurate distance
- If the robot travels less than the distance reported in
/odom: - Decrease
wheel_radiusortrack_widthuntil actual and odom distances match over a known distance (e.g., 2 m straight line test). - If robot rotates more or less than expected:
- Adjust
track_width.- Too small width → robot appears to rotate too slowly in odom.
- Too large width → robot appears to rotate too quickly.
CAN HAT problems (for future motor control)
- Check
dmesg | grep -i mcp2515for driver errors. - Verify link:
ip link show can0
- If missing, re-check
config.txtoverlays and SPIdtparam=spi=on. - Confirm HAT jumpers/termination resistors per Waveshare docs.
SLAM quality problems
- Make sure LiDAR topic is
/scanand frame has valid TF tobase_link. - Move slowly and avoid sudden jerks.
- Avoid highly reflective or featureless walls at first; map a cluttered room.
Improvements
Once basic joystick teleop is working, consider these extensions:
-
Real CAN‑based motor control
ReplaceGenericSystemin URDF with a real hardware plugin that sends velocity commands overcan0to your motor drivers. Implement aros2_controlhardware interface that reads encoder ticks and writes wheel velocities using SocketCAN. -
IMU integration
Add a physical IMU sensor (e.g., MPU‑9250). Publish/imu/data, configureekf.yamlproperly, and verify smoother orientation estimation during teleop. -
Autonomous navigation with Nav2
Use the joystick only to position the robot initially. Then: - Bring up
nav2_bringupwith your map. -
Send
NavigateToPosegoals from RViz and watch the robot follow paths. -
Safety features
Add a physical E‑stop that cuts motor power, and a ROS topic/emergency_stopthatdiff_drive_controllersubscribes to, overriding/cmd_velwhen asserted. -
Advanced teleop modes
Implement additional teleop modes (e.g., “cruise control”, predefined patterns) or integrate with a web joystick through WebSockets.
Final Checklist
Use this checklist to confirm that your ros2-joy-teleop-ugv setup on Raspberry Pi 4 Model B 4GB + Waveshare 2-CH CAN HAT (MCP2515) is working end‑to‑end:
- [ ] Ubuntu 22.04 64‑bit installed on Raspberry Pi 4; system updated.
- [ ] ROS 2 Humble with
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, andros-humble-teleop-twist-joyinstalled viaapt. - [ ] Workspace
~/ros2_wscreated;ugv_beast_descriptionandugv_beast_bringuppackages built successfully withcolcon build. - [ ] URDF defines
base_link,left_wheel_link,right_wheel_linkand continuous wheel joints with realisticwheel_radiusandtrack_width. - [ ]
diff_drive_controllerconfigured and active;/odompublished at ≥20 Hz. - [ ] Joystick recognized as
/dev/input/js0;/joytopic outputs correct axes/buttons. - [ ]
teleop_twist_joypublishes/cmd_velwhen holding the enable button and moving the stick, at ~20–50 Hz. - [ ]
/cmd_velcorrectly remapped to/diff_drive_controller/cmd_vel_unstamped. - [ ] Robot moves in the commanded direction (forward/back/left/right) with no inversions.
- [ ] TF tree contains at least
odom,base_link, and wheel links;view_framesPDF generated. - [ ] Optional: LiDAR publishes
/scan;slam_toolboxmaps the environment while you teleop the robot and you save the map viamap_saver_cli. - [ ] CAN HAT (MCP2515) is recognized as
can0and can be brought up, ready for future motor control integration.
With this in place, you have a reliable ros2‑joy‑teleop‑ugv baseline on the Raspberry Pi 4 + CAN HAT platform, ready for incremental upgrades toward full autonomous navigation.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.




