Integrating RFID Readers via Bluetooth into Mobile Applications
RFID readers with BLE interfaces are a distinct class of devices: Zebra RFD40/RFD90, Chainway R6, TSL 1128. They connect to phones via BLE SPP profile (Serial Port Profile) or custom GATT services and stream read EPC/UID tags. Integration is simpler than it seems if you understand the specific reader protocol.
BLE SPP vs GATT
Most Bluetooth RFID readers operate in two modes:
BLE SPP (Serial Port Profile over BLE) — emulate serial ports. Data flows through custom GATT characteristics with manufacturer UUIDs. Nordic UART Service (NUS) is the most common: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E.
Standard GATT — some readers implement proprietary GATT services with separate characteristics for commands and responses.
For Zebra RFD40 — Nordic UART Service:
val NORDIC_UART_SERVICE = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
val NORDIC_UART_RX = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") // write
val NORDIC_UART_TX = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") // notify
fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(NORDIC_UART_SERVICE) ?: return
// Subscribe to TX (receive data from reader)
val txChar = service.getCharacteristic(NORDIC_UART_TX)
gatt.setCharacteristicNotification(txChar, true)
val descriptor = txChar.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
fun sendCommand(command: ByteArray) {
val rxChar = bluetoothGatt?.getService(NORDIC_UART_SERVICE)
?.getCharacteristic(NORDIC_UART_RX) ?: return
// If command > MTU-3 bytes, fragmentation is needed
if (command.size > negotiatedMtu - 3) {
sendFragmented(command)
} else {
rxChar.value = command
bluetoothGatt?.writeCharacteristic(rxChar)
}
}
Parsing RFID Responses
Protocol depends on the reader. Zebra RFD40 returns ASCII strings in format EPC: <hex>\r\n. TSL 1128 uses binary packets with headers and CRC. Example for ASCII protocol:
private val dataBuffer = StringBuilder()
fun onCharacteristicChanged(value: ByteArray) {
dataBuffer.append(String(value, Charsets.UTF_8))
// Data arrives in fragments — wait for complete line
while (dataBuffer.contains('\n')) {
val lineEnd = dataBuffer.indexOf('\n')
val line = dataBuffer.substring(0, lineEnd).trim()
dataBuffer.delete(0, lineEnd + 1)
if (line.startsWith("EPC:")) {
val epc = line.removePrefix("EPC:").trim()
onTagRead(epc)
}
}
}
Critical: BLE packets may arrive split — one line in multiple onCharacteristicChanged notifications. Always buffer until delimiter (usually \r\n).
Zebra SDK as Alternative
For Zebra devices (RFD40, RFD90, RFD8500) — official EMDK for Android or Zebra RFID SDK:
implementation("com.zebra.rfid.api3:rfid-api3:2.0.2.109")
val readers = Readers(context, ENUM_TRANSPORT.BLUETOOTH)
readers.GetAvailableRFIDReaderList()?.forEach { readerDevice ->
val reader = readerDevice.RFIDReader
reader.connect()
reader.Events.addEventsListener(rfidEventsListener)
reader.Events.setHandheldEvent(true)
reader.Events.setTagReadEvent(true)
reader.Actions.Inventory.perform() // start continuous scanning
}
private val rfidEventsListener = object : RfidEventsListener {
override fun eventReadNotify(e: RfidReadEvents) {
e.ReadEventData.TagData.forEach { tag ->
Log.d("RFID", "EPC: ${tag.TagID}, RSSI: ${tag.PeakRSSI}")
}
}
override fun eventStatusNotify(rfidStatusEvents: RfidStatusEvents) {
// STATUS_EVENT_BATTERY_LOW and other status codes
}
}
RSSI from Zebra SDK provides approximate distance to tag — useful for prioritization when reading multiple tags simultaneously.
Timeline
Integration via Nordic UART Service or similar custom GATT with ASCII protocol parsing: 5 days. Integration through Zebra/Chainway SDK with extended features (inventory, RSSI, write to tag): 1–2 weeks.







