🤖️ 自主机器人“罗德尼”: 第一部分
Phil Hopley 著
Conmajia 译
2019 年 1 月 16 日原文发表于 CodeProject
2019 年 1 月 15 日 ( 中文版已获作者本人授权. ) , 本文是 House Bot 机器人操作系统的第一部分.
简介
罗德尼是我设计的一个自主家庭机器人. 这是这个项目系列的第一篇文章. 在这部分我主要阐述概念
背景
早在 1970
在我那两本启蒙读物中
![]() |
![]() |
我在 CodeProject 上看到过两篇关于机器人的文章
第一篇文章是
ROS
机器人操作系统 ( 为软件开发者创建机器人程序提供各种库函数和工具. 它提供了硬件的抽象 ) 设备驱动 , 库函数 , 可视化驱动 , 消息传递机制 , 分包管理等等内容. ROS 作为开源软件 , 通过 BSD 许可授权. ,
ROS 其实算不是一个真正的操作系统
第二篇文章是
所以
ROS 和树莓派
这里我将解释如何在项目中使用 ROS 和它提供的工具来测试代码. 我没打算写一篇 ROS 的教程
- 这是一个分布式系统
机器人代码可以在通过网络连接的多台设备上运行, - 一个节点
node( 负责执行某一任务) - 节点以功能包
package( 的形式组织) 作为一套文件夹或者文件的集合, - 可以用多种编程语言来设计节点
我用的是 C++ 和 Python, - 节点互相之间用称为话题
topic( 的单向流通信) - 话题属于即时消息
message( 消息是话题的数据结构) , - ROS 自带标准消息
也可以定义自己的消息结构, - 节点也可以通过服务器
/ 客户端 server/client( 的阻塞式协议) 使用服务, service( 进行通信) - 节点还可以通过动作
action( 协议进行非阻塞式面向目标任务的通信) - 所有节点向系统中唯一的主机
master( 注册) 即使用分布式系统, 主机也是唯一的, - 用 catkin 构建和编译代码
- 独立的节点可以用
rosrun
命令调用 也可以用启动工具在一个命令行下启用多个节点, - 系统包含了一个参数服务器
parameter server( 节点可以在运行过程中存取参数) , - 系统还包含检查
硬件仿真等多种工具、
既然决定了用树莓派作为处理器
ROS 的下载
罗德尼用到的其他外设有
- 7 寸触摸屏
- 摄像头模块
显示屏显示机器人的状态信息
这张是触摸屏的照片.stl
文件我已经打包出来


鉴于 ROS 可以在分布式网络上运行
机器人的任务
设计项目最基本的是确定需求. 对于罗德尼
帮我带个话
既然机器人可以认出家庭成员那么让它给某人带个话就很自然了. 我可以说 , 机器人之后帮我告诉某某下午六点来车站接我. , 即使他电话静音了或者正在嗨音乐 , 机器人也会跑到他的房间 , 找到他提醒他. ,
听起来不错
带个话给某人是一个大任务
- 使用摄像头观察环境
搜索并识别人脸, 找到后显示消息, 在脸上( ) - 面部识别和语音合成
这样罗德尼可以真正地带个话, - 通过键盘和摇杆遥控机器人移动
- 辅助导航的激光雷达或者类似的传感器
- 自主移动
- 安排任务完成后提示
对机器人来说
任务 1 , 目标 1
要完成这个设计目标
- 用 RC 舵机控制机器人的脑袋
摄像头( 平移和倾斜) - 读取树莓派摄像头拍摄的图片
- 检测识别人脸
- 依序控制这些动作
本文是第一篇文章
PiBorg, the UltraBorg 上有一种模块
ROS 社区有很多杰作可以应用rosserial_arduino
包
使用前rosserial_arduino
现在来编写控制各个舵机位置的 ROS 功能包. 这个包的节点将处理平移
第一条消息我用 ROS 内置的 sensor_msgs/JointState
实现. ROS 里标准的位置单位是弧度JointState
里有不少字段暂时都用不上
第二条消息定义了待操作舵机编号和角度. 这里我用了自定义的消息
现在来编写用于这两条消息的功能包
控制舵机的功能包消息我命名为 servo_msgs
. 创建好后.h
文件.
实现这个功能包的代码文件包存在 servo_msgs
文件夹下CmakeList.txt
和 package.xml
文件. 这些文件的意义以及如何创建功能包
msg
文件夹包含了消息的定义文件 servo_array.msg
# index references the servo that the angle is for, e.g. 0, 1, 2 or 3
# angle is the angle to set the servo to
uint8 index
uint16 angle
除了编程语法的一点区别
这样就完成了一个简单的 ROS 功能包. 接下来将实现用于平移pan_tilt
功能包. 这个包的文件都在 pan_tilt
文件夹下pan_tilt_node
.
有几个子文件夹config
包含了配置文件 config.yaml
# Configuration for pan/tilt devices
# In Rodney index0 is for the head and index 1 is spare
servo:
index0:
pan:
servo: 0
joint_name: 'head_pan'
tilt:
servo: 1
flip_rotation: true
max: 0.349066
min: -1.39626
joint_name: 'head_tilt'
index1:
pan:
servo: 2
tilt:
servo: 3
index0
和 index1
分别给两个平移
servo
指定负责当前关节的舵机编号joint_name
指定joint_state
消息里关节的名称max
和min
用于限制关节移动幅度 单位是弧度, flip_rotation
下文解释
ROS 惯例是右手定则flip_rotation
为真. pan_tilt_node
可以保证传给 Arduino 的舵机方向是正确的
cfg
文件夹下的 pan_tilt.cfg
文件用于动态配置服务器
#!/usr/bin/env python
PACKAGE = "pan_tilt"
from dynamic_reconfigure.parameter_generator_catkin import *
gen = ParameterGenerator()
gen.add("index0_pan_trim", int_t, 0, "Index 0 - Pan Trim", 0, -45, 45)
gen.add("index0_tilt_trim", int_t, 0, "Index 0 - Tilt Trim", 0, -45, 45)
gen.add("index1_pan_trim", int_t, 0, "Index 1 - Pan Trim", 0, -45, 45)
gen.add("index1_tilt_trim", int_t, 0, "Index 1 - Tilt Trim", 0, -45, 45)
exit(gen.generate(PACKAGE, "pan_tilt_node", "PanTilt"))
关于动态配置服务器详细内容
launch
文件夹包含了全部启动文件. 其中的 pan_tilt_test.launch
是测试专用的. 它实际上是个 XML 文件
<?xml version="1.0" ?>
<launch>
<rosparam command="load" file="$(find pan_tilt)/config/config.yaml" />
<node pkg="pan_tilt" type="pan_tilt_node" name="pan_tilt_node" output="screen" />
<node pkg="rosserial_python" type="serial_node.py" name="serial_node" output="screen" args="/dev/ttyUSB0" />
</launch>
关于启动文件的详细信息load
命令加载了配置文件
<rosparam command="load" file="$(find pan_tilt)/config/config.yaml" />
接下来执行了 pan_tilt
功能包的 pan_tilt_node
节点screen
<node pkg="pan_tilt" type="pan_tilt_node" name="pan_tilt_node" output="screen" />
最后运行 roserial
和 Arduino 进行通信. 我用的 Arduino Nano 是通过 USB 连接到 PC 的/dev/ttyUSB0
.
<node pkg="rosserial_python" type="serial_node.py" name="serial_node" output="screen" args="/dev/ttyUSB0" />
剩下的 include
和 src
文件夹里是功能包的 C++ 源码. pan_tilt_node.cpp
文件包含了 PanTiltNode
类的定义和程序的主函数.
主函数用 pan_tilt_node
初始化 ROS
int main(int argc, char **argv)
{
ros::init(argc, argv, "pan_tilt_node");
PanTiltNode *pan_tiltnode = new PanTiltNode();
dynamic_reconfigure::Server<pan_tilt::PanTiltConfig> server;
dynamic_reconfigure::Server<pan_tilt::PanTiltConfig>::CallbackType f;
f = boost::bind(&PanTiltNode::reconfCallback, pan_tiltnode, _1, _2);
server.setCallback(f);
std::string node_name = ros::this_node::getName();
ROS_INFO("%s started", node_name.c_str());
ros::spin();
return 0;
}
PanTiltNode
类在构造函数里加载参数服务器的参数
// 构造函数
PanTiltNode::PanTiltNode()
{
double max_radians;
double min_radians;
int temp;
/* 从参数服务器获取参数,如果获取失败,则使用默认值 */
// 指定舵机功能
n_.param("/servo/index0/pan/servo", pan_servo_[0], 0);
n_.param("/servo/index0/tilt/servo", tilt_servo_[0], 1);
n_.param("/servo/index1/pan/servo", pan_servo_[1], 2);
n_.param("/servo/index1/tilt/servo", tilt_servo_[1], 3);
// 检查舵机安装方式是否符合右手定则
n_.param("/servo/index0/pan/flip_rotation", pan_flip_rotation_[0], false);
n_.param("/servo/index0/tilt/flip_rotation", tilt_flip_rotation_[0], false);
n_.param("/servo/index1/pan/flip_rotation", pan_flip_rotation_[1], false);
n_.param("/servo/index1/tilt/flip_rotation", tilt_flip_rotation_[1], false);
/* 取值范围. 为了满足右手定则,这些值可能需要进行翻转. */
n_.param("/servo/index0/pan/max", max_radians, M_PI/2.0);
n_.param("/servo/index0/pan/min", min_radians, -(M_PI/2.0));
pan_max_[0] = (int)signedRadianToServoDegrees(max_radians, pan_flip_rotation_[0]);
pan_min_[0] = (int)signedRadianToServoDegrees(min_radians, pan_flip_rotation_[0]);
if(true == pan_flip_rotation_[0])
{
temp = pan_max_[0];
pan_max_[0] = pan_min_[0];
pan_min_[0] = temp;
}
n_.param("/servo/index0/tilt/max", max_radians, M_PI/2.0);
n_.param("/servo/index0/tilt/min", min_radians, -(M_PI/2.0));
tilt_max_[0] = (int)signedRadianToServoDegrees(max_radians, tilt_flip_rotation_[0]);
tilt_min_[0] = (int)signedRadianToServoDegrees(min_radians, tilt_flip_rotation_[0]);
if(true == tilt_flip_rotation_[0])
{
temp = tilt_max_[0];
tilt_max_[0] = tilt_min_[0];
tilt_min_[0] = temp;
}
n_.param("/servo/index1/pan/max", max_radians, M_PI/2.0);
n_.param("/servo/index1/pan/min", min_radians, -(M_PI/2.0));
pan_max_[1] = (int)signedRadianToServoDegrees(max_radians, pan_flip_rotation_[1]);
pan_min_[1] = (int)signedRadianToServoDegrees(min_radians, pan_flip_rotation_[1]);
if(true == pan_flip_rotation_[1])
{
temp = pan_max_[1];
pan_max_[1] = pan_min_[1];
pan_min_[1] = temp;
}
n_.param("/servo/index1/tilt/max", max_radians, M_PI/2.0);
n_.param("/servo/index1/tilt/min", min_radians, -(M_PI/2.0));
tilt_max_[1] = (int)signedRadianToServoDegrees(max_radians, tilt_flip_rotation_[1]);
tilt_min_[1] = (int)signedRadianToServoDegrees(min_radians, tilt_flip_rotation_[1]);
if(true == tilt_flip_rotation_[1])
{
temp = tilt_max_[1];
tilt_max_[1] = tilt_min_[1];
tilt_min_[1] = temp;
}
// 关节名
n_.param<std::string>("/servo/index0/pan/joint_name", pan_joint_names_[0], "reserved_pan0");
n_.param<std::string>("/servo/index0/tilt/joint_name", tilt_joint_names_[0], "reserved_tilt0");
n_.param<std::string>("/servo/index1/pan/joint_name", pan_joint_names_[1], "reserved_pan1");
n_.param<std::string>("/servo/index1/tilt/joint_name", tilt_joint_names_[1], "reserved_tilt1");
first_index0_msg_received_ = false;
first_index1_msg_received_ = false;
// 锁存已发布的节点
servo_array_pub_ = n_.advertise<servo_msgs::servo_array>("/servo", 10, true);
// 订阅话题
joint_state_sub_ = n_.subscribe("/pan_tilt_node/joints", 10, &PanTiltNode::panTiltCB, this);
}
调用 param
的时候会从参数服务器读取
n_.param("/servo/index0/pan_servo", pan_servo_[0], 0);
构造函数最后两行订阅了话题panTiltCB
// 移动关节的回调函数
void PanTiltNode::panTiltCB(const sensor_msgs::JointState& joint)
{
bool index0 = false;
bool index1 = false;
/* 在消息的列表里查找关节名. 位置(旋转)值均为正弧度值,符合右手定则,
* 需要根据舵机方向换算成角度值.
*/
for (unsigned int i = 0; i < joint.name.size(); i++)
{
// Is it one of the pan or tilt joints
if(pan_joint_names_[0] == joint.name[i])
{
// Index 0 平移
index0_pan_ = (int)signedRadianToServoDegrees(joint.position[i], pan_flip_rotation_[0]);
index0 = true;
}
else if(pan_joint_names_[1] == joint.name[i])
{
// Index 1 平移
index1_pan_ = (int)signedRadianToServoDegrees(joint.position[i], pan_flip_rotation_[1]);
index1 = true;
}
else if(tilt_joint_names_[0] == joint.name[i])
{
// Index 0 倾斜
index0_tilt_ = (int)signedRadianToServoDegrees(joint.position[i], tilt_flip_rotation_[0]);
index0 = true;
}
else if (tilt_joint_names_[1] == joint.name[i])
{
// Index 1 倾斜
index1_tilt_ = (int)signedRadianToServoDegrees(joint.position[i], tilt_flip_rotation_[1]);
index1 = true;
}
}
if(index0 == true)
{
first_index0_msg_received_ = true;
movePanTilt(index0_pan_, index0_tilt_, index0_pan_trim_, index0_tilt_trim_, 0);
}
if(index1 == true)
{
first_index1_msg_received_ = true;
movePanTilt(index1_pan_, index1_tilt_, index1_pan_trim_, index0_tilt_trim_, 1);
}
}
回调函数针对接收消息中的每个名字反复执行signedRadianToServoDegrees
函数按照 ROS 标准和方向对关节名关联的正值进行转化
随后movePanTilt
函数给对应的数值里加上微调偏移
void PanTiltNode::movePanTilt(int pan_value, int tilt_value, int pan_trim, int tilt_trim, int index)
{
int pan;
int tilt;
servo_msgs::servo_array servo;
pan = pan_trim + pan_value;
tilt = tilt_trim + tilt_value;
pan = checkMaxMin(pan, pan_max_[index], pan_min_[index]);
tilt = checkMaxMin(tilt, tilt_max_[index], tilt_min_[index]);
// 发送平移位置
servo.index = (unsigned int)pan_servo_[index];
servo.angle = (unsigned int)pan;
servo_array_pub_.publish(servo);
// 发送偏移位置
servo.index = (unsigned int)tilt_servo_[index];
servo.angle = (unsigned int)tilt;
servo_array_pub_.publish(servo);
}
这里设计了两个助手函数
int PanTiltNode::checkMaxMin(int current_value, int max, int min)
{
int value = current_value;
if (value > max)
{
value = max;
}
if (value < min)
{
value = min;
}
return (value);
}
第二个助手函数用来把 ROS 标准单位和方向换算成适合舵机的数值.
// 将正弧度值换算成舵机使用的角度值. 0 弧度相当于 90 度.
double PanTiltNode::signedRadianToServoDegrees(double rad, bool flip_rotation)
{
double retVal;
if(true == flip_rotation)
{
retVal = ((-rad/(2.0*M_PI))*360.0)+90.0;
}
else
{
retVal = ((rad/(2.0*M_PI))*360.0)+90.0;
}
return retVal;
}
动态参数服务器回调保存了微调参数movePanTilt
// 这个回调会在动态配置参数变化的时候执行
void PanTiltNode::reconfCallback(pan_tilt::PanTiltConfig &config, uint32_t level)
{
index0_pan_trim_ = config.index0_pan_trim;
index0_tilt_trim_ = config.index0_tilt_trim;
index1_pan_trim_ = config.index1_pan_trim;
index1_tilt_trim_ = config.index1_tilt_trim;
// 只有收到位置消息才执行
if(first_index0_msg_received_ == true)
{
// 用新微调值发送新消息
movePanTilt(index0_pan_, index0_tilt_, index0_pan_trim_, index0_tilt_trim_, 0);
}
if(first_index1_msg_received_ == true)
{
movePanTilt(index1_pan_, index1_tilt_, index1_pan_trim_, index1_tilt_trim_, 1);
}
}
pan_tilt_node.h
文件包含了 PanTiltNode
类定义.
完成了平移rosserial
例程作为模板来写的
setup
函数对节点进行了初始化loop
函数里spinOnce
spinOnce
实际上会执行 servo_cb
回调函数. 这个函数每次收到舵机消息时都会执行.
/*
* 基于 rosserial 舵机例程
* 最多可以控制 4 台舵机
* 节点订阅舵机话题,并作为 rodney_msgs::servo_array 消息运行.
* 消息包含两个元素,编号和角度.
* 编号范围:0-3
* 角度范围:0-180
*
* D5 -> PWM 输出口,舵机 2
* D6 -> PWM 输出口,舵机 1
* D9 -> PWM 输出口,舵机 0
* D10 -> PWM 输出口,舵机 3
*/
#if (ARDUINO >= 100)
#include <Arduino.h>
#else
#include <WProgram.h>
#endif
#include <Servo.h>
#include <ros.h>
#include <servo_msgs/servo_array.h>
/* 定义连接舵机的 PWM 端口 */
#define SERVO_0 9
#define SERVO_1 6
#define SERVO_2 5
#define SERVO_3 10
ros::NodeHandle nh;
Servo servo0;
Servo servo1;
Servo servo2;
Servo servo3;
void servo_cb( const servo_msgs::servo_array& cmd_msg)
{
/* Which servo to drive */
switch(cmd_msg.index)
{
case 0:
nh.logdebug("Servo 0 ");
servo0.write(cmd_msg.angle); //设置舵机 0 角度,范围 0-180
break;
case 1:
nh.logdebug("Servo 1 ");
servo1.write(cmd_msg.angle); //设置舵机 1 角度,范围 0-180
break;
case 2:
nh.logdebug("Servo 2 ");
servo2.write(cmd_msg.angle); //设置舵机 2 角度,范围 0-180
break;
case 3:
nh.logdebug("Servo 3 ");
servo3.write(cmd_msg.angle); //设置舵机 3 角度,范围 0-180
break;
default:
nh.logdebug("No Servo");
break;
}
}
ros::Subscriber<servo_msgs::servo_array> sub("servo", servo_cb);
void setup()
{
nh.initNode();
nh.subscribe(sub);
servo0.attach(SERVO_0); // 关联舵机输出引脚
servo1.attach(SERVO_1);
servo2.attach(SERVO_2);
servo3.attach(SERVO_3);
// Defaults
servo0.write(90);
servo1.write(120);
}
void loop(){
nh.spinOnce();
delay(1);
}
使用源码
上面的程序编译烧录到 Arduino 板子前rodney_ws
test_ws
在 PC 上编译 ROS 功能包
ROS 使用的是 catkin 编译环境
$ mkdir -p ~/test_ws/src
$ cd ~/test_ws/
$ catkin_make
把功能包文件夹 pan_tilt
servo_msgs
拷到 ~/test_ws/src
文件夹下并编译
$ cd ~/test_ws/
$ catkin_make
如果以上步骤没有出错
编译 Arduino ROS 库
编译 ros_lib
库的命令行如下
$ source ~/test_ws/devel/setup.bash
$ cd ~/Arduino/libraries
$ rm -rf ros_lib
$ rosrun rosserial_arduino make_libraries.py .
如果编译没有问题~/Arduino/libraries/ros_lib/servo_msgs
文件夹下会生成 servo_array.h
头文件.
编译并烧录 Arduino
把 rodney_control
文件夹复制到 ~/Arduino/Projects
下. 运行 Arduino IDErodney_control.ino
文件. 在工具→开发板
菜单里选择 Arduino 开发板型号工具→处理器
菜单里选择处理器型号
用 USB 线把 Arduino Nano 连接到 PC 上工具→端口
菜单里选择对应的端口/dev/ttyUSB0
点击上传
按钮
Arduino 电路
制作罗德尼的时候

为了测试

在树莓派上编译 ROS 功能包
还是用类似的命令
$ mkdir -p ~/rodney_ws/src
$ cd ~/rodney_ws/
$ catkin_make
把 pan_tilt
和 servo_msgs
文件夹复制到 ~/rodney_ws/src
然后编译
$ cd ~/rodney_ws/
$ catkin_make
如果无错
小提示
在 PC 和树莓派上运行 ROS 代码和工具时.bash
文件简化命令输入.
首先编辑 .bashrc
$ cd ~/ $ nano .bashrc
在文件最后添加 source /home/ubuntu/rodney_ws/devel/setup.bash
PC 在运行测试代码时需要知道 ROS 主机位置.bashrc
里我添加了下面的语句
alias rodney='source ~/test_ws/devel/setup.bash; \ export ROS_MASTER_URI=http://ubiquityrobot:11311'
一个 rodney
就可以一次运行上面两个命令
运行代码
一切准备就绪
$ cd ~/rodney_ws/
$ source devel/setup.bash
$ roslaunch pan_tilt pan_tilt_test.launch
终端上会显示
- 参数服务器的参数列表
- 节点列表
包括了, pan_tilt_node
和serial_node
- 主机地址
- 上面两个节点的启动过程
- 代码里的日志信息
这时就可以用 ROS 的工具来检查
$ cd ~/test_ws
$ source devel/setup.bash
如果节点是在同一设备上运行的
$ export ROS_MASTER_URI=http://ubiquityrobot:11311
现在可以运行图形工具了
$ rqt_graph

用这个工具可以看到节点的运行情况以及和 /servo
话题的连接情况. 图中可以看到 /pan_tilt_node/joints
话题.
现在在 PC 上打开一个终端rostopic
发送一条消息移动设备
$ cd ~/test_ws
$ source devel/setup.bash
$ export ROS_MASTER_URI=http://ubiquityrobot:11311
$ rostopic pub -1 /pan_tilt_node/joints sensor_msgs/JointState '{header: {seq: 0, stamp: {secs: 0, nsecs: 0},
frame_id: ""}, name: [ "head_pan","tilt_pan"], position: [0,0.349066], velocity: [], effort: []}'
最后一行命令会在 rostopic
里发布一个 /pan_tilt_node/joints
话题的实例sensor_msgs/JointState
消息类型
用 rostopic
命令要输入的东西有点多
$ rosrun rqt_gui rqt_gui
这个命令会运行一个图形界面

在装配各零件的时候
$ rostopic pub -1 /pan_tilt_node/joints sensor_msgs/JointState
'{header: {seq: 0, stamp: {secs: 0, nsecs: 0}, frame_id: ""},
name: [ "head_pan","tilt_pan"], position: [0,0], velocity: [], effort: []}'
在新终端里rqt_reconfigure
命令
$ cd ~/test_ws
$ source devel/setup.bash
$ export ROS_MASTER_URI=http://ubiquityrobot:11311
$ rosrun rqt_reconfigure rqt_reconfigure
这个命令会打开类似下面的窗口

调整到满意之后pan_tilt.cfg
配置文件里的默认值了
要关闭节点
平移/ 倾斜设备
平移

当然我用的是 3D 打印的零件. 考虑到显示器和树莓派的重量对舵机轴向压力


兴趣所在
这篇文章里
如果说现在罗德尼还只是一副躯壳

历史
- 首次发表
2018/07/28: - 第二版
2018/07/31 修正了: package.xml
的错误 - 第三版
2019/01/09 改用: sensor_msgs/JointState
许可
本文以及任何相关的源代码和文件都是根据 GNU
关于作者
Phil Hopley
if(jQuery('#no-reward').text() == 'true') jQuery('.bottom-reward').addClass('hidden');
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?