Разработка мобильного приложения для носимых IoT-устройств (Wearables)
Носимые устройства — трекеры активности, медицинские патчи, промышленные «метки» рабочего — живут в постоянном противоречии: батарея маленькая, данных нужно много, связь нестабильная. Разработка приложения-компаньона для такого устройства — это прежде всего работа с BLE, управление энергопотреблением и синхронизация накопленных данных. Общие паттерны работы с BLE-периферией описаны в отдельном разделе по передаче данных с фитнес-браслетов; здесь сосредоточимся на специфике кастомных IoT-носимых.
BLE GATT-профиль кастомного устройства
Кастомное носимое устройство — не Apple Watch и не Fitbit. У него свой GATT-сервис с проприетарными UUID, которые назначает производитель устройства. Первая задача — получить спецификацию GATT от firmware-команды или реверс-инжинирить её через Nordic nRF Connect.
Типичный GATT-профиль промышленного носимого:
| Service UUID | Characteristic | Properties | Описание |
|---|---|---|---|
0x1800 |
Device Name | Read | Стандарт GAP |
0x180F |
Battery Level | Read, Notify | Стандарт BAS |
{custom}-0001 |
Raw Sensor Data | Notify | Поток IMU/датчиков |
{custom}-0002 |
Buffered Data | Read, Indicate | Накопленные записи |
{custom}-0003 |
Control Point | Write | Команды устройству |
{custom}-0004 |
Device Status | Read, Notify | Статус, ошибки, uptime |
Подключение и подписка на Notify-характеристику на Android через корутины:
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"))
}
}
Критический момент: BluetoothGattCallback работает на выделенном binder-потоке Android, не на main thread. Все вызовы gatt.writeCharacteristic() тоже должны идти последовательно — параллельные GATT-операции вызывают GATT_BUSY (133) и рандомные дисконнекты.
Управление очередью GATT-операций
Самый частый источник крэшей при работе с BLE — параллельные GATT-запросы. Android BLE stack не поддерживает конкурентные операции. Нужна очередь:
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()
// Ждём callback перед следующей операцией
operation.awaitCompletion()
}
}
}
suspend fun enqueue(operation: GattOperation) {
queue.send(operation)
}
}
Без такой очереди проект с несколькими устройствами одновременно гарантированно получит onCharacteristicWrite с status=133 на части устройств.
Синхронизация накопленных данных
Носимое устройство пишет данные во внутренний буфер (flash или SRAM) когда телефон недоступен. При подключении нужно вычитать весь буфер — иногда несколько тысяч записей по 20 байт каждая.
Протокол вычитки через Indicate-характеристику:
suspend fun syncBufferedData(): List<SensorRecord> {
val records = mutableListOf<SensorRecord>()
var offset = 0
do {
// Запрашиваем порцию данных командой в Control Point
writeControlPoint(ReadBufferCommand(offset = offset, count = 50))
// Ждём Indicate с ответом
val chunk = awaitIndicate(BUFFERED_DATA_UUID, timeout = 5.seconds)
val parsed = SensorRecord.parseChunk(chunk)
records.addAll(parsed)
offset += parsed.size
// Последний чанк — флаг конца буфера в заголовке
} while (!SensorRecord.isLastChunk(chunk))
// Подтверждаем синхронизацию — устройство может очистить буфер
writeControlPoint(AckSyncCommand(recordsReceived = records.size))
return records
}
MTU negotiation перед синхронизацией (requestMtu(512)) ускоряет передачу: вместо 20 байт per notification получаем до 509 байт. На iOS MTU 185 байт по умолчанию, negotiate до 512 на Bluetooth 5.0+.
iOS: CoreBluetooth и разрешения
На iOS вся работа с BLE через CoreBluetooth. Фоновый режим требует background mode bluetooth-central в Info.plist. Без него подписка на Notify обрывается когда приложение уходит в фон — данные с устройства теряются.
class WearableManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var centralManager: CBCentralManager!
private var peripheral: CBPeripheral?
override init() {
super.init()
// CBCentralManagerOptionRestoreIdentifierKey — восстановление после kill приложения
centralManager = CBCentralManager(delegate: self,
queue: DispatchQueue(label: "ble.queue"),
options: [CBCentralManagerOptionRestoreIdentifierKey: "WearableSession"])
}
func centralManager(_ central: CBCentralManager,
willRestoreState dict: [String: Any]) {
// Восстанавливаем подключение после перезапуска приложения ОС
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey]
as? [CBPeripheral], let p = peripherals.first {
peripheral = p
peripheral?.delegate = self
}
}
}
CBCentralManagerOptionRestoreIdentifierKey — без этого на iOS при перезапуске процесса теряется сессия BLE и устройство приходится переподключать вручную.
Обновление прошивки по воздуху (DFU)
Для Nordic nRF-чипов — библиотека iOSDFULibrary (Swift) и Android-DFU-Library (Kotlin). Для STM32WB — ST BLE Mesh DFU. Показываем прогресс с байтами и процентами, блокируем другие операции на время DFU, обрабатываем прерывание — устройство должно уметь возобновить загрузку с места прерывания (DFU resume).
Разработка мобильного компаньона для кастомного носимого IoT-устройства с BLE GATT, синхронизацией буфера и DFU: 8–14 недель в зависимости от сложности GATT-профиля и требований к фоновой работе. Стоимость рассчитывается индивидуально после изучения спецификации устройства.







