Developing Mobile Companion Apps for Wearable IoT Devices
Wearable devices — activity trackers, medical patches, industrial worker badges — live in constant conflict: small battery, lots of data needed, unstable connectivity. Developing a companion app for such device is primarily BLE work, power management, and accumulated data synchronization. General patterns for BLE peripheral communication are described in a separate section on fitness tracker data transfer; this section focuses on custom IoT wearable specifics.
BLE GATT Profile of Custom Device
Custom wearable is not Apple Watch or Fitbit. It has its own GATT service with proprietary UUIDs assigned by manufacturer. First task — get GATT specification from firmware team or reverse-engineer via Nordic nRF Connect.
Typical industrial wearable GATT profile:
| Service UUID | Characteristic | Properties | Description |
|---|---|---|---|
0x1800 |
Device Name | Read | Standard GAP |
0x180F |
Battery Level | Read, Notify | Standard BAS |
{custom}-0001 |
Raw Sensor Data | Notify | IMU/sensor stream |
{custom}-0002 |
Buffered Data | Read, Indicate | Accumulated records |
{custom}-0003 |
Control Point | Write | Commands to device |
{custom}-0004 |
Device Status | Read, Notify | Status, errors, uptime |
Connect and subscribe to Notify characteristic on Android via coroutines:
class WearableRepository(private val context: Context) {
private var gatt: BluetoothGatt? = null
private val _sensorData = MutableSharedFlow<SensorFrame>(extraBufferCapacity = 64)
val sensorData: SharedFlow<SensorFrame> = _sensorData.asSharedFlow()
suspend fun connect(device: BluetoothDevice): Result<Unit> = withContext(Dispatchers.IO) {
val connected = CompletableDeferred<Boolean>()
gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
connected.complete(false)
scheduleReconnect(device)
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
enableSensorNotify(gatt)
connected.complete(true)
}
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
if (characteristic.uuid == SENSOR_DATA_UUID) {
val frame = SensorFrame.fromBytes(value)
_sensorData.tryEmit(frame)
}
}
}, BluetoothDevice.TRANSPORT_LE)
if (connected.await()) Result.success(Unit)
else Result.failure(IOException("Connection failed"))
}
}
Critical: BluetoothGattCallback runs on dedicated Android binder thread, not main thread. All gatt.writeCharacteristic() calls must be sequential — parallel GATT operations cause GATT_BUSY (133) and random disconnects.
Managing GATT Operation Queue
Most common crash source with BLE — parallel GATT requests. Android BLE stack doesn't support concurrent operations. Need a queue:
class GattOperationQueue {
private val queue = Channel<GattOperation>(capacity = Channel.UNLIMITED)
private val executor = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
executor.launch {
for (operation in queue) {
operation.execute()
// Wait for callback before next operation
operation.awaitCompletion()
}
}
}
suspend fun enqueue(operation: GattOperation) {
queue.send(operation)
}
}
Without this queue, multi-device project guarantees onCharacteristicWrite with status=133 on some devices.
Buffered Data Synchronization
Wearable writes to internal buffer (flash or SRAM) when phone unavailable. On connection, must read entire buffer — sometimes thousands of records, 20 bytes each.
Read protocol via Indicate characteristic:
suspend fun syncBufferedData(): List<SensorRecord> {
val records = mutableListOf<SensorRecord>()
var offset = 0
do {
// Request data chunk via Control Point command
writeControlPoint(ReadBufferCommand(offset = offset, count = 50))
// Wait for Indicate response
val chunk = awaitIndicate(BUFFERED_DATA_UUID, timeout = 5.seconds)
val parsed = SensorRecord.parseChunk(chunk)
records.addAll(parsed)
offset += parsed.size
// Last chunk — end flag in header
} while (!SensorRecord.isLastChunk(chunk))
// Confirm sync — device can clear buffer
writeControlPoint(AckSyncCommand(recordsReceived = records.size))
return records
}
MTU negotiation before sync (requestMtu(512)) speeds transfer: instead of 20 bytes per notification get up to 509 bytes. On iOS MTU 185 bytes default, negotiate to 512 on Bluetooth 5.0+.
iOS: CoreBluetooth and Permissions
On iOS, all BLE via CoreBluetooth. Background mode requires bluetooth-central in Info.plist. Without it, Notify subscription breaks when app backgrounded — device data lost.
class WearableManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var centralManager: CBCentralManager!
private var peripheral: CBPeripheral?
override init() {
super.init()
// CBCentralManagerOptionRestoreIdentifierKey — restore after app kill
centralManager = CBCentralManager(delegate: self,
queue: DispatchQueue(label: "ble.queue"),
options: [CBCentralManagerOptionRestoreIdentifierKey: "WearableSession"])
}
func centralManager(_ central: CBCentralManager,
willRestoreState dict: [String: Any]) {
// Restore connection after OS process restart
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey]
as? [CBPeripheral], let p = peripherals.first {
peripheral = p
peripheral?.delegate = self
}
}
}
CBCentralManagerOptionRestoreIdentifierKey — without this, iOS process restart loses BLE session and device needs manual reconnection.
Firmware Update Over-the-Air (DFU)
For Nordic nRF chips — iOSDFULibrary (Swift) and Android-DFU-Library (Kotlin). For STM32WB — ST BLE Mesh DFU. Show progress with bytes and percentages, block other operations during DFU, handle interruption — device must resume from break point (DFU resume).
Developing mobile companion for custom wearable IoT device with BLE GATT, buffer sync and DFU: 8–14 weeks depending on GATT profile complexity and background work requirements. Cost calculated individually after studying device specification.







