软件需求分析
- 能通过局域网远程控制树莓派小车
- 能够实时传输树莓派摄像头的画面
软件设计
设定树莓派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