How to Develop a Companion App for Wearable IoT Devices?
Wearable devices—activity trackers, medical patches, industrial tags—live in constant contradiction: small battery (typically 100–200 mAh), high data demand (100 Hz IMU stream, 10,000+ record buffer), and unstable connectivity. Developing a companion app for such a device is first and foremost about BLE, power management, and syncing accumulated data. We specialize in custom IoT wearables where no ready-made SDK exists—only a GATT specification from the firmware team. Our experience: over 5 years and 20+ projects, from fitness trackers to medical IoT patches. Contact us for a consultation on your device.
BLE GATT Profile of a Custom Device
A custom wearable is not an Apple Watch or Fitbit. It has its own GATT service with proprietary UUIDs assigned by the manufacturer. The first task is to obtain the GATT specification from the firmware team or reverse-engineer it using nRF Connect or GATT profile (Wikipedia).
Typical GATT profile of an industrial wearable:
| 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 | Device commands |
{custom}-0004 |
Device Status | Read, Notify | Status, errors, uptime |
Connecting and subscribing to a Notify characteristic on Android using 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"))
}
}
BLE Stack Comparison: iOS vs Android
| Parameter | iOS (CoreBluetooth) | Android (BluetoothGatt) |
|---|---|---|
| Background mode | bluetooth-central background mode + restore identifier |
WorkManager + Service, but scanning restrictions |
| GATT queue | Automatic serialization (single-threaded callback) | Manual queue required, otherwise GATT_BUSY |
| Default MTU | 185 bytes | 23 bytes |
| Parallel operations | Not supported | Leads to disconnects |
| Restore session | CBCentralManagerOptionRestoreIdentifierKey | No built-in mechanism |
How to Manage the GATT Operations Queue?
The most common source of BLE crashes is parallel GATT requests. The Android BLE stack does not support concurrent operations. You 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 such a queue, a project with multiple devices simultaneously will guaranteed get onCharacteristicWrite with status=133 on some devices. Our implementation reduces such errors by 70% compared to a naive approach.
Syncing Accumulated Data
The wearable writes data to an internal buffer (flash or SRAM) when the phone is unavailable. Upon connection, the entire buffer must be read—sometimes thousands of 20-byte records. Read-out protocol via Indicate characteristic:
suspend fun syncBufferedData(): List<SensorRecord> {
val records = mutableListOf<SensorRecord>()
var offset = 0
do {
// Request a 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 has end-of-buffer flag in header
} while (!SensorRecord.isLastChunk(chunk))
// Acknowledge sync—device can clear buffer
writeControlPoint(AckSyncCommand(recordsReceived = records.size))
return records
}
MTU negotiation before sync (requestMtu(512)) speeds up transfer: instead of 20 bytes per notification, you get up to 509 bytes. On iOS, default MTU is 185 bytes, negotiable to 512 on Bluetooth 5.0+. In a real project for a medical patch with a 10,000-record buffer, we cut sync time from 8 minutes to 45 seconds by increasing MTU and optimizing the read-out protocol. This is a 10x improvement—and it's free.
Why Background BLE Matters on iOS?
On iOS, all BLE work goes through CoreBluetooth. Background mode requires bluetooth-central background mode in Info.plist. Without it, Notify subscription is lost when the app goes to background—data from the device is lost.
class WearableManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var centralManager: CBCentralManager!
private var peripheral: CBPeripheral?
override init() {
super.init()
// CBCentralManagerOptionRestoreIdentifierKey — recovery after app termination
centralManager = CBCentralManager(delegate: self,
queue: DispatchQueue(label: "ble.queue"),
options: [CBCentralManagerOptionRestoreIdentifierKey: "WearableSession"])
}
func centralManager(_ central: CBCentralManager,
willRestoreState dict: [String: Any]) {
// Restore connection after OS app restart
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey]
as? [CBPeripheral], let p = peripherals.first {
peripheral = p
peripheral?.delegate = self
}
}
}
CBCentralManagerOptionRestoreIdentifierKey — without this, BLE session is lost on process restart on iOS and the device must be reconnected manually.
Over-the-Air Firmware Update (DFU)
For Nordic nRF chips — iOSDFULibrary (Swift) and Android-DFU-Library (Kotlin). For STM32WB — ST BLE Mesh DFU. We show progress with bytes and percentages, block other operations during DFU, handle interruptions—the device must be able to resume upload from the break point (DFU resume). We ensure correct connection recovery after the update.
What’s Included in the Work?
- Analysis of the device's GATT specification and creation of interaction documentation.
- Mobile app architecture design considering background tasks and power consumption.
- BLE stack implementation with operation queue and error handling.
- Buffer sync and DFU integration.
- Testing on the real device (including oscilloscope for packet analysis).
- 3-month post-release support.
Contact us—we will evaluate your project and propose the optimal solution. Get a consultation for your device: we'll analyze the GATT specification and determine development timelines.







