电子说
在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。
这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。
需要注意的是正如上一篇文章所说的,我目前的条件只允许我使用 ESP32 开发版烧录 Arduino 程序与安卓真机(小米10U)进行串口通信演示。
由于我们需要使用 ESP32 烧录 Arduino 程序演示安卓端的串口通信,所以在开始之前我们应该先把程序烧录好。
那么烧录一个怎样的程序呢?
很简单,我这里直接烧了一个 ESP32 使用 9600 的波特率进行串口通信,程序内容就是 ESP32 不断的向串口发送数据 “e” ,并且监听串口数据,如果接收到数据 “o” 则打开开发版上自带的 LED 灯,如果接收到数据 “c” 则关闭这个 LED 灯。
代码如下:
#define LED 12
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
}
void loop() {
if (Serial.available()) {
char c = Serial.read();
if (c == 'o') {
digitalWrite(LED, HIGH);
}
if (c == 'c') {
digitalWrite(LED, LOW);
}
}
Serial.write('e');
delay(100);
}
上面的 12 号 Pin 是这块开发版的 LED。
使用 Arduino自带串口监视器测试结果:
可以看到,确实如我们设想的通过串口不断的发送字符 “e”,并且在接收到字符 “o” 后点亮了 LED。
众所周知,安卓其实是基于 Linux 的操作系统,所以在安卓中对于串口的处理与 Linux 一致。
在 Linux 中串口会被视为一个“设备”,并体现为 /dev/ttys
文件。
/dev/ttys
又被称为字符终端,例如 ttys0
对应的是 DOS/Windows 系统中的 COM1 串口文件。
通常,我们可以简单理解,如果我们插入了某个串口设备,则这个设备与 Linux 的通信会由 /dev/ttys
文件进行 “中转”。
即,如果 Linux 想要发送数据给串口设备,则可以通过往 /dev/ttys
文件中直接写入要发送的数据来实现,如:
echo test > /dev/ttyS1
这个命令会将 “test” 这串字符发送给串口设备。
如果想读取串口发送的数据也是一样的,可以通过读取 /dev/ttys
文件内容实现。
所以,如果我们在安卓中想要实现串口通信,大概率也会想到直接读取/写入这个特殊文件。
在上文中我们说到,在安卓中也可以通过与 Linux 一样的方式--直接读写 /dev/ttys
实现串口通信。
但是其实并不需要我们自己去处理读写和数据的解析,因为谷歌官方给出了一个解决方案:android-serialport-api
为了便于理解,我们会大致说一下这个解决方案的源码,但是就不上示例了,至于为什么,同学们往下看就知道了。另外,虽然这个方案历史比较悠久,也很长时间没有人维护了,但是并不意味着不能使用了,只是使用条件比较苛刻,当然,我司目前使用的还是这套方案(哈哈哈哈)。
不过这里我们不直接看 android-serialport-api 的源码,而是通过其他大佬二次封装的库来看: Android-SerialPort-API
在这个库中,通过
// 默认直接初始化,使用8N1(8数据位、无校验位、1停止位),path为串口路径(如 /dev/ttys1),baudrate 为波特率
SerialPort serialPort = new SerialPort(path, baudrate);
// 使用可选参数配置初始化,可配置数据位、校验位、停止位 - 7E2(7数据位、偶校验、2停止位)
SerialPort serialPort = SerialPort
.newBuilder(path, baudrate)
// 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN)
// .parity(2)
// 数据位,默认8;可选值为5~8
// .dataBits(7)
// 停止位,默认1;1:1位停止位;2:2位停止位
// .stopBits(2)
.build();
初始化串口,然后通过:
InputStream in = serialPort.getInputStream();
OutputStream out = serialPort.getOutputStream();
获取到输入/输出流,通过读取/写入这两个流来实现与串口设备的数据通信。
我们首先来看看初始化串口是怎么做的。
首先检查了当前是否具有串口文件的读写权限,如果没有则通过 shell 命令更改权限为 666
,更改后再次检查是否有权限,如果还是没有就抛出异常。
注意这里的执行 shell 时使用的 runtime 是 Runtime.getRuntime().exec(sSuPath);
也就是说,它是通过 root 权限来执行这段命令的!
换句话说,如果想要通过这种方式实现串口通信,必须要有 ROOT 权限!这就是我说我不会给出示例的原因,因为我手头的设备无法 ROOT 啊。至于为啥我司还能继续使用这种方案的原因也很简单,因为我们工控机的安卓设备都是定制版的啊,拥有 ROOT 权限不是基本操作?
确定权限可用后通过 open
方法拿到一个类型为 FileDescriptor
的变量 mFd
,最后通过这个 mFd
拿到输入输出流。
所以核心在于 open
方法,而 open 方法是一个 native 方法,即 C 代码:
private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity,
int stopBits, int flags);
C 的源码这里就不放了,只需要知道它做的工作就是打开了 /dev/ttys
文件(准确的说是“终端”),然后通过传递进去的这些参数去按串口规则解析数据,最后返回一个 java 的 FileDescriptor
对象。
在 java 中我们再通过这个 FileDescriptor
对象可以拿到输入/输出流。
原理说起来是十分的简单。
看完通信部分的原理后,我们再来看看我们如何查找可用的串口呢?
其实和 Linux 上也一样:
public Vector<File> getDevices() {
if (mDevices == null) {
mDevices = new Vector<File>();
File dev = new File("/dev");
File[] files = dev.listFiles();
if (files != null) {
int i;
for (i = 0; i < files.length; i++) {
if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
Log.d(TAG, "Found new device: " + files[i]);
mDevices.add(files[i]);
}
}
}
}
return mDevices;
}
也是通过直接遍历 /dev
下的文件,只不过这里做了一些额外的过滤。
或者也可以通过读取 /proc/tty/drivers
配置文件后过滤:
Vector
关于读取可用串口设备,其实从这里的路径也可以看出,都是系统路径,也就是说,如果没有权限,大概率也是读取不到东西的。
这就是使用与 Linux 一样的方式去读取串口数据的基本原理,那么问题来了,既然我说这个方法使用条件比较苛刻,那么更易用的替代方案是什么呢?
我们下面就会介绍,那就是使用安卓的 USB host
(USB主机)的功能。
Android 3.1(API 级别 12)或更高版本的平台直接支持 USB 配件和主机模式。USB 配件模式还作为插件库向后移植到 Android 2.3.4(API 级别 10)中,以支持更广泛的设备。设备制造商可以选择是否在设备的系统映像中添加该插件库。
在安卓 3.1 版本开始,支持将USB作为主机模式(USB host)使用,而我们如果想要通过 USB 读取串口数据则需要依赖于这个主机模式。
在正式开始介绍USB主机模式前,我们先简要介绍一下安卓上支持的USB模式。
安卓上的USB支持三种模式:设备模式、主机模式、配件模式。
设备模式即我们常用的直接将安卓设备连接至电脑上,此时电脑上显示为 USB 外设,即可以当成 “U盘” 使用拷贝数据,不过现在安卓普遍还支持 MTP模式(作为摄像头)、文件传输模式(即当U盘用)、网卡模式等。
主机模式即将我们的安卓设备作为主机,连接其他外设,此时安卓设备就相当于上面设备模式中的电脑。此时安卓设备可以连接键盘、鼠标、U盘以及嵌入式应用USB转串口、转I2C等设备。但是如果想要将安卓设备作为主机模式可能需要一条支持 OTG 的数据线或转接头。(Micro-USB 或 USB type-c 转 USB-A 口)
而在 USB 配件模式下,外部 USB 硬件充当 USB 主机。配件示例可能包括机器人控制器、扩展坞、诊断和音乐设备、自助服务终端、读卡器等等。这样,不具备主机功能的 Android 设备就能够与 USB 硬件互动。Android USB 配件必须设计为与 Android 设备兼容,并且必须遵守 Android 配件通信协议。
设备模式与配件模式的区别在于在配件模式下,除了 adb 之外,主机还可以看到其他 USB 功能。
在介绍完安卓中的三种USB模式后,下面我们开始介绍如何使用USB主机模式。当然,这里只是大概介绍原生APi的使用方法,我们在实际使用中一般都都是直接使用大佬编写的第三方库。
在开始正式使用USB主机模式时我们需要先做一些准备工作。
首先我们需要在清单文件(AndroidManifest.xml)中添加:
name="android.hardware.usb.host" />
name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /
一个完整的清单文件示例如下:
<manifest ...>
<uses-feature android:name="android.hardware.usb.host" />
<uses-sdk android:minSdkVersion="12" />
...
<application>
<activity ...>
...
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<span class="hljs-name"intent-filter>
<span class="hljs-name"activity>
<span class="hljs-name"application>
<span class="hljs-name"manifest>
声明好清单文件后,我们就可以查找当前可用的设备信息了:
private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val deviceList: HashMap
将 ESP32 开发版插上手机,运行程序,输出如下:
可以看到,正确的查找到了我们的 ESP32 开发版。
这里提一下,因为我们的手机只有一个 USB 口,此时已经插上了 ESP32 开发版,所以无法再通过数据线直接连接电脑的 ADB 了,此时我们需要使用无线 ADB,具体怎么使用无线 ADB,请自行搜索。
另外,如果我们想要通过查找到设备后请求连接的方式连接到串口设备的话,还需要额外申请权限。(同理,如果我们直接在清单文件中提前声明需要连接的设备则不需要额外申请权限,具体可以看看参考资料5,这里不再赘述)
首先声明一个广播接收器,用于接收授权结果:
private lateinit var permissionIntent: PendingIntent
private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"
private val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
synchronized(this) {
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
device?.apply {
// 已授权,可以在这里开始请求连接
connectDevice(context, device)
}
} else {
Log.d(TAG, "permission denied for device $device")
}
}
}
}
}
声明好之后在 Acticity 的 OnCreate 中注册这个广播接收器:
permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE)
val filter = IntentFilter(ACTION_USB_PERMISSION)
registerReceiver(usbReceiver, filter)
最后,在查找到设备后,调用 manager.requestPermission(deviceList.values.first(), permissionIntent)
弹出对话框申请权限。
完成上述的准备工作后,我们终于可以连接搜索到的设备并进行数据交互了:
private fun connectDevice(context: Context, device: UsbDevice) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
CoroutineScope(Dispatchers.IO).launch {
device.getInterface(0).also { intf ->
intf.getEndpoint(0).also { endpoint ->
usbManager.openDevice(device)?.apply {
claimInterface(intf, forceClaim)
while (true) {
val validLength = bulkTransfer(endpoint, bytes, bytes.size, TIMEOUT)
if (validLength > 0) {
val result = bytes.copyOfRange(0, validLength)
Log.i(TAG, "connectDevice: length = $validLength")
Log.i(TAG, "connectDevice: byte = ${result.contentToString()}")
}
else {
Log.i(TAG, "connectDevice: Not recv data!")
}
}
}
}
}
}
}
在上面的代码中,我们使用 usbManager.openDevice
打开了指定的设备,即连接到设备。
然后通过 bulkTransfer
接收数据,它会将接收到的数据写入缓冲数组 bytes
中,并返回成功接收到的数据长度。
运行程序,连接设备,日志打印如下:
可以看到,输出的数据并不是我们预料中的数据。
这是因为这是非常原始的数据,如果我们想要读取数据,还需要针对不同的串口转USB芯片或协议编写驱动程序才能获取到正确的数据。
顺道一提,如果想要将数据写入串口数据的话可以使用 controlTransfer()
。
所以,我们在实际生产环境中使用的都是基于此封装好的第三方库。
这里推荐使用 usb-serial-for-android
使用这个库的第一步当然是导入依赖:
// 添加仓库
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
// 添加依赖
dependencies {
implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'
}
添加完依赖同样需要在清单文件中添加相应字段以及处理权限,因为和上述使用原生API一致,所以这里不再赘述。
全部0条评论
快来发表一下你的评论吧 !