继续树莓派小车的内容,这次记录手柄控制小车运动的实现。

1 方案设计

对于手动控制小车的工具,大概有这么几种:
①用红外遥控器,小车上放一个接收器,读取遥控器信息。实现应该比较简单,红外收发元件也很便宜,不过遥控器得对着小车,恐怕不太方便;
②蓝牙手柄,因为树莓派带蓝牙,可以通过蓝牙接收手柄数据,不过一个蓝牙手柄可不便宜;
③有线手柄,相比无线设备肯定low一点,不过我手头就只有一个有线手柄,50多块钱的小鸡G3,就是个xbox 360手柄;
④手机,如果写个带方向键的手机客户端来控制小车应该是最酷的了,不过我不会Android或IOS开发。

最终选择用有线手柄控制小车,而控制方式不是把手柄连到树莓派上,而是连到我的笔记本上,用C#写个客户端程序,一方面读取手柄数据,一方面与树莓派通信。至于通信,采用TCP通信,树莓派上运行服务器程序,接收控制信息,电脑上运行客户端程序,发送手柄数据。

2 手柄数据读取

现在主要的问题是怎么读取手柄数据呢?通过网络搜索,发现可以使用SharpDX库在.NET平台下读取手柄输入数据,SharpDX是一个全新的、开源的、封装了 DirectX API的项目(Wiki文档链接:http://sharpdx.org/ ,目前还不全),其支持的API包括2D和3D渲染、音频、设备输入等方面,貌似游戏开发中使用的挺多的。不过我不需要用到那么多功能,只需要使用XInput库就行,XInput支持XBox360手柄的数据读取。关于SharpDX及XInput的教程或文档有点难找,Wiki上的官方文档只是接口说明,而且现在还不全;微软官方文档上可以找到XInput的相关文档,不过它是针对C++的;我在Stack Overflow找到一个回答,介绍了SharpDX.XInput的简单使用方法(https://stackoverflow.com/questions/39109609/how-to-use-xbox-one-controller-in-c-sharp-application/39109610#39109610)。

首先需要下载SharpDX.XInput库,可以在Visual Studio的Nuget包管理器里直接下,不过如果直接下SharpDX.XInput会提示需要依赖SharpDX库,因此先下SharpDX库。下好SharpDX后再下载SharpDX.XInput竟然还是报错,说缺少SharpDX依赖,这时可以先卸载Nuget管理器再重安装(“工具”->“扩展和更新”),这样就可以顺利下载了。

添加XInput的引用using SharpDX.XInput;,然后可以写一个手柄控制类:

 class XInputController
   {
        Controller controller;
        Gamepad gamepad;
        public bool connected = false;

        public XInputController()
        {
            controller = new Controller(UserIndex.One);
            connected = controller.IsConnected;
        }

        /// <summary>
        /// 读取方向键信息
        /// </summary>
        /// <returns></returns>
        public string GetDirection()
        {
            if (!controller.IsConnected)
                return null;
            gamepad = controller.GetState().Gamepad;

            GamepadButtonFlags flag = gamepad.Buttons;
            int resultStart = ((int)flag) & 0x10;
            int resultBack = ((int)flag) & 0x20;
            int resultUp=((int)flag) & 0x01;
            int resultDown=((int)flag) & 0x02;
            int resultLeft=((int)flag) & 0x04;
            int resultRight=((int)flag) & 0x08;
            if (resultStart != 0)
                return "start";
            else if (resultBack != 0)
                return "back";
            else if (resultUp != 0)
                return "up";
            else if (resultDown != 0)
                return "down";
            else if (resultLeft != 0)
                return "left";
            else if (resultRight != 0)
                return "right";
            else
                return "undefine";

        }
    }

这个类代码很短,因为我只需要读取方向键、start和back键,如果要读取摇杆和震动数据的话就需要麻烦一些了。Controller对象就代表了手柄设备,初始化的时候指定了设备号为“UserIndex.One”,总共好像可以有四个设备;Gamepad对象存储了手柄当前的状态信息,其中有一个结构体为GamepadButtonFlags属性,它以二进制位的形式存储了按键信息,比如0x0001就表示“上”方向键处于按下状态,0x0002就表示“下”方向键处于按下状态,在上述代码中,我读取Buttons属性,判断每一位的状态来检测哪个键被按下,如果是没有键被按下或者除了方向键、start和back键外的键被按下,就返回“undefine”。

3 PC端程序

新建一个Winform项目,编写PC客户端程序。因为要做TCP客户端,需要使用socket相关类;另外也需要考虑读取手柄信息的方式,是事件机制还是主动轮询,怎么处理手柄的事件我还不知道怎么写,我采用定时器的方式主动查询手柄状态。下图是PC端程序的流程图。

下面是程序主体代码:

 public partial class HandleControlForm : Form
    {
        private TcpClient client = null;
        private NetworkStream streamToServer = null;
        private XInputController controller = null;
        public HandleControlForm()
        {
            InitializeComponent();
            this.button1.Enabled = false;
            this.timer1.Interval = 100;
            this.timer1.Stop();
        }

        private void HandleControlForm_Load(object sender, EventArgs e)
        {
            //手柄初始化
            controller = new XInputController();
            if (controller.connected)
            {
                this.textBox1.Text = "the handle has connected...\r\n";
                this.button1.Enabled = true;
            }
        }
        /// <summary>
        /// 启动
        /// </summary>
        private void button1_Click(object sender, EventArgs e)
        {
            //连接小车
            client = new TcpClient();
            try
            {
                client.Connect(IPAddress.Parse("192.168.1.17"), 5150);
            }
            catch
            {
                this.textBox1.Text += "connect to the car falled...\r\n";
                this.button1.Enabled = false;
                return;
            }
            this.textBox1.Text += "connect to the car successfully...\r\n";
            streamToServer = client.GetStream();
            this.timer1.Start();//开始接收手柄输入
            this.button1.Enabled = false;
        }
        /// <summary>
        /// 定时器处理
        /// </summary>
        private void timer1_Tick(object sender, EventArgs e)
        {
            string input = null;
            input = controller.GetDirection();
            if (input == null)
            {
                this.textBox1.Text += "the handle disconnect...\r\n";
                return;
            }
            this.textBox1.Text += input + "\r\n";
            //向小车发送控制指令
            byte[] buffer = Encoding.UTF8.GetBytes(input);
            streamToServer.Write(buffer, 0, buffer.Length);
            if (input == "back")
            {
                //关闭
                this.timer1.Stop();
                streamToServer.Close();
                client.Close();
                streamToServer = null;
                client = null;
                this.button1.Enabled = true;
                this.textBox1.Text += "shut down the connection...\r\n";
            }
        }

        private void textBox1_TextChanged(object sender, EventArgs e)
        {
            this.textBox1.SelectionStart = this.textBox1.Text.Length;
            this.textBox1.ScrollToCaret();
        }
    }

在发送数据到小车的代码中,是把字符串编码为UTF-8格式的,一开始我用的UNICODE编码,结果树莓派上的Python程序接收乱码,因为Python中的网络数据解析默认是按照UTF-8格式的。下图是程序界面,用了一个文本框显示程序信息。

4 树莓派程序

树莓派上的Python程序主要由电机控制模块和TCP服务器代码组成,程序流程如下图所示:

Python代码如下:

#! /usr/bin/python3

import socket
import re
import Motor_Module

print("init motor module...")
try:
    motor=Motor_Module.Motor_Module()
    motor.setup()
except:
    print("init motor module fail...")
    exit()
server=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
host='192.168.1.17'
port=5150
try:
    server.bind((host,port))
except:
    print("socket bind fail...")
    exit()
server.listen(5)
print("listening for connect...")
client,addr=server.accept()
print("Accept the connect , now receving commands...")
duration=1
#不断接收指令
while True:
    data=client.recv(1024)
    info=bytes.decode(data) #解码字符串
    if re.match('back',info):
        break
    elif re.match('up',info):
        print("move ahead")
        motor.ahead()
    elif re.match('down',info):
        print("move rear")
        motor.rear()
    elif re.match('left',info):
        print("move left")
        motor.left()
    elif re.match('right',info):
        print("move right")
        motor.right()
    elif re.match('undefine',info):
        print("no control command")
        motor.stop()
print("end the connection...")
motor.stop()
client.close()

在接收处理的代码中, 我用match方法匹配相应的关键词而不是直接用等于号,是因为TCP传输中可能会出现拆包、黏包等现象,用match方法稳妥一些;对于“undefine”指令,直接停止小车,而受到“back”指令时终止程序。

5 测试

首先启动树莓派上的程序监听连接,然后启动PC端程序,手柄需要提前连接上,否则PC程序上的按钮不可用;由于程序用定时器轮询手柄,并且设置了“undefine”指令,小车可实现随时停止的效果,长按方向键可以持续运行,松开按钮小车立即停止;此外,可以在树莓派上插上摄像头,后台运行mjpg-streamer,在PC端网页查看实时视频,这样就可以实现简易的远程控制了。