Introduction: Bridging the Gap Between Industrial IoT and Android Things
The industrial landscape is rapidly evolving, driven by the need for greater efficiency, predictive maintenance, and real-time data insights. Central to this transformation is the Industrial Internet of Things (IIoT), which often relies on robust, proven communication protocols like Modbus RTU for sensor and actuator integration. On the other hand, Android Things offers a compelling platform for embedded devices, bringing the familiarity and rich ecosystem of Android to dedicated hardware. However, out-of-the-box Android Things doesn’t natively provide high-level abstractions for industrial serial protocols, creating a significant integration challenge. This article provides an expert-level guide on customizing Android Things OS and application logic to seamlessly integrate industrial Modbus RTU sensors.
Why Android Things for Industrial IoT?
Android Things leverages the power of Android for embedded devices, offering:
- Familiar development environment (Android Studio, Java/Kotlin).
- Robust security features and over-the-air (OTA) updates.
- Rich UI capabilities for local dashboards.
- Access to a vast array of existing Android libraries and services.
By overcoming the industrial protocol barrier, Android Things can become a formidable platform for custom IIoT gateways, control panels, and data aggregators.
Understanding Modbus RTU Fundamentals
Modbus RTU (Remote Terminal Unit) is a serial communication protocol, widely used in industrial automation. It operates on a master-slave architecture, where a single master communicates with multiple slave devices. Key characteristics include:
- Physical Layer: Typically RS-485 for multi-drop networks, offering differential signaling for noise immunity over long distances.
- Data Representation: Messages are transmitted in binary format.
- Frame Format: Each message includes a slave address, function code, data, and a CRC (Cyclic Redundancy Check) for error detection.
- Function Codes: Standardized codes for reading coils, discrete inputs, holding registers, and input registers.
For our purposes, we’ll focus on reading holding registers (Function Code 0x03) and input registers (Function Code 0x04), which are commonly used by industrial sensors to provide measurement data.
Hardware Setup: Android Things Board and RS-485 Connectivity
To begin, you’ll need an Android Things compatible board (e.g., Raspberry Pi 3/4, NXP i.MX7D) and a reliable RS-485 transceiver. Since most Android Things boards don’t have native RS-485 ports, a common approach is to use a USB-to-RS485 converter.
Required Hardware:
- Android Things compatible board (e.g., Raspberry Pi 3 B+) with Android Things OS installed.
- USB-to-RS485 converter (ensure it uses a common chipset like FTDI FT232R, CP210x, or CH340 for good driver support).
- Industrial Modbus RTU sensor (e.g., a temperature/humidity sensor, energy meter).
- Power supply for the Android Things board and sensor.
- Wiring: Twisted pair cable for RS-485 (A/B lines) and GND.
Wiring Diagram (Conceptual):
Connect your USB-to-RS485 converter to a USB port on your Android Things board. Then, wire the RS-485 lines:
USB-to-RS485 Converter --- Modbus RTU Sensor
----------------------- -------------------
A (Data+) --- A (Data+)
B (Data-) --- B (Data-)
GND --- GND (Common)
Ensure proper termination resistors (120 Ohm) are placed at both ends of the RS-485 bus if you have a long bus or multiple devices, to prevent signal reflections.
Customizing Android Things OS for Serial Port Access
While Android Things itself supports USB host mode, direct access to raw serial devices (like the ones exposed by a USB-to-RS485 converter) requires specific permissions and, in some cases, appropriate kernel modules. For standard USB-to-serial chips, Android Things usually has the necessary kernel drivers. The main task is declaring USB host feature and permissions in your application.
1. Declare USB Host Feature and Permissions
In your application’s AndroidManifest.xml, declare that your app uses the USB host feature and requests permission to access USB devices.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.modbus_master">
<uses-feature android:name="android.hardware.usb.host" />
<uses-permission android:name="android.permission.USB_PERMISSION" />
<application
android:label="@string/app_name">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
</application>
</manifest>
Create an xml/device_filter.xml file (e.g., in res/xml/) to specify the USB devices your application intends to interact with. You can leave it empty to prompt for permission for any attached USB device, or specify vendor/product IDs for specific converters.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- For example, FTDI devices -->
<!-- <usb-device vendor-id="1027" product-id="24577" /> -->
</resources>
2. Incorporate a USB Serial Library
While you can interact with USB devices directly using Android’s UsbManager, it’s often easier to use a dedicated library like ‘usb-serial-for-android’ by felhr. It abstracts away much of the complexity.
Add the dependency to your build.gradle (app level):
dependencies {
implementation 'com.felhr.usbserial:usb-serial-for-android:6.1.0' // Check for latest version
}
Implementing Modbus Communication in Android
With the hardware set up and OS permissions configured, we can now write the Android application logic to communicate via Modbus RTU.
1. Initialize USB Serial Communication
First, detect and open the USB serial port.
import android.hardware.usb.UsbConstants;
import android.hardware.usb.UsbDevice;
import android.hardware.usb.UsbDeviceConnection;
import android.hardware.usb.UsbManager;
import com.felhr.usbserial.UsbSerialDevice;
import com.felhr.usbserial.UsbSerialPort;
import com.felhr.usbserial.UsbSerialProber;
public class ModbusMaster {
private UsbManager usbManager;
private UsbSerialPort serialPort;
private static final int BAUD_RATE = 9600; // Common Modbus baud rate
private static final int DATA_BITS = UsbSerialPort.DATABITS_8;
private static final int STOP_BITS = UsbSerialPort.STOPBITS_1;
private static final int PARITY = UsbSerialPort.PARITY_NONE;
public ModbusMaster(UsbManager manager) {
this.usbManager = manager;
}
public boolean startSerial() {
HashMap<String, UsbDevice> usbDevices = usbManager.getDeviceList();
if (!usbDevices.isEmpty()) {
for (Map.Entry<String, UsbDevice> entry : usbDevices.entrySet()) {
UsbDevice device = entry.getValue();
int deviceClass = device.getDeviceClass();
// Filter for CDC-ACM (serial) or specific vendor/product IDs
if (deviceClass == UsbConstants.USB_CLASS_CDC_DATA ||
UsbSerialProber.getDefaultProber().probe(device) != null) {
// Found a potential serial device
UsbSerialProber prober = UsbSerialProber.getDefaultProber();
List<UsbSerialDriver> drivers = prober.findAllDrivers(usbManager);
if (!drivers.isEmpty()) {
UsbSerialDriver driver = drivers.get(0); // Take the first available
UsbDeviceConnection connection = usbManager.openDevice(driver.getDevice());
if (connection != null) {
serialPort = driver.getPorts().get(0); // Assuming first port
try {
serialPort.open(connection);
serialPort.setParameters(BAUD_RATE, DATA_BITS, STOP_BITS, PARITY);
Log.d("ModbusMaster", "Serial port opened successfully.");
return true;
} catch (IOException e) {
Log.e("ModbusMaster", "Error opening serial port: " + e.getMessage());
return false;
}
}
}
}
}
}
Log.e("ModbusMaster", "No suitable USB serial device found.");
return false;
}
public void stopSerial() {
if (serialPort != null && serialPort.isOpen()) {
try {
serialPort.close();
Log.d("ModbusMaster", "Serial port closed.");
} catch (IOException e) {
Log.e("ModbusMaster", "Error closing serial port: " + e.getMessage());
}
}
}
// ... Modbus RTU read/write methods below
}
2. Implement Modbus RTU Frame Construction and Parsing
Modbus RTU frames require specific byte sequences, including a CRC-16 checksum. A basic Modbus master implementation will involve:
- Request Frame Generation: Constructing the byte array for a Modbus request (slave ID, function code, start address, quantity).
- CRC-16 Calculation: Appending the CRC-16 checksum to the request frame.
- Sending and Receiving: Writing the request to the serial port and reading the response.
- Response Frame Parsing: Validating the CRC-16 of the response and extracting data.
Below is a simplified example for reading holding registers (Function Code 0x03). The CRC-16 implementation is crucial and should be robust.
// Inside ModbusMaster class
// Placeholder for CRC-16 calculation. Implement a proper one!
private byte[] calculateCrc16(byte[] bytes) {
// This is a placeholder. A full CRC-16-MODBUS implementation is required.
// Example: https://www.modbus.org/docs/Modbus_Application_Protocol_V1_1b.pdf (Appendix B)
int crc = 0xFFFF;
for (byte b : bytes) {
crc ^= (int) b & 0xFF;
for (int i = 0; i < 8; i++) {
if ((crc & 0x0001) != 0) {
crc >>= 1;
crc ^= 0xA001; // Polynomial for Modbus CRC-16
} else {
crc >>= 1;
}
}
}
return new byte[]{(byte) (crc & 0xFF), (byte) ((crc >> 8) & 0xFF)};
}
public int[] readHoldingRegisters(int slaveId, int startAddress, int quantity) throws IOException {
if (serialPort == null || !serialPort.isOpen()) {
throw new IOException("Serial port not open.");
}
// Modbus RTU request frame for Function Code 0x03 (Read Holding Registers)
// Slave ID (1 byte) + Function Code (1 byte) + Starting Address (2 bytes) + Quantity (2 bytes)
byte[] request = new byte[6];
request[0] = (byte) slaveId;
request[1] = (byte) 0x03; // Function Code: Read Holding Registers
request[2] = (byte) ((startAddress >> 8) & 0xFF);
request[3] = (byte) (startAddress & 0xFF);
request[4] = (byte) ((quantity >> 8) & 0xFF);
request[5] = (byte) (quantity & 0xFF);
byte[] crc = calculateCrc16(request);
byte[] fullRequest = new byte[request.length + crc.length];
System.arraycopy(request, 0, fullRequest, 0, request.length);
System.arraycopy(crc, 0, fullRequest, request.length, crc.length);
serialPort.write(fullRequest);
// Expect response: Slave ID (1) + Func Code (1) + Byte Count (1) + Data (N * 2) + CRC (2)
// Max response length: 1 + 1 + 1 + (quantity * 2) + 2
byte[] buffer = new byte[3 + (quantity * 2) + 2];
int bytesRead = serialPort.read(buffer, 1000); // Read with a timeout of 1000ms
if (bytesRead >= 5 && buffer[0] == (byte) slaveId && buffer[1] == (byte) 0x03) {
// Validate CRC-16 of the response (omitted for brevity, but essential)
// ...
int byteCount = buffer[2] & 0xFF;
if (byteCount == quantity * 2) {
int[] data = new int[quantity];
for (int i = 0; i < quantity; i++) {
// Modbus registers are 16-bit unsigned integers
data[i] = ((buffer[3 + (i * 2)] & 0xFF) << 8) | (buffer[4 + (i * 2)] & 0xFF);
}
return data;
} else {
throw new IOException("Modbus response byte count mismatch.");
}
} else if (bytesRead >= 3 && buffer[0] == (byte) slaveId && (buffer[1] & 0x80) != 0) {
// Modbus Exception Response: Slave ID (1) + (Func Code | 0x80) (1) + Exception Code (1) + CRC (2)
int exceptionCode = buffer[2] & 0xFF;
throw new IOException("Modbus Exception: " + exceptionCode);
} else {
throw new IOException("Invalid or incomplete Modbus response.");
}
}
3. Asynchronous Communication and Threading
Serial communication should always happen on a background thread to prevent blocking the main UI thread. Use Android’s HandlerThread, AsyncTask, or Kotlin Coroutines for this. Implement a mechanism to periodically poll sensors or respond to events.
Data Processing and Application Layer
Once you retrieve the raw integer values from Modbus registers, you’ll need to interpret them according to your sensor’s documentation. Common transformations include:
- Scaling: Many sensors provide raw values that need to be divided by a factor (e.g., a temperature of 250 might mean 25.0 °C if the scaling factor is 10).
- Combining Registers: Some 32-bit values (e.g., float or long integers) are transmitted across two 16-bit Modbus registers. You’ll need to combine these.
- Data Types: Convert raw integers to appropriate Java data types (float, double, long).
Example for combining two 16-bit registers into a 32-bit float (IEEE 754 standard, big-endian order):
public float registersToFloat(int highRegister, int lowRegister) {
int intBits = (highRegister << 16) | (lowRegister & 0xFFFF);
return Float.intBitsToFloat(intBits);
}
After processing, this data can be:
- Displayed on a local Android Things UI.
- Logged to a local SQLite database for offline analysis.
- Transmitted to a cloud platform (e.g., Google Cloud IoT Core, AWS IoT, Azure IoT Hub) using standard REST APIs or MQTT.
Conclusion
Integrating industrial Modbus RTU sensors with a custom Android Things OS transforms an embedded device into a powerful IIoT gateway. By understanding Modbus fundamentals, setting up the correct hardware, configuring OS permissions, and implementing robust serial communication and Modbus frame handling, developers can unlock a vast array of industrial applications. This approach leverages the modern Android ecosystem while adhering to established industrial communication standards, paving the way for smarter, more connected factories and infrastructure. While this guide covers the core technical steps, remember to prioritize error handling, robust data validation, and security practices for production deployments.
Android Mobile Specs & Compare Directory
Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!
Compare Devices Specs →