前提

当前OriginBot小车的无线模块连接在家用的路由器上,一旦小车离开了WiFi的覆盖范围,即断开连接之后便无法再接收数据或下达指令;试想一下,如果现在你拿着手机和小车在一个没有网络的环境里,你该如何使用手机控制小车运动起来呢? 

其实只需要在手机和小车之间搞定两个点:

  • 建立局域网
  • 建立远程连接

建立局域网

在没有任何其他无线网络存在的环境下,局域网的建立只能依赖手机端或小车端发出热点,然后让另外一端连接上该热点从而建立起二者之间的连接。如果是手机端发出热点小车端连接,则小车端必须提前使用PC端远程配置好将要连接的热点信息,一旦手机热点更换,则需重复以上操作。所以最好选择从小车端发出热点,这样只要手机在小车的附近,就可以选择连接热点并保持连接状态。

OriginBot的核心板旭日X3Pi 集成了2.4GHz无线WiFi模块,支持Soft AP和Station两种模式, Station即是连接其他WiFi的模式,板子默认运行在此模式下; Soft AP即为我们将要实现的板子发出热点的模式。

地平线官方文档中关于网络配置的模块讲解了两种模式的配置方法,我这里就不重复说明了,具体请参照下面的网页:

https://developer.horizon.ai/api/v1/fileData/documents_pi/System_Configuration/System_Configuration.html#id5

核心板上的WiFi模块配置成Soft AP模式后,也可以通过电脑远程连接进行调试,不过此时该热点就丧失了上网的功能;X3Pi还提供了以太网,可通过网线连接进行调试,该以太网卡为固定的IP,依然是不具备上网的功能,若想要小车依然可具备上网的功能,可以在核心板上配置一个USB WiFi模块,具体参考地平线官方社区的一篇文章:

https://developer.horizon.ai/forumDetail/112555549341653767

Soft AP模式配置后,还需要进行一个开机自启动配置,关于自启动配置,官方文档也有介绍,你可以将以下命令添加到系统启动脚本中,使其在系统启动时自动执行:

sudo systemctl enable hostapd
sudo systemctl enable dnsmasq

最终配置好后的结果如下图:

在Android手机端可以连接到此热点,如下图:

问题

wlan0配置好热点后,重启系统总是会连接到之前的WiFi。

这个问题可以通过禁用网络管理器自动连接到之前的WiFi。通过编辑"/etc/NetworkManager/NetworkManager.conf"文件来完成。在该文件的“[main]”部分中添加以下行:

[keyfile]
unmanaged-devices=interface-name:wlan0

保存更改并重新启动网络服务。可以使用以下命令来重新启动网络服务:

sudo systemctl restart networking.service
sudo systemctl restart NetworkManager.service

远程启动

在使用手机端控制小车运动之前,通常需要启动小车的底层控制节点;在OriginBot上已经写好相关的 launch 文件,启动命令为: ros2 launch originbot_bringup originbot.launch.py

并可设置参数:

  • use_camera:是否连接并使用摄像头
  • use_lidar:是否连接并使用激光雷达
  • use_imu:是否连接并使用IMU

以上参数可以在需要时加上,如果只是启动小车的底层控制则不需要设置这些参数;我之前在手机端做了实时图像流显示,所以还要在小车端运行 ros2 launch websocket hobot_websocket.launch.py

底层控制与网页端显示这两个launch文件其实可以融合为一个launch文件,这里我将hobot_websocket.launch.py也写成参数启用模式,设置参数为:use_websocket

launch.actions.IncludeLaunchDescription(
            launch.launch_description_sources.PythonLaunchDescriptionSource(
                os.path.join(get_package_share_directory('websocket'),
                             'launch','hobot_websocket.launch.py')),
                condition=launch.conditions.IfCondition(
                    launch.substitutions.LaunchConfiguration('use_websocket')))

Android端使用 jsch 库实现SSH远程连接,新建一个 SSHManager 类来管理SSH,代码如下:

package com.example.originbot;

import android.util.Log;

import com.jcraft.jsch.*;

public class SSHManager {
    private static final int TIMEOUT = 3000; // 连接超时时间,单位为毫秒

    private String username;
    private String password;
    private String host;
    private int port;

    public SSHManager(String username, String password, String host, int port) {
        this.username = username;
        this.password = password;
        this.host = host;
        this.port = port;
    }

    public boolean executeCommand(String command) {
        JSch jsch = new JSch();
        Session session = null;

        try {
            session = jsch.getSession(username, host, port);
            session.setPassword(password);
            session.setConfig("StrictHostKeyChecking", "no");
            session.setTimeout(TIMEOUT);
            session.connect();

            Channel channel = session.openChannel("exec");
            ((ChannelExec) channel).setCommand(command);
            channel.setInputStream(null);
            ((ChannelExec) channel).setErrStream(System.err);

            channel.connect();

            Log.d("SSH_LOG", "SSH连接成功");
//            InputStream in = channel.getInputStream();
//            byte[] buffer = new byte[1024];
//            int bytesRead;
//            StringBuilder result = new StringBuilder();
//
//            while ((bytesRead = in.read(buffer)) != -1) {
//                result.append(new String(buffer, 0, bytesRead));
//            }

//            System.out.println(result.toString());
//            Log.d("SSH_LOG", result.toString());

            channel.disconnect();
            session.disconnect();
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            Log.d("SSH_LOG", "SSH连接失败");
            return false;
        }
    }
}

在上面的代码中,注释的代码原是用于打印远程执行命令后的终端输出,但是一旦执行的命令为不会主动退出的进程,则APP就会阻塞在此处;所以这里可以不用打印输出。

然后新建立了一个 SplashActivity 作为APP的启动界面,中间显示OriginBot平面图,下方通过一个按钮来建立与小车端的远程连接,连接成功后通过SSH启动底层控制以及网页展示端,然后进入 MainActivate。代码如下:

package com.example.originbot;

import androidx.appcompat.app.AppCompatActivity;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;

import android.widget.Toast;

public class SplashActivity extends AppCompatActivity {
    private Thread bring_up_thread;
    private SSHManager ssh_originbot_bringup;

    @SuppressLint("MissingInflatedId")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
        getSupportActionBar().hide();
        setContentView(R.layout.activity_splash);

        Button connect_btn = findViewById(R.id.connect_robot);
        String host = "192.168.42.10";
        String username = "root";
        String password = "root";
        int port = 22;
        ssh_originbot_bringup = new SSHManager(username, password, host, port);

        connect_btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                connect_btn.setEnabled(false);
                if (bring_up_thread != null && bring_up_thread.isAlive()) {
                    Log.d("SSH_LOG", "线程已启动");
                }
                bring_up_thread = new Thread(new Runnable() {//创建子线程
                    @Override
                    public void run() {
                        String command = "source /opt/ros/foxy/setup.bash && " +
                                "source /opt/tros/setup.bash &&" +
                                "source /userdata/dev_ws/install/local_setup.bash &&" +
                                "ros2 launch originbot_bringup originbot.launch.py use_websocket:=true";
                        boolean state = ssh_originbot_bringup.executeCommand(command);
                        if (state) {
                            runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    Toast.makeText(getApplicationContext(), "连接成功", Toast.LENGTH_SHORT).show();
                                }
                            });

                            Intent it = new Intent(getApplicationContext(), MainActivity.class);//启动MainActivity
                            startActivity(it);
                            connect_btn.setEnabled(true);
                            new Handler().postDelayed(new Runnable() {
                                @Override
                                public void run() {
                                    finish();
                                }
                            }, 2000); // 延迟关闭当前活动 2 秒钟
                        } else {
                            runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    Toast.makeText(getApplicationContext(), "连接失败", Toast.LENGTH_SHORT).show();
                                    connect_btn.setEnabled(true);
                                }
                            });
                        }
                    }
                });
                bring_up_thread.start();
            }
        });
    }
}

在上面的代码中,可以看到终端执行的命令 command 包含了几个 source ROS2环境变量的操作,这是因为通过SSH启动的 channel 并不会包含这些环境变量。

我在 MainActivate 左上方添加了一个回退按钮,用于返回activate,同时通过SSH远程杀死之前launch启动的所有相关节点;通过pkill+节点名称, 这些节点的名称可以通过在PC端启动launch文件时看到,如下图:

MainActivate 中的实现代码如下:

private SSHManager ssh_kill_node;
private Thread kill_node_thread;
private ImageButton back_splash_btn;
back_splash_btn = findViewById(R.id.back_btn);
back_splash_btn.setOnClickListener(backSplash);
String host = "192.168.42.10";
String username = "root";
String password = "root";
int port = 22;
ssh_kill_node = new SSHManager(username, password, host, port);

......

private View.OnClickListener backSplash = new View.OnClickListener() {
        public void onClick(final View view) {
            if (kill_node_thread != null && kill_node_thread.isAlive()) {
                Log.d("SSH_LOG", "kill_node_thread 线程已启动");
            }
            kill_node_thread = new Thread(new Runnable(){//创建子线程
                @Override
                public void run() {
                    String command = "pkill hobot_codec_rep && pkill mono2d_body_det &&" +
                            "pkill mipi_cam && pkill websocket &&" +
                            "pkill static_transfor && pkill originbot_base";
                    boolean state = ssh_kill_node.executeCommand(command);
//                    boolean state = true;
                    if(state)
                    {
                        Intent it=new Intent(getApplicationContext(),SplashActivity.class); // 启动 Splash
                        startActivity(it);
//                        finish();//关闭当前活动
                    }
                }
            });
            kill_node_thread.start();
        }
    };

@Override
protected void onDestroy() {
    super.onDestroy();
    // 执行一些清理操作
    Thread exit_thread = new Thread(new Runnable() {
        @Override
        public void run() {
            String command = "pkill hobot_codec_rep && pkill mono2d_body_det &&" +
                    "pkill mipi_cam && pkill websocket &&" +
                    "pkill static_transfor && pkill originbot_base";
            ssh_kill_node.executeCommand(command);
        }
    });
    exit_thread.start();
    try {
        exit_thread.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    Log.d("SSH_LOG", "程序退出");
}

结果展示

初始界面:

主界面:

总结延伸

在SSH执行启动小车底层命令后,APP界面由初始界面直接跳转到主界面,暂时没有实现待小车端ROS2节点完全启动成功后跳转到主界面,也就是说进入主界面后会等待一会儿才能控制小车。

在小车端发出热点后通过SSH远程启动小车的ROS2节点只是其中一种选择;还可以将这些节点设置为开机自启动,当然这种方法缺少灵活性;还可以在小车端编写服务端新建或杀死进程来启动节点,在手机端通过建立客户端来进行控制,这种方式可以不用再使用SSH。