Android BLE

Unpacking BLE frames in a readable way.

Ever wondered why does unpacking Bluetooth data you receive is so much work compared to automatic unpacking done by Retrofit via GSon/etc?

This is caused because BLE data is usually very tightly packed and data format varies greatly between devices.
Some devices can send Int values as one/two/three/four bytes, sometimes they are big-endian, sometimes little-endian. Sometimes byte can also fit an single digit value on few bytes, and boolean flags on the rest.

Most straightforward way for unpacking that data would to imperatively write bunch of statements to unpack data:

class Unpacked(val pulse: Int, val steps: Int, val id: String, val flag1: Boolean, val flag2: Boolean)

fun unpack(data: ByteArray): Unpacked {
    val pulse: Int = data[0].toInt() and 0xFF // 0-255

    val steps1: Int = data[1].toInt() and 0xFF
    val steps2: Int = data[2].toInt() and 0xFF
    val steps3: Int = data[3].toInt() and 0xFF
    val steps = steps1 * 255 * 255 + steps2 * 255 + steps3

    val id: String = String(data.sliceArray(4..5), Charsets.UTF_8)

    val flag1: Boolean = data[6].toInt() and 1 == 1
    val flag2: Boolean = (data[6].toInt() shr 1) and 1 == 1
    return Unpacked(pulse, steps, id, flag1, flag2)

This has an advantage of everything being in one place.
Unfortunately most real devices have a lot more of data to unpack and some disadvantages starts to show:

  • A lot of code gets repeated
  • Extra code makes it is harder to compare implementation with spec
  • Extra code makes it is easier to make off-by one error in bytes/bit indexes

To solve this you can separate “implementation details” from Frame “spec” via Kotlin extension functions:

// Easy to compare with Spec
class Unpacked(d: ByteArray) {
    val pulse = d.intFromByte(byteIdx = 0) // you can skip using named arguments once you are familiar with your methods
    val steps = d.intFromThreeBytes(firstByteIdx = 1)
    val id = d.utfFromTwoBytes(firstByteIdx = 4)
    val flag1 = d[6].bitAsBoolean(0)
    val flag2 = d[6].bitAsBoolean(1)

// Extension functions (implementation details)
fun ByteArray.intFromByte(byteIdx: Int): Int { ... }

For this short example extracting extension functions would make the code longer overall, but with real frames it often reduces the LOC count.

Implementation of extension functions was not provided, since they tend to differ greatly between projects, but you can unpack any type of data(enums/list/floating points/etc) as with imperative method, but the extensions functions should provide you better code arrangement.