软件需求分析

  1. 能通过局域网远程控制树莓派小车
  2. 能够实时传输树莓派摄像头的画面

软件设计

设定树莓派ip地址功能

通过一个Dialog控件来实现树莓派ip信息的填写

远程控制功能

设置四个按钮分别控制小车前后左右运动,再设置四个按钮控制摄像头舵机前后左右运动,通过添加按钮监听并使用socket网络编程实现命令的发送。

视频画面传输功能

通过添加视频流到视频显示在WebView控件中

关于如何获取树莓派的视频流:

https://hash070.top/mjpg-streamer.html

关于如何将视频流放在WebView中:

https://hash070.top/android-webview-playvideo.html

软件需求实现

视频传输功能实现

申请网络权限并声明使用明文网络流量

视频网络传输功能需要访问网络,并且因为视频流使用的是http流量,从Android 9.0(API级别28)开始,默认情况下限制了明文流量的网络请求,对未加密流量不再信任,直接放弃请求,因此http的url均无法在webview中加载,https 不受影响,所以需要手动声明使用明文流量传输。

修改Mainfest.xml

<uses-permission android:name="android.permission.INTERNET" />

    <application
...
        android:hardwareAccelerated ="true"
<!--        开启硬件加速-->
        android:usesCleartextTraffic="true"
...

    </application>

添加WebView到布局文件中

    <WebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        >

在Activity中初始化WebView信息

        super.onCreate(savedInstanceState);
        //去除标题栏
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        //去除状态栏
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);
        setContentView(R.layout.activity_main);
        setWebView(ip,mjpg_port);

远程控制功能实现

在布局中添加按钮

    <ImageButton
        android:id="@+id/left"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        android:layout_marginLeft="40dp"
        android:layout_marginBottom="50dp"
        android:src="@drawable/leftarr" />
    <ImageButton
        android:id="@+id/right"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintLeft_toRightOf="@id/left"
        app:layout_constraintTop_toTopOf="@id/left"
        android:layout_marginLeft="70dp"
        android:src="@drawable/rightarr" />
    <ImageButton
        android:id="@+id/up"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintBottom_toTopOf="@+id/left"
        app:layout_constraintLeft_toRightOf="@+id/left"
        android:src="@drawable/uparr" />
    <ImageButton
        android:id="@+id/down"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintTop_toBottomOf="@id/left"
        app:layout_constraintLeft_toRightOf="@id/left"
        android:src="@drawable/downarr" />
    <ImageButton
        android:id="@+id/settings"
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:src="@drawable/settings"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="20dp"
        android:layout_marginRight="40dp"
        android:onClick="settings" />
    <ImageButton
        android:id="@+id/s_right"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginRight="40dp"
        android:layout_marginBottom="50dp"
        android:src="@drawable/rightarr"
        android:onClick="s_right" />
    <ImageButton
        android:id="@+id/s_left"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintRight_toLeftOf="@+id/s_right"
        app:layout_constraintTop_toTopOf="@+id/s_right"
        android:layout_marginRight="70dp"
        android:src="@drawable/leftarr"
        android:onClick="s_left" />
    <ImageButton
        android:id="@+id/s_up"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintBottom_toTopOf="@+id/s_right"
        app:layout_constraintRight_toLeftOf="@+id/s_right"
        android:src="@drawable/uparr"
        android:onClick="s_up" />
    <ImageButton
        android:id="@+id/s_down"
        android:layout_width="70dp"
        android:layout_height="50dp"
        app:layout_constraintTop_toBottomOf="@+id/s_right"
        app:layout_constraintRight_toLeftOf="@+id/s_right"
        android:src="@drawable/downarr"
        android:onClick="s_down" />

添加按钮监听

根据遥控小车的软件需求,软件需要监听按钮的按下事件和松开事件,分别发送运动和停止信号。

先自定义按钮的监听方法,通过重写onTouch方法来实现按下和松开实现分别监听,同时为了防止按下和松开太快,在发送命令的代码中添加了一小段等待时间。为防止篇幅过长,马达和舵机的控制方法分别只放一个。

    private View.OnTouchListener upListener = new View.OnTouchListener() {//前进按钮监听
        @Override
        public boolean onTouch(View arg0, MotionEvent event) {
            int action = event.getAction();
            if (action == MotionEvent.ACTION_DOWN) {
                // 按下 todo 处理相关逻辑
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            sendData = ("1,50,0,0,0,1").getBytes();
                            datagramPacket = new DatagramPacket(sendData, sendData.length, addr);
                            s.send(datagramPacket);
                            Log.d(TAG, "run: 1,50");
                            Thread.sleep(100);
                        } catch (IOException | InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            } else if (action == MotionEvent.ACTION_UP) {
                // 松开 todo 处理相关逻辑
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            sendData = ("0,0,0,0,0,1").getBytes();
                            datagramPacket = new DatagramPacket(sendData, sendData.length, addr);
                            s.send(datagramPacket);
                            Log.d(TAG, "run: 0,0");
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }
            return false;
        }
    };

    public void s_right(View view) {//舵机右转按钮监听
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    sendData = ("0,0,1,1,20,2").getBytes();
                    datagramPacket = new DatagramPacket(sendData, sendData.length, addr);
                    s.send(datagramPacket);
                    Log.d(TAG, "右转20度");
                    Thread.sleep(100);
                } catch (IOException | InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

然后将监听添加到按钮上就行了

        up.setOnTouchListener(upListener);
        down.setOnTouchListener(downListener);
        left.setOnTouchListener(leftListener);
        right.setOnTouchListener(rightListener);

添加树莓派小车ip设置和记忆功能

要控制树莓派小车,就需要指定树莓派ip,这里我选择将一个自定义的layout插入到AlertDialog中,实现ip和端口信息的修改。

并利用SharedPreferences保存和读取树莓派ip和端口信息,相关文章:https://hash070.top/android-sharedpreferences.html

首先新建一个布局文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">
    <TextView
        android:id="@+id/t1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="树莓派ip"
        android:textSize="20dp" />

    <EditText
        android:id="@+id/ip_addr"
        android:background="@drawable/mid_edittext_bg"
        android:alpha="0.9"
        android:layout_width="200dp"
        android:layout_height="42dp"
        android:inputType="text"
        android:gravity="center"
        android:maxLines="1" />
    <TextView
        android:id="@+id/t2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="树莓派端口号"
        android:textSize="20dp" />

    <EditText
        android:id="@+id/port"
        android:background="@drawable/mid_edittext_bg"
        android:alpha="0.9"
        android:layout_width="200dp"
        android:layout_height="42dp"
        android:inputType="text"
        android:gravity="center"
        android:maxLines="1" />

</LinearLayout>

然后创建按钮监听方法

    public void settings(View view) {
        layoutInflater = getLayoutInflater();
        View v = layoutInflater.inflate(R.layout.setting_layout, null);
        EditText ip_addr = (EditText) v.findViewById(R.id.ip_addr);
        EditText car_port = (EditText) v.findViewById(R.id.port);
        ip_addr.setText(ip);
        car_port.setText(String.valueOf(port));
        AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
        builder.setView(v);
        builder.setPositiveButton("确定", null);
        Display defaultDisplay = getWindowManager().getDefaultDisplay();
        AlertDialog alertDialog = builder.create();
        Window window = alertDialog.getWindow();
        window.setGravity(Gravity.BOTTOM);
        alertDialog.show();
        alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (ip_addr.getText().toString().length() != 0 && car_port.getText().toString().length() != 0) {
                    Log.i(TAG, "onClick:" + ip_addr.getText().toString());
                    Log.i(TAG, "onClick:" + car_port.getText().toString());
                    SharedPreferences data = getSharedPreferences("data", Context.MODE_PRIVATE);
                    data.edit()
                            .putString("ip", ip_addr.getText().toString())
                            .putInt("port", Integer.parseInt(car_port.getText().toString()))
                            .apply();//写入
                    initUDP();//刷新UDP连接信息
                    alertDialog.cancel();
                } else {
                    Toast.makeText(MainActivity.this, "ip或端口不能为空", Toast.LENGTH_SHORT).show();
                }
            }
        });
    }

最后在按钮上加上监听,大功告成

        android:onClick="settings"

是不是很简单呢

添加功能:小车速度控制

首先在布局中添加两个按钮

    <ImageButton
        android:id="@+id/speed_plus"
        android:layout_width="50dp"
        android:layout_height="50dp"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginRight="130dp"
        android:layout_marginTop="20dp"
        android:src="@drawable/add"
        android:onClick="inc_speed" />
    <TextView
        android:id="@+id/speed_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintRight_toLeftOf="@id/speed_down"
        app:layout_constraintTop_toTopOf="@+id/speed_down" />

然后再修改原代码,利用SharedPreferences增加速度记忆功能,并添加速度增加和速度减少按钮监听。

    public void inc_speed(View view) {//速度增加
        SharedPreferences data = getSharedPreferences("data", Context.MODE_PRIVATE);
        if (running_speed>0&&running_speed<=100){
            if (running_speed<100) running_speed+=10;//最高速度为100
            data.edit()
                    .putInt("speed", running_speed)
                    .apply();//写入
        }
        speed_text.setText("当前速度为:"+running_speed);
    }

    public void desc_speed(View view) {//速度减少
        SharedPreferences data = getSharedPreferences("data", Context.MODE_PRIVATE);
        if (running_speed>0&&running_speed<=100){
            if (running_speed>20) running_speed-=10;//最低速度为20
            data.edit()
                    .putInt("speed", running_speed)
                    .apply();//写入
        }
        speed_text.setText("当前速度为:"+running_speed);
    }

添加功能:远程ssh重启树莓派客户端

可能是舵机代码和马达运动代码有冲突,有一定概率导致该python程序运行出现问题,这里利用jsch远程ssh到树莓派后台来执行重启客户端的命令,下面这个是jsch调用的代码

    public ArrayList<String> execSSH(String command){
        ArrayList<String> output = new ArrayList<>();
        try{
            JSch jsch=new JSch();
            Session session = jsch.getSession(sshName,ip, sshPort);
            session.setPassword(sshPw);
            // username and password will be given via UserInfo interface.
            session.setUserInfo(new MyUserInfo());
            session.setConfig("StrictHostKeyChecking", "no");
            session.connect();
            Channel channel=session.openChannel("exec");
            ((ChannelExec)channel).setCommand(command);
            channel.setInputStream(null);
            ((ChannelExec)channel).setErrStream(System.err);
            InputStream in=channel.getInputStream();
            channel.connect();
            byte[] tmp=new byte[1024];
            while(true){
                while(in.available()>0){
                    int i=in.read(tmp, 0, 1024);//返回的是读取的长度
                    if(i<0)break;
                    System.out.print(new String(tmp, 0, i));
                    output.add(new String(tmp, 0, i));
                }
                if(channel.isClosed()){
                    if(in.available()>0) continue;
                    System.out.println("exit-status: "+channel.getExitStatus());
                    break;
                }
                try{Thread.sleep(1000);}catch(Exception ee){}
            }
            channel.disconnect();
            session.disconnect();
        }
        catch(Exception e){
            System.out.println(e);
        }
        return output;
    }

    public ArrayList<String> execSSH(String command){
        ArrayList<String> output = new ArrayList<>();
        try{
            JSch jsch=new JSch();
            Session session = jsch.getSession(sshName,ip, sshPort);
            session.setPassword(sshPw);
            // username and password will be given via UserInfo interface.
            session.setUserInfo(new MyUserInfo());
            session.setConfig("StrictHostKeyChecking", "no");
            session.connect();

//            String command="ifconfig";

            Channel channel=session.openChannel("exec");
            ((ChannelExec)channel).setCommand(command);

            // X Forwarding
            // channel.setXForwarding(true);

            //channel.setInputStream(System.in);
            channel.setInputStream(null);

            //channel.setOutputStream(System.out);



    private class MyUserInfo implements UserInfo {
        @Override
        public String getPassphrase() {
            System.out.println("getPassphrase");
            return null;
        }
        @Override
        public String getPassword() {
            System.out.println("getPassword");
            return null;
        }
        @Override
        public boolean promptPassword(String s) {
            System.out.println("promptPassword:"+s);
            return false;
        }
        @Override
        public boolean promptPassphrase(String s) {
            System.out.println("promptPassphrase:"+s);
            return false;
        }
        @Override
        public boolean promptYesNo(String s) {
            System.out.println("promptYesNo:"+s);
            return true;//notice here!
        }
        @Override
        public void showMessage(String s) {
            System.out.println("showMessage:"+s);
        }
    }

按钮执行方法

1:设置ssh密码的按钮监听方法

    public void fix_settings(View view) {//ssh连接设置
        layoutInflater = getLayoutInflater();
        View v = layoutInflater.inflate(R.layout.fix_setting_layout, null);
        EditText fix_name = (EditText) v.findViewById(R.id.fix_name);
        EditText fix_pw = (EditText) v.findViewById(R.id.fix_pw);
        fix_name.setText(sshName);
        fix_pw.setText(sshPw);
        AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
        builder.setView(v);
        builder.setPositiveButton("确定", null);
        Display defaultDisplay = getWindowManager().getDefaultDisplay();
        AlertDialog alertDialog = builder.create();
        Window window = alertDialog.getWindow();
        window.setGravity(Gravity.BOTTOM);
        alertDialog.show();
        alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (fix_name.getText().toString().length() != 0 && fix_pw.getText().toString().length() != 0) {
                    Log.i(TAG, "onClick:" + fix_name.getText().toString());
                    Log.i(TAG, "onClick:" + fix_pw.getText().toString());
                    SharedPreferences data = getSharedPreferences("data", Context.MODE_PRIVATE);
                    data.edit()
                            .putString("sshname", fix_name.getText().toString())
                            .putString("sshpw", fix_pw.getText().toString())
                            .apply();//写入
                    initUDP();//刷新UDP连接信息
                    alertDialog.cancel();
                } else {
                    Toast.makeText(MainActivity.this, "登录名和密码不能为空", Toast.LENGTH_SHORT).show();
                }
            }
        });
    }

2:执行重启服务命令的按钮监听方法

    public void fix_error(View view) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                execSSH("sudo systemctl restart carcil.service");
            }
        }).start();
    }

效果展示:

首先,我手动关闭了客户端相关的服务以模拟程序出错的情况,可以看到小车一开始是无法操控的,然后我使用修复按钮先设置树莓派登录信息,然后重启树莓派客户端的客户端服务修复了程序错误。

树莓派端代码

马达和舵机的树莓派代码

#!/usr/bin/env python
# coding=UTF-8
import Adafruit_PCA9685
import RPi.GPIO as GPIO
import time
import socket
import sys

reload(sys)
sys.setdefaultencoding('utf8')

PWMA = 18
AIN1 = 22
AIN2 = 27

PWMB = 23
BIN1 = 25
BIN2 = 24


# 舵机摇头代码
class CarServo:
    def __init__(self):
        # 2个摄像头舵机,1个超声波舵机
        self.pwm_pca9685 = Adafruit_PCA9685.PCA9685()
        self.pwm_pca9685.set_pwm_freq(50)

        self.servo = {}

        self.set_servo_angle(0, 110)
        self.set_servo_angle(1, 100)
        self.set_servo_angle(2, 20)

    # 输入角度转换成12^精度的数值
    def set_servo_angle(self, channel, angle):
        if (channel >= 0) and (channel <= 2):
            new_angle = angle
            if angle < 0:
                new_angle = 0
            elif angle > 180:
                new_angle = 180
            else:
                new_angle = angle
            print("channel={0}, angle={1}".format(channel, new_angle))
            # date=4096*((new_angle*11)+500)/20000#进行四舍五入运算 date=int(4096*((angle*11)+500)/(20000)+0.5)
            date = int(4096 * ((new_angle * 11) + 500) / (20000) + 0.5)
            self.pwm_pca9685.set_pwm(channel, 0, date)
            self.servo[channel] = new_angle
        else:
            print("set_servo_angle error. servo[{0}] = [{1}]".format(channel, angle))

    def inc_servo_angle(self, channel, v):
        self.set_servo_angle(channel, self.servo[channel] + v)

    def dec_servo_angle(self, channel, v):
        self.set_servo_angle(channel, self.servo[channel] - v)


def t_up(speed):
    L_Motor.ChangeDutyCycle(speed)
    GPIO.output(AIN2, False)  # AIN2
    GPIO.output(AIN1, True)  # AIN1
    # up
    R_Motor.ChangeDutyCycle(speed)
    GPIO.output(BIN2, False)  # BIN2
    GPIO.output(BIN1, True)  # BIN1


def t_down(speed):
    L_Motor.ChangeDutyCycle(speed)
    GPIO.output(AIN2, True)  # AIN2
    GPIO.output(AIN1, False)  # AIN1

    R_Motor.ChangeDutyCycle(speed)
    GPIO.output(BIN2, True)  # BIN2
    GPIO.output(BIN1, False)  # BIN1


def t_left(speed):
    L_Motor.ChangeDutyCycle(speed)
    GPIO.output(AIN2, True)  # AIN2
    GPIO.output(AIN1, False)  # AIN1

    R_Motor.ChangeDutyCycle(speed)
    GPIO.output(BIN2, False)  # BIN2
    GPIO.output(BIN1, True)  # BIN1


def t_right(speed):
    L_Motor.ChangeDutyCycle(speed)
    GPIO.output(AIN2, False)  # AIN2
    GPIO.output(AIN1, True)  # AIN1

    R_Motor.ChangeDutyCycle(speed)
    GPIO.output(BIN2, True)  # BIN2
    GPIO.output(BIN1, False)  # BIN1


def t_stop():
    L_Motor.ChangeDutyCycle(0)
    GPIO.output(AIN2, False)  # AIN2
    GPIO.output(AIN1, False)  # AIN1
    # stop
    R_Motor.ChangeDutyCycle(0)
    GPIO.output(BIN2, False)  # BIN2
    GPIO.output(BIN1, False)  # BIN1


GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(AIN2, GPIO.OUT)
GPIO.setup(AIN1, GPIO.OUT)
GPIO.setup(PWMA, GPIO.OUT)

GPIO.setup(BIN1, GPIO.OUT)
GPIO.setup(BIN2, GPIO.OUT)
GPIO.setup(PWMB, GPIO.OUT)

L_Motor = GPIO.PWM(PWMA, 100)
L_Motor.start(0)

R_Motor = GPIO.PWM(PWMB, 100)
R_Motor.start(0)

cs = CarServo()

# 下面是命令接收代码
ip_port = ('', 7878)  # 留空才能接收来自局域网的UDP连接
sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)  # 创建套接字
sk.bind(ip_port)  # 绑定服务地址
print('启动socket服务,等待客户端连接...')
try:
    while True:
        client_data = sk.recv(1024).strip().decode()  # 接收遥控命令,使用字符串分割来接收多个命令参数
        print("客户端向你发来信息:%s" % (client_data))
        order_list = client_data.split(',')  # 将接收到的命令通过逗号分割
        o = int(order_list[0])  # 第一个为小车的运行方向
        speed = int(order_list[1])  # 第二个为小车的运行速度
        iod = int(order_list[2])  # 第三个参数为判断increase_channel(0)或decrease_channel(1)
        channel = int(order_list[3])  # 第四个参数为了获得channel的值
        angel = int(order_list[4])  # 第五个参数为获得angel的值
        type = int(order_list[5])  # 第六个参数用于判断是接受到了马达命令还是舵机命令,0马达,1舵机,这样就不怕马达和舵机命令相互干扰了
        # 马达命令示例:1,50,0,0,0,1 以50的速度前进
        # 舵机命令示例:0,0,0,1,20,2 舵机向左转动20度
        if type == 1:  # 如果收到了马达命令
            if speed == 0:  # 如果没有收到speed参数,或speed为0,则调整为50
                speed = 50
            if o == 0:  # 0停止小车
                t_stop()
            elif o == 1:  # 1前进
                t_up(speed)
            elif o == 2:  # 2后退
                t_down(speed)
            elif o == 3:  # 3左转
                t_left(speed)
            elif o == 4:  # 4右转
                t_right(speed)
        if type == 2:  # 如果收到了舵机命令
            if iod == 0:  # increase_channel:1左2下
                cs.inc_servo_angle(channel, angel)
            elif iod == 1:  # decrease_channel:1右2上
                cs.dec_servo_angle(channel, angel)

finally:
    GPIO.cleanup()
    sk.close()

视频传输部分使用MJPG-streamer,启动MJPG-streamer即可

https://hash070.top/mjpg-streamer.html

树莓派设置这些程序自启动

我利用创建Linux服务的方法来实现这个功能,相关文章如下

https://hash070.top/linux-service-note.html

1.MJPG-streamer服务配置代码

[Unit]
Description=MJGP
After=network.target

[Service]
Type=simple
User=root
Restart=on-abort
WorkingDirectory=/root/mjpg/mjpg-streamer/mjpg-streamer-experimental/
ExecStart=/root/mjpg/mjpg-streamer/mjpg-streamer-experimental/start.sh
[Install]
WantedBy=multi-user.target

2.carcil服务配置代码

[Unit]
Description=CarCtrlClient
After=network.target

[Service]
Type=simple
User=root
Restart=on-abort
WorkingDirectory=/home/pi/mycode
ExecStart=/home/pi/mycode/carcil.py
[Install]
WantedBy=multi-user.target

写好服务配置文件后,enable这些服务即可让这些服务自启动

Android客户端代码(GitHub)

https://github.com/Hash070/RaspberryCarControlApp

Q.E.D.