BLE Peripheral Data Exchange Implementation
After connection is established and services discovered, main work begins: reading values, writing commands, subscribing to notifications. This part of BLE stack requires understanding GATT attributes, MTU and platform-specific details when working with binary data.
Characteristic Operation Types
A characteristic has properties flags that define available operations:
| Flag | iOS (CBCharacteristicProperties) | Android | Operation |
|---|---|---|---|
| Read | .read |
PROPERTY_READ |
One-time read |
| Write | .write |
PROPERTY_WRITE |
Write with confirmation |
| Write Without Response | .writeWithoutResponse |
PROPERTY_WRITE_NO_RESPONSE |
Fast write |
| Notify | .notify |
PROPERTY_NOTIFY |
Notifications without confirmation |
| Indicate | .indicate |
PROPERTY_INDICATE |
Notifications with confirmation |
Write Without Response faster — no ACK from device. Suitable for streaming (audio, sensor readings). Write — for commands where delivery guarantee matters.
iOS: Working with Data
Notify Subscription and Parsing
// Enable notify
peripheral.setNotifyValue(true, for: characteristic)
// Receive data
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let data = characteristic.value else { return }
// Example: sensor sends 3 bytes [flags, heartRate, energyExpended]
guard data.count >= 2 else { return }
let flags = data[0]
let heartRate: Int
if flags & 0x01 == 0 {
// heart rate in 1 byte
heartRate = Int(data[1])
} else {
// heart rate in 2 bytes (little-endian)
heartRate = Int(data[1]) | (Int(data[2]) << 8)
}
}
Working with binary data via Data + byte offsets. For nonstandard devices with sparse documentation — Wireshark + BLE sniffer (Ellisys, Nordic nRF Sniffer) helps parse protocol.
Write Command
func sendCommand(_ command: UInt8, value: UInt16) {
var bytes: [UInt8] = [command, UInt8(value & 0xFF), UInt8(value >> 8)]
let data = Data(bytes)
let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.writeWithoutResponse)
? .withoutResponse
: .withResponse
peripheral.writeValue(data, for: characteristic, type: writeType)
}
With .withResponse — wait for didWriteValueFor callback. With .withoutResponse on iOS 11+ check peripheral.canSendWriteWithoutResponse before sending, otherwise data lost on buffer overflow.
Android: BluetoothGatt in Detail
Notify + CCCD Descriptor
Notify subscription requires two steps: enable notify locally and write to CCCD (Client Characteristic Configuration Descriptor) on device:
fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
// Step 1: enable locally
gatt.setCharacteristicNotification(characteristic, true)
// Step 2: write descriptor on device
val cccd = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
) ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(cccd)
}
}
Step 2 often skipped — and notifications don't arrive. This is most common notify error.
Operation Sequence
One critical Android BLE detail: cannot execute multiple GATT operations simultaneously. Only one operation in flight. Send next after callback from previous.
Violating this rule causes error status 133 or data loss on most Android devices.
Solution — queue:
class BleOperationQueue {
private val queue: LinkedList<BleOperation> = LinkedList()
private var operationInProgress = false
fun enqueue(operation: BleOperation) {
queue.add(operation)
if (!operationInProgress) {
executeNext()
}
}
fun onOperationCompleted() {
operationInProgress = false
executeNext()
}
private fun executeNext() {
val op = queue.poll() ?: return
operationInProgress = true
op.execute()
}
}
MTU and Large Data
Default MTU = 23 bytes (20 bytes payload after GATT header). For firmware transmission or large configs this catastrophically small.
Request increased MTU immediately after connection:
// iOS
peripheral.maximumWriteValueLength(for: .withoutResponse) // returns current max
// MTU negotiation automatic via iOS 9+, can influence indirectly
// Android
gatt.requestMtu(512) // in onMtuChanged we get real negotiated size
In practice most BLE chips support MTU 247–512 bytes. This turns 10KB transmission from 500 packets to 20–40.
Implementation timeframe: 3–5 days — full data exchange with operation queue, MTU negotiation and error handling on both platforms. Cost calculated individually.







