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.




