Інтеграція Bluetooth Low Energy (BLE) у мобільне додаток
BLE-інтеграція — це не просто «підключити пристрій». Це кінцевий автомат з дюжиною станів, кожен з яких може завершитися помилкою: адаптер вимкнено, пристрій поза зоною, сервіс не знайдений, характеристика не підтримує запис. Додатки, які не обробляють ці стани явно, стабільно крешяться при першому реальному використанні.
Архітектура BLE-стека
BLE працює за моделлю GATT (Generic Attribute Profile). Периферійний пристрій (датчик, браслет, замок) надає Services — логічні групи функцій. Кожен Service містить Characteristics — конкретні значення для читання, запису або підписки (notify/indicate).
Приклад: фітнес-браслет має Service 0x180D (Heart Rate). У ньому Characteristic 0x2A37 — поточний пульс з флагом notify. Додаток підписується і отримує дані без опроса.
Конечний автомат підключення
Мінімальний набір станів, який потрібно обробляти:
IDLE → SCANNING → DISCOVERED → CONNECTING → CONNECTED → DISCOVERING_SERVICES
→ SERVICES_READY → SUBSCRIBING → READY
І помилки на кожному переході: таймаут сканування, розрив з'єднання в CONNECTING, gattStatus != GATT_SUCCESS при discovery, втрата з'єднання в READY.
iOS: CoreBluetooth
import CoreBluetooth
class BLEManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
var centralManager: CBCentralManager!
var targetPeripheral: CBPeripheral?
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: DispatchQueue(label: "ble.queue"))
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else {
// обробляємо .poweredOff, .unauthorized, .unsupported
return
}
startScanning()
}
func startScanning() {
let serviceUUID = CBUUID(string: "180D")
centralManager.scanForPeripherals(withServices: [serviceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard RSSI.intValue > -80 else { return } // фільтр за сигналом
centralManager.stopScan()
targetPeripheral = peripheral
centralManager.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.delegate = self
peripheral.discoverServices([CBUUID(string: "180D")])
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil, let services = peripheral.services else { return }
for service in services {
peripheral.discoverCharacteristics([CBUUID(string: "2A37")], for: service)
}
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard let characteristics = service.characteristics else { return }
for char in characteristics where char.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: char)
}
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else { return }
// парсимо data відповідно до GATT-специфікації характеристики
}
}
Проблеми, які зустрічаються в кожному проекті
didDisconnectPeripheral без попередження. BLE-з'єднання рветься при виході з зони, розряді пристрою, системному втручанні. Потрібен автоматичний реконект: при отриманні didDisconnectPeripheral — centralManager.connect(peripheral) з експоненціальним backoff.
Сканування з allowDuplicates: true вбиває батарею. Включаємо тільки якщо потрібні постійні оновлення RSSI для вимірювання відстані. Для звичайного пошуку — false.
State restoration. Якщо додаток убитий системою під час BLE-сесії, CBCentralManagerOptionRestoreIdentifierKey дозволяє відновити стан при наступному запуску. Без цього пристрій вважає себе підключеним, а додаток не знає про це.
Android: BluetoothGatt
val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
// status містить код помилки, наприклад 133 (GATT_ERROR) - потрібен реконект
gatt.close()
return
}
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices()
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val characteristic = gatt
.getService(UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb"))
?.getCharacteristic(UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb"))
?: return
gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") // Client Characteristic Configuration
)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
val data = characteristic.value
// парсимо
}
}
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
Статус 133 (GATT_ERROR) — найчастіший при підключенні на Android. Причини: пристрій уже підключений в іншому процесі, кеш GATT застарілий. Лікування: gatt.close() + gatt.refresh() (через рефлексію) + повторне підключення через 1-2 секунди.
Android 12+ дозволи. BLUETOOTH_SCAN та BLUETOOTH_CONNECT — обов'язкові. Старий BLUETOOTH та BLUETOOTH_ADMIN більше не працюють на API 31+.
Продуктивність та батарея
MTU за замовчуванням — 23 байти. Запитуємо через gatt.requestMtu(512) / peripheral.maximumWriteValueLength(for: .withResponse). На великих передачах (прошивка OTA, дані датчиків) це різниця між 30 секундами та 3 хвилинами.
Терміни інтеграції: 3-5 днів — базове підключення з notify. Складні сценарії (OTA update, мультиперифері, background mode) — 1-2 тижні. Вартість розраховується індивідуально.







