DiffBot Base Package - Low Level Approach
Having explained the two components of the base controller in DiffBot Base, the low-level firmware is implemented first, followed by the high-level hardware, detailed in the next section.
Implementing the low-level base controller for Remo
The low-level base controller is implemented on the Teensy microcontroller using
PlatformIO. The programming language in PlatformIO is
the same as Arduino (based on Wiring) and it is available as a plugin for the
Visual Studio Code editor. On the development PC, we can flash the robot
firmware to the board with this plugin. We will get the firmware code from the
diffbot_base
ROS package, located in the
scripts/base_controller
subfolder. Opening this folder in Visual Studio Code will recognize it as a
PlatformIO workspace because it contains the platformio.ini
file. This file
defines the required dependencies and makes it straightforward to flash the
firmware to the Teensy board after compilation. Inside this file, the used
libraries are listed:
1 2 3 |
|
As you can see, the firmware depends on rosserial
, the Adafruit Motor Shield
V2
library, and Wire
, an I2C library. PlatformIO allows using custom
libraries defined in the local ./lib
folder, which are developed in this
section.
The firmware is used to read from encoders and ultrasonic and IMU sensors, and
receive wheel velocity commands from the high-level
hardware_interface::RobotHW
class, discussed in the next section. The
following code snippets are part of the low-level base controller’s main.cpp
file and show the used libraries, found in
diffbot_base/scripts/base_controller
, in the lib
and src
folders. src
contains main.cpp
consisting of the setup()
and loop()
functions, common
to every Arduino sketch and starts off by including the following headers:
1 2 |
|
Besides the ros header file, it includes definitions specific to Remo, which are
defined in the diffbot_base_config.h
header. It contains constant parameter
values such as the following:
- Encoder pins: Defines to which pins on the Teensy microcontroller the Hall effect sensors are connected.
- Motor I2C address and pins: The Adafruit motor driver can drive four DC
motors. Due to cable management, motor terminals
M3
andM4
are used for the left and right motors, respectively. - PID: The tuned constants for both PID controllers of
base_controller
. - PWM_MAX and PWM_MIN: The minimum and maximum possible PWM values that can be sent to the motor driver.
- Update rates: Defines how often functions of
base_controller
are executed. For example, the control portion of the low-level base controller code reads encoder values and writes motor commands at a specific rate.
After including Remo-specific definitions, next follows the custom libraries in
the lib
folder:
1 2 3 4 |
|
These include directives and the libraries that get included with them are introduced next:
base_controller
: Defines theBaseController
template class, defined in thebase_controller.h
header, and acts as the main class to manage the two motors, including each motor’s encoder, and communicate with the high-level hardware interface.motor_controller_intf
: This library is indirectly included withadafruit_feather_wing.h
and defines an abstract base class, namedMotorControllerIntf
. It is a generic interface used to operate a single motor using arbitrary motor drivers. It is meant to be implemented by other specific motor controller subclasses and therefore avoids changing code in classes that know theMotorControllerIntf
interface and call itssetSpeed(int value)
method, such as done byBaseController
. The only requirement for this to work is for a subclass to inherit from thisMotorControllerIntf
interface and implement thesetSpeed(int value)
class method.adafruit_feather_wing
: This library, in themotor_controllers
folder, implements theMotorControllerIntf
abstract interface class and defines a concrete motor controller. For Remo, the motor controller is defined in theAdafruitMotorController
class. This class has access to the motor driver board and serves to operate the speed of a single motor, which is why two instances are created in themain.cpp
file.-
encoder
: This library is used in theBaseController
class and is based onEncoder.h
from https://www.pjrc.com/teensy/td_libs_Encoder.html that allows reading encoder tick counts from quadrature encoders, like the DG01D-E motors consist of. The encoder library also provides a methodjointState()
to directly obtain the joint state, which is returned by this method in theJointState
struct, that consists of the measured angular position (rad) and angular velocity (rad/s) of the wheel joints:1 2 3 4 5 6 7 8 9 10 11 12 13
diffbot::JointState diffbot::Encoder::jointState() { long encoder_ticks = encoder.read(); ros::Time current_time = nh_.now(); ros::Duration dt = current_time - prev_update_time_; double dts = dt.toSec(); double delta_ticks = encoder_ticks - prev_encoder_ticks_; double delta_angle = ticksToAngle(delta_ticks); joint_state_.angular_position_ += delta_angle; joint_state_.angular_velocity_ = delta_angle / dts; prev_update_time_ = current_time; prev_encoder_ticks_ = encoder_ticks; return joint_state_; }
-
pid
: Defines a PID controller to compute PWM signals based on the velocity error between measured and commanded angular wheel joint velocities. For more infos about PIDs and the tuning sections refer to PID Controllers
With these libraries, we look at the main.cpp
file. Inside it exists only a
few global variables to keep the code organized and make it possible to test the
individual components that get included. The main code is explained next:
-
First, we define the global ROS node handle, which is referenced in other classes, such as BaseController, where it is needed to publish, subscribe, or get the current time, using
ros::NodeHandle::now()
, to keep track of the update rates:1
ros::NodeHandle nh;
-
For convenience and to keep the code organized, we declare that we want to use the
diffbot
namespace, where the libraries of the base controller are declared:1
using namespace diffbot;
-
Next, we define two concrete motor controllers of type
AdafruitMotorController
found in themotor_controllers
library:1 2
AdafruitMotorController motor_controller_right = AdafruitMotorController(3); AdafruitMotorController motor_controller_left = AdafruitMotorController(4);
This class inherits from the abstract base class
MotorControllerIntf
, explained above. It knows how to connect to the Adafruit motor driver using its open sourceAdafruit_MotorShield
library (https://learn.adafruit.com/adafruit-stepper-dc-motor-featherwing/library-reference) and how to get a C++ pointer to one of its DC motors (getMotor(motor_num)
). Depending on the integer input value toAdafruitMotorController::setSpeed(int value)
, the DC motor is commanded to rotate in a certain direction and at a specified speed. For Remo, the range is between –255 and 255, specified by thePWM_MAX
andPWM_MIN
identifiers. -
The next class that is defined globally inside main is
BaseController
, which incorporates most of the main logic of this low-level base controller:1
BaseController<AdafruitMotorController, Adafruit_MotorShield> base_controller(nh, &motor_controller_left, &motor_controller_right);
As you can see, it is a template class that accepts different kinds of motor controllers (
TMotorController
, which equalsAdafruitMotorController
in the case of Remo) that operate on different motor drivers (TMotorDriver
, which equalsAdafruit_MotorShield
), using theMotorControllerIntf
interface as explained previously. TheBaseController
constructor takes a reference to the globally defined ROS node handle and the two motor controllers to let it set the commanded speeds computed through two separate PID controllers, one for each wheel.In addition to setting up pointers to the motor controllers, the
BaseController
class initializes two instances of typediffbot::Encoder
. Its measured joint state, returned fromdiffbot::Encoder::jointState()
, is used together with the commanded wheel joint velocities in thediffbot::PID
controllers to compute the velocity error and output an appropriate PWM signal for the motors.
After defining the global instances, the firmware’s setup()
function is
discussed next. The low-level BaseController class communicates with the
high-level interface DiffBotHWInterface
using ROS publishers and subscribers.
These are set up in the Basecontroller::setup()
method, which is called in the
setup()
function of main.cpp
. In addition to that, the
BaseController::init()
method is here to read parameters stored on the ROS
parameter server, such as the wheel radius and distance between the wheels.
Beside initializing BaseController
, the communication frequency of the motor
driver is configured:
1 2 3 4 5 6 |
|
The begin(uint16_t freq)
method of the motor controllers has to be called
explicitly in the main setup()
function because MotorControllerIntf
doesn't
provide a begin()
or setup()
method in its interface. This is a design
choice that, when added, would make the MotorControllerIntf
less generic.
After the setup()
function follows the loop()
function, to read from sensors
and write to actuators, which happens at specific rates, defined in the
diffbot_base_config.h
header. The bookkeeping of when these read/write
functionalities occurred is kept in the BaseController
class inside its
lastUpdateRates
struct. Reading from the encoders and writing motor commands
happens in the same code block as the control rate:
1 2 3 4 5 6 7 |
|
The following steps in this code block happen continuously at the control rate:
- Encoder sensor values are read through the
BaseController::read()
method and the data is published inside this method for the high-levelDiffbotHWInterface
class, on themeasured_joint_states
topic of message typesensor_msgs::JointState
. - The
BaseController
class subscribes toDiffBotHWInterface
from which it receives the commanded wheel joint velocities (topicwheel_cmd_velocities
, typediffbot_msgs::WheelsCmdStamped
) inside theBaseController::commandCallback(const diffbot_msgs::WheelsCmdStamped&)
callback method. InBaseController::read()
, the PID is called to compute the motor PWM signals from the velocity error and the motor speeds are set with the two motor controllers. - To keep calling this method at the desired control rate, the
lastUpdateTime().control
variable is updated with the current time.
After the control loop update block, if an IMU is used, its data could be read
at the imu rate and published for a node that fuses the data with the encoder
odometry to obtain more precise odometry. Finally, in the main loop()
, all the
callbacks waiting in the ROS callback queue are processed with a call to
nh.spinOnce()
.
MThis describes the low-level base controller. For more details and the complete
library code, please refer to the diffbot_base/scripts/base_controller
package.
In the following section, the diffbot::DiffBotHWInterface
class is described.
ROS Control high-level hardware interface for a differential drive robot
The ros_control meta package contains the
hardware interface class hardware_interface::RobotHW
, which needs to be
implemented to leverage many available controllers from the ros_controllers
meta package. First, we’ll look at the diffbot_base
node that instantiates and
uses the hardware interface:
-
The
diffbot_base
node includes thediffbot_hw_interface.h
header, as well as thecontroller_manager
, defined incontroller_manager.h
, to create the control loop (read, update, write):1 2 3
#include <ros/ros.h> #include <diffbot_base/diffbot_hw_interface.h> #include <controller_manager/controller_manager.h>
-
Inside the main function of this
diffbot_base
node, we define the ROS node handle, the hardware interface (diffbot_base::DiffBotHWInterface
), and pass it to thecontroller_manager
, so that it has access to its resources:1 2 3
ros::NodeHandle nh; diffbot_base::DiffBotHWInterface diffBot(nh); controller_manager::ControllerManager cm(&diffBot);
-
Next, set up a separate thread that will be used to service ROS callbacks. This runs the ROS loop in a separate thread as service callbacks can block the control loop:
1 2
ros::AsyncSpinner spinner(1); spinner.start();
-
Then define at which rate the control loop of the high-level hardware interface should run. For Remo, we choose 10 Hz:
1 2
ros::Time prev_time = ros::Time::now(); ros::Rate rate(10.0); rate.sleep(); // 10 Hz rate
-
Inside the blocking while loop of the
diffbot_base
node, we do basic bookkeeping to get the system time to compute the control period:1 2 3 4 5
while (ros::ok()) { const ros::Time time = ros::Time::now(); const ros::Duration period = time - prev_time; prev_time = time; ...
-
Next, we execute the control loop steps: read, update, and write. The
read()
method is here to get sensor values, whilewrite()
writes commands that were computed bydiff_drive_controller
during theupdate()
step:1 2 3 4 5
... diffBot.read(time, period); cm.update(time, period); diffBot.write(time, period); ...
-
These steps keep getting repeated with the specified rate using
rate.sleep()
.
After having defined the code that runs the main control loop of the
diffbot_base
node, we’ll take a look at the implementation of
diffbot::DiffBotHWInterface
, which is a child class of
hardware_interface::RobotHW
. With it, we register the hardware and implement
the read()
and write()
methods, used above in the control loop.
The constructor of the diffbot::DiffBotHWInterface
class is used to get
parameters from the parameter server, such as the diff_drive_controller
configuration from the diffbot_control
package. Inside the constructor, the
wheel command publisher and measured joint state subscriber are initialized.
Another publisher is pub_reset_encoders_
, which is used in the
isReceivingMeasuredJointStates
method to reset the encoder ticks to zero after
receiving measured joint states from the low-level base controller.
After constructing DiffBotHWInterface
, we create instances of
JointStateHandles
classes (used only for reading) and JointHandle
classes
(used for read, and write access) for each controllable joint and register them
with the JointStateInterface
and VelocityJointInterface
interfaces,
respectively. This enables the controller_manager
to manage access for joint
resources of multiple controllers. Remo uses DiffDriveController
and
JointStateController
:
1 2 3 4 5 6 7 |
|
The last step that is needed to initialize the hardware resources is to register
the JointStateInterface
and the VelocityJointInterface
interfaces with the
robot hardware interface itself, thereby grouping the interfaces together to
represent Remo robot in software:
1 2 |
|
Now that the hardware joint resources are registered and the controller manager
knows about them, it’s possible to call the read()
and write()
methods of
the hardware interface. The controller manager update happens in between the
read and write steps. Remo subscribes to the measured_joint_states
topic,
published by the low-level base controller. The received messages on this topic
are stored in the measured_joint_states_ array
of type
diffbot_base::JointState
using the measuredJointStateCallback
method, and
are relevant in the read()
method:
-
The
read()
method is here to update the measured joint values with the current sensor readings from the encoders – angular positions (rad) and velocities (rad/s):1 2 3 4 5
void DiffBotHWInterface::read() { for (std::size_t i = 0; i < num_joints_; ++i) { joint_positions[i] = measured_joint_states[i].angular_position; joint_velocity[i] = measured_joint_states[i].angular_velocity; }
-
The final step of the control loop is to call the
write()
method of theDiffBotHWInterface
class to publish the angular wheel velocity commands of each joint, computed bydiff_drive_controller
:1 2 3 4 5 6 7
void DiffBotHWInterface::write() { diffbot_msgs::WheelsCmdStamped wheel_cmd_msg; for (int i = 0; i < NUM_JOINTS; ++i) { wheel_cmd_msg.wheels_cmd.angular_velocities.joint.push_back(joint_velocity_commands_[i]); } pub_wheel_cmd_velocities_.publish(wheel_cmd_msg); }
In this method, it would be possible to correct for steering offsets due to model imperfections and slight differences in the wheel radii. See gain / trim model.
This concludes the important parts of the DiffBotHWInterface
class and enables
Remo to satisfy the requirements to work with the ROS Navigation Stack. In the
next section, we’ll look at how to bring up the robot hardware and how the
started nodes interact with each other.