Розробка мобільних компаньйонів для носимих IoT-пристроїв
Носимі пристрої — трекери активності, медичні патчі, промислові «мітки» робітника — живуть у постійному протиріччі: батарея маленька, даних потрібно багато, зв'язок нестабільна. Розробка додатка-компаньйона для такого пристрою — це передусім робота з 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-профіля та вимог до фонової роботи. Вартість розраховується індивідуально після вивчення специфікації пристрою.







