Introduction
In the previous article, we discussed the basics of serial communication. If you haven’t read it, I recommend you do so first; otherwise, you might not fully understand some of the content in this article.
This article will explain the practical aspects of serial communication on the Android side, specifically how to implement data exchange between Android devices and other devices such as PLC motherboards using serial communication.
It is important to note that, as mentioned in the previous article, my current setup only allows me to use the ESP32 development board to flash Arduino programs and demonstrate serial communication with the actual Android device (Xiaomi 10U).
Preparation
Since we need to use the ESP32 to flash Arduino programs for demonstrating serial communication on Android, we should first prepare the program.
What kind of program should we flash?
It’s simple; I’ve flashed a program on the ESP32 that uses a baud rate of 9600 for serial communication. The program continuously sends the data “e” to the serial port and listens for incoming serial data. If it receives the data “o”, it turns on the built-in LED on the development board, and if it receives the data “c”, it turns off the LED.
The code is as follows:
#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);}
The pin 12 mentioned above is the LED on this development board.
Using the Arduino’s built-in serial monitor to test the results:
As you can see, we are indeed continuously sending the character “e” through the serial port, and the LED lights up after receiving the character “o”.
Implementing Serial Communication on Android
Overview of the Principles
As we all know, Android is based on the Linux operating system, so the handling of serial ports in Android is consistent with that in Linux.
In Linux, a serial port is treated as a “device” and is represented as a /dev/ttys
file.
The /dev/ttys
is also known as a character terminal; for example, ttys0
corresponds to the COM1 serial port file in DOS/Windows systems.
Generally, we can simply understand that if we plug in a serial port device, the communication with Linux will be “relayed” through the /dev/ttys
file.
That is, if Linux wants to send data to a serial device, it can do so by writing the data directly to the /dev/ttys
file, such as:
echo test > /dev/ttyS1
This command will send the string “test” to the serial device.
Similarly, if we want to read data sent from the serial port, we can do so by reading the contents of the /dev/ttys
file.
Therefore, if we want to implement serial communication on Android, we will likely think of directly reading/writing this special file.
android-serialport-api
As mentioned earlier, we can also implement serial communication on Android in the same way as in Linux—by directly reading and writing to /dev/ttys
.
However, we do not need to handle the reading and writing and data parsing ourselves, as Google has provided a solution: android-serialport-api.
To facilitate understanding, we will briefly discuss the source code of this solution, but we won’t provide examples. As for why, you will understand as you read further. Additionally, although this solution has a long history and has not been maintained for a long time, it does not mean it cannot be used; it is just that the usage conditions are quite strict. Of course, our company is still using this solution (hahaha).
However, here we will not directly look at the source code of android-serialport-api but will look at a library re-encapsulated by other developers: Android-SerialPort-API.
In this library, by
// Default initialization, using 8N1 (8 data bits, no parity, 1 stop bit), path is the serial port path (e.g. /dev/ttys1), baudrate is the baud rate
SerialPort serialPort = new SerialPort(path, baudrate);
// Use optional parameters for configuration initialization, configurable data bits, parity, stop bits - 7E2 (7 data bits, even parity, 2 stop bits)
SerialPort serialPort = SerialPort.newBuilder(path, baudrate)
// Parity; 0: no parity (NONE, default); 1: odd parity (ODD); 2: even parity (EVEN)
// .parity(2) // Data bits, default is 8; optional values are 5~8
// .dataBits(7) // Stop bits, default is 1; 1: 1 stop bit; 2: 2 stop bits
// .stopBits(2) .build();
Initialize the serial port, and then through:
InputStream in = serialPort.getInputStream();
OutputStream out = serialPort.getOutputStream();
We obtain the input/output streams and achieve data communication with the serial device by reading/writing these two streams.
First, let’s see how the serial port is initialized.
First, check if there are read/write permissions for the serial port file; if not, change the permissions to 666
using a shell command. After changing, check again if permissions are available; if not, throw an exception.
Note that when executing the shell, the runtime used is Runtime.getRuntime().exec(sSuPath);
which means it is executed with root permissions!
In other words, if you want to implement serial communication in this way, you must have ROOT permissions! This is why I won’t provide examples, as my current devices cannot be rooted. As for why our company can continue using this solution, it’s simple: our industrial control Android devices are all customized versions, and having ROOT permissions is standard practice.
Once the permissions are confirmed to be available, we use the open
method to get a variable of type FileDescriptor
mFd
, and finally, we obtain the input/output streams through this mFd
.
So the core lies in the open
method, which is a native method, i.e., C code:
private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity, int stopBits, int flags);
We won’t provide the C source code here; just know that it opens the /dev/ttys
file (to be precise, the “terminal”) and parses the data according to the serial port rules by passing in these parameters, finally returning a Java FileDescriptor
object.
In Java, we can then obtain the input/output streams using this FileDescriptor
object.
Understanding the principles is quite simple.
After examining the principles of the communication part, let’s see how we can find available serial ports.
In fact, it is similar to 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;}
It also traverses the files under /dev
, but some additional filtering is done here.
Alternatively, you can filter by reading the /proc/tty/drivers
configuration file:
Vector<Driver> getDrivers() throws IOException { if (mDrivers == null) { mDrivers = new Vector<Driver>(); LineNumberReader r = new LineNumberReader(new FileReader("/proc/tty/drivers")); String l; while ((l = r.readLine()) != null) { // Issue 3: // Since driver name may contain spaces, we do not extract driver name with split()
String drivername = l.substring(0, 0x15).trim(); String[] w = l.split(" +"); if ((w.length >= 5) && (w[w.length - 1].equals("serial"))) { Log.d(TAG, "Found new driver " + drivername + " on " + w[w.length - 4]); mDrivers.add(new Driver(drivername, w[w.length - 4])); } } r.close(); } return mDrivers;}
As can be seen from the paths, these are all system paths, meaning that if you do not have permissions, you likely won’t be able to read anything.
This is the basic principle of reading serial data using the same method as in Linux. Now, the question arises: since I mentioned that the usage conditions are quite strict, what is a more user-friendly alternative?
We will introduce that next, which is using the Android USB host
functionality.
USB Host
Android 3.1 (API Level 12) or higher platforms directly support USB accessories and host mode. The USB accessory mode has also been backported to Android 2.3.4 (API Level 10) as a plugin library to support a wider range of devices. Device manufacturers can choose whether to add this plugin library to the system image of the device.
Starting from Android version 3.1, USB can be used in host mode, and if we want to read serial data through USB, we need to rely on this host mode.
Before formally introducing the USB host mode, let’s briefly introduce the USB modes supported on Android.
There are three USB modes supported on Android: device mode, host mode, and accessory mode.
Device mode is when we commonly connect the Android device directly to a computer, at which point it appears as a USB peripheral on the computer, allowing it to be used like a “U disk” for copying data. However, Android now generally supports MTP mode (as a camera), file transfer mode (used as a U disk), network card mode, etc.
Host mode is when our Android device acts as a host, connecting to other peripherals. At this point, the Android device is equivalent to the computer in device mode. The Android device can connect to keyboards, mice, USB drives, and embedded applications like USB-to-serial and USB-to-I2C devices. However, to use the Android device in host mode, you may need a data cable or adapter that supports OTG (Micro-USB or USB type-c to USB-A).
In accessory mode, external USB hardware acts as the USB host. Examples of accessories might include robot controllers, docks, diagnostic and music devices, self-service terminals, card readers, etc. Thus, Android devices that do not have host capabilities can interact with USB hardware. Android USB accessories must be designed to be compatible with Android devices and must comply with the Android accessory communication protocol.
The difference between device mode and accessory mode is that in accessory mode, the host can see other USB functionalities in addition to adb.
Using USB Host Mode to Interact with Peripherals
After introducing the three USB modes in Android, we will now begin to explain how to use USB host mode. Of course, this is just a brief introduction to the usage of the native API; in practice, we generally use third-party libraries written by others.
Preparation
Before we start using USB host mode, we need to do some preparation work.
First, we need to add the following to the manifest file (AndroidManifest.xml):
<!-- Declare the need for USB host mode support to prevent unsupported devices from installing the application --><uses-feature android:name="android.hardware.usb.host" />
<!-- ... -->
<!-- Declare the need to receive USB connection events --><action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
An example of a complete manifest file is as follows:
<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" /> </intent-filter> </activity> </application></manifest>
After declaring the manifest file, we can now look for the available device information:
private fun scanDevice(context: Context) { val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager val deviceList: HashMap<String, UsbDevice> = manager.deviceList Log.i(TAG, "scanDevice: $deviceList")}
When we plug the ESP32 development board into the phone and run the program, the output is as follows:
As you can see, we successfully found our ESP32 development board.
Here, I should mention that since our phone only has one USB port and it is already connected to the ESP32 development board, we cannot directly connect to the computer’s ADB with a data cable. At this point, we need to use wireless ADB. Please search for how to use wireless ADB.
Additionally, if we want to connect to the serial device by requesting permission after finding the device, we need to request additional permissions. (Similarly, if we declare the device we want to connect to in the manifest file in advance, we do not need to request additional permissions; please refer to reference material 5 for more details.)
First, declare a broadcast receiver to receive the authorization result:
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 { // Authorized, you can start requesting connection here connectDevice(context, device) } } else { Log.d(TAG, "permission denied for device $device") } } } }}
After declaring it, register this broadcast receiver in the Activity’s OnCreate:
permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE)
val filter = IntentFilter(ACTION_USB_PERMISSION)
registerReceiver(usbReceiver, filter)
Finally, after finding the device, call manager.requestPermission(deviceList.values.first(), permissionIntent)
to pop up a dialog requesting permission.
Connecting to the Device and Sending/Receiving Data
After completing the above preparatory work, we can finally connect to the found device and exchange data:
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!") } } } } } }}
In the above code, we use usbManager.openDevice
to open the specified device, i.e., connect to the device.
Then, we use bulkTransfer
to receive data, which writes the received data into the buffer array bytes
and returns the length of successfully received data.
Run the program, connect the device, and the log output is as follows:
As you can see, the output data is not what we expected.
This is because this is very raw data; if we want to read the data, we need to write driver programs for different serial-to-USB chips or protocols to obtain the correct data.
By the way, if you want to write data to the serial port, you can use controlTransfer()
.
Therefore, in our actual production environment, we use third-party libraries that encapsulate this well.
Here, I recommend using usb-serial-for-android.
usb-serial-for-android
The first step in using this library is of course to import the dependency:
// Add repository
allprojects { repositories { ... maven { url 'https://jitpack.io' } }}
// Add dependency
dependencies { implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'}
After adding the dependency, you also need to add the corresponding fields in the manifest file and handle permissions. Since this is consistent with the usage of the native API, we won’t go into detail here.
Unlike the native API, since we already know the device information of our ESP32 motherboard and the driver used (CDC), we will not use the native method to look for available devices; instead, we will directly specify this known device (of course, you can continue to use the native API’s searching and connecting methods):
private fun scanDevice(context: Context) { val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val customTable = ProbeTable() // Add our device information, the three parameters are vendroId, productId, driver class customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)
val prober = UsbSerialProber(customTable) // Check if the specified device exists val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)
if (drivers.isNotEmpty()) { val driver = drivers[0] // This device exists, connect to this device val connection = manager.openDevice(driver.device) } else { Log.i(TAG, "scanDevice: No device found!") }}
After connecting to the device, the next step is to exchange data. This is very convenient because after obtaining the UsbSerialPort
, you can directly call its read()
and write()
methods to read and write data:
port = driver.ports[0] // Most devices only have one port, so usually just take the first one
port.open(connection)// Set connection parameters, baud rate 9600, and "8N1"
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
// Read data
val responseBuffer = ByteArray(1024)
port.read(responseBuffer, 0)
// Write data
val sendData = byteArrayOf(0x6F)
port.write(sendData, 0)
At this point, a complete code for testing our ESP32 program is as follows:
@Composable
fun SerialScreen() { val context = LocalContext.current
Column( Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Button(onClick = { scanDevice(context) }) { Text(text = "Find and Connect Device") }
Button(onClick = { switchLight(true) }) { Text(text = "Turn On Light") }
Button(onClick = { switchLight(false) }) { Text(text = "Turn Off Light") }
}}
private fun scanDevice(context: Context) { val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val customTable = ProbeTable() customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)
val prober = UsbSerialProber(customTable)
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)
if (drivers.isNotEmpty()) { val driver = drivers[0]
val connection = manager.openDevice(driver.device)
if (connection == null) { Log.i(TAG, "scanDevice: Connection failed") return }
port = driver.ports[0]
port.open(connection)
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
Log.i(TAG, "scanDevice: Connect success!")
CoroutineScope(Dispatchers.IO).launch { while (true) { val responseBuffer = ByteArray(1024)
val len = port.read(responseBuffer, 0)
Log.i(TAG, "scanDevice: recv data = ${responseBuffer.copyOfRange(0, len).contentToString()}") } } } else { Log.i(TAG, "scanDevice: No device found!") }}
private fun switchLight(isON: Boolean) { val sendData = if (isON) byteArrayOf(0x6F) else byteArrayOf(0x63)
port.write(sendData, 0)
Run this program and connect the device; the output is as follows:
As you can see, the output is the byte 101, which converts to ASCII “e”.
Then we click on “Turn On Light” and “Turn Off Light” and the results are as follows:
By the way, the data sent here “0x6F” is the hexadecimal of ASCII “o”; similarly, “0x63” is “c”.
As you can see, we can perfectly communicate with our ESP32 development board.
Examples
Regardless of which method we use to communicate with the serial port, the data we can obtain at the code level of the Android app has already been processed.
That is, as we mentioned in the previous article, a frame of data in serial communication includes start bits, data bits, parity bits, and stop bits. However, when using Android, we generally only receive the data bits of the data, as the other data has already been parsed at the lower level, and we do not need to worry about how to parse or use it.
The data we can directly obtain is usable data.
Here is an example of a certain model of driver board I have used before.
Information about communication for this driver board is shown in the image:
As you can see, it uses RS485 communication, supports baud rates of 9600 or 38400, 8 data bits, no parity, and 1 stop bit.
It also specifies a data protocol.
In the defined protocol, the first bit is the address; the second bit is the command; the third bit to the Nth bit is the data content; and the last two bits are the CRC check.
It is important to note that the protocol defined here is based on serial communication; do not confuse this protocol with the serial communication itself. In simple terms, it defines its own protocol within the data bits of the serial communication protocol.
Moreover, it can be seen that although no parity was specified when defining serial port parameters, CRC check is specified in its own protocol.
Additionally, I would like to complain a bit; the protocol for this driver board is really not very useful.
In practical use, the communication data between the host and the driver board cannot guarantee that it will be sent completely in the same data frame, which may cause “sticky packets” or “fragmented packets.” In other words, the data may be sent in several parts, and it is difficult to determine whether this data is from the previous incomplete send or new data.
I have used another driver board that is much more convenient because it adds a start symbol and data length to the frame header, and adds an end symbol to the frame tail.
This way, even if there are “sticky packets” or “fragmented packets,” we can still parse it well.
Of course, the reason for designing the protocol this way must have its rationale, such as reducing communication costs.
I also encountered a very simple driver board that simply sends an integer to indicate the corresponding command.
The data returned by the driver board is also very simple, just a number, and the meanings of each number are pre-agreed upon…
That said, let’s continue to look at the communication protocol of this driver board:
This is one of the command contents; after we send the command “1,” it will return the model and version information of the current driver board.
Since our motherboard is a customized industrial control motherboard, we use serial communication directly with android-serialport-api.
Ultimately, sending and receiving replies is also very simple:
/** * Convert hexadecimal string to ByteArray * */private fun hexStrToBytes(hexString: String): ByteArray { check(hexString.length % 2 == 0) { return ByteArray(0) }
return hexString.chunked(2) .map { it.toInt(16).toByte() } .toByteArray()}
private fun isReceivedLegalData(receiveBuffer: ByteArray): Boolean {
val rcvData = receiveBuffer.copyOf() // Copy a new one for use to avoid clearing the original data
if (cmd.cmdId.checkDataFormat(rcvData)) { // Check the format of the response data isPkgLost = false if (cmd.cmdId.isResponseBelong(rcvData)) { // Check the source of the response command if (!AdhShareData.instance.getIsUsingCrc()) { // If CRC check is not enabled, return true directly resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId) coroutineScope.launch(Dispatchers.Main) { cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId) }
return true }
if (cmd.cmdId.checkCrc(rcvData)) { // Check CRC resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId) coroutineScope.launch(Dispatchers.Main) { cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId) }
return true } else { coroutineScope.launch(Dispatchers.Main) { cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseCrcError, ByteArray(0), -1, -1, cmd.cmdId) }
return false } } else { coroutineScope.launch(Dispatchers.Main) { cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseNotFromThisCmd, ByteArray(0), -1, -1, cmd.cmdId) }
return false } } else { // Data is not valid, may encounter fragmented packets, continue to wait for the next data and merge isPkgLost = true return isReceivedLegalData(cmd) /*coroutineScope.launch(Dispatchers.Main) { cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseWrongFormat, ByteArray(0), -1, -1, cmd.cmdId) }
return false */ }}
// ... Omitted initialization and connection code
// Send data
val bytes = hexStrToBytes("0201C110")
outputStream.write(bytes, 0, bytes.size)
// Parse data
val recvBuffer = ByteArray(0)
inputStream.read(recvBuffer)
while (receiveBuffer.isEmpty()) { delay(10)}
isReceivedLegalData()
I originally planned to send you the encapsulated protocol library of this driver board, but I thought it might not be appropriate, so I just abstracted these incomplete codes. I hope you understand.
Conclusion
From the two methods introduced above, it can be seen that each method has its advantages and disadvantages.
Using android-serialport-api allows direct reading of serial data without the need to convert to USB interfaces or driver support, but it requires ROOT permissions, making it suitable for customized Android motherboards that already have RS232 or RS485 interfaces and are rooted.
On the other hand, using USB host allows direct reading of serial data converted through USB interfaces without the need for ROOT permissions, but it only supports serial-to-USB chips with drivers and only supports USB interfaces, not direct connections to serial devices.
You can choose which method to implement serial communication based on your actual situation.
Of course, in addition to the serial communication introduced here, there is another communication protocol that is widely used in practical applications: the MODBUS protocol.
In the next article, we will introduce MODBUS.
Author: equationl Link: https://juejin.cn/post/7171347086032502792
Follow me for more knowledge or to contribute
Leave a Comment
Your email address will not be published. Required fields are marked *