[사물 인터넷 네트워크와 서비스 구축 강좌] #3-7 센서장치-모바일 BLE 통신
강좌 전체보기
.
비컨이 구현도 간단하고 쓰임새도 다양함은 두 말 할 필요가 없습니다. 하지만 비컨으로 전달 가능한 데이터는 한계가 명확합니다. 스마트 밴드나 스마트 워치를 떠올려 보면 왜 BLE 통신이 필요한지 알 수 있을 것입니다. 이번 파트에서는 BLE 통신 시나리오 중 가장 자주 사용되는 센서장치 – 모바일 BLE 통신 시나리오를 실습해 보겠습니다.
이번 파트의 목표는 센서장치를 ESP32 모듈로 만들고 안드로이드 폰과 서로 채팅을 할 수 있도록 하는 것입니다.
센서장치 제작
먼저 ESP32 모듈을 이용해 센서장치부터 만들어야겠죠.
[3-5 BLE 프로토콜 소개] 파트에서 언급했듯이 센서장치는 일반적으로 BLE peripheral / GATT server 역할을 합니다. 즉, 센서장치는 자신이 제공하는 기능, 데이터의 종류에 따라 Service/Characteristic 구조를 갖추고, 각 characteristic 에는 적절한 read/write/notify 속성이 설정되어 있어야 합니다. 그래야 이 센서장치에 접속하는 상대방이 Service discovery 를 수행한 뒤, 데이터를 일고 쓸 수가 있습니다.
우리는 채팅 서비스를 제공할 예정이므로 1개의 Service와 2개의 characteristic 을 만들면 됩니다. 2개의 characteristic 은 각각 read, write 속성을 주면 적당하겠죠. 그리고 채팅의 속성상 센서장치가 보낼 메시지가 있으면 상대방에 알려줄수 있도록 notify 속성도 설정되어야 합니다. notify 속성은 read 속성이 설정된 characteristic 에 함께 걸어주는 것이 적당하겠습니다.
정리하면… 아래처럼 Service/Characteristic 구조를 갖추면 됩니다.
- Chat Service : 8ec76ea3-6668-48da-9866-75be8bc86f4d
- In characteristic (Write) : 4fafc201-1fb5-459e-8fcc-c5c9c331914b
- Out characteristic (Read/Notify) : beb5483e-36e1-4688-b7f5-ea07361b26fa
물론 이 설정은 센서장치가 BLE 연결된 후, GATT server 상태일때 동작하는 겁니다. BLE 연결 전에는 [3-6 비컨 장치 제작과 모바일 스캔] 에서 테스트 했듯이 iBeacon 장치처럼 동작하면 됩니다. (이번에는 센서장치 찾기 편하도록 iBeacon 이름도 MyBLE 로 표시되도록 하겠습니다.)
위 설정대로 동작하도록 만든 스케치가 아래 링크에 있습니다. ESP32 보드에 업로드 하세요.
업로드 후에는 [시리얼 모니터]를 실행하고, BAUDRATE 를 115200 으로 맞추세요.
모바일 앱 BLE Chat
센서장치는 준비가 되었으니 이제 모바일 앱을 준비할 차례입니다. 앞선 파트에서 사용했던 nRF Connect 앱을 사용해도 되지만, 채팅을 주고 받기에는 전용으로 만든 앱이 더 편리합니다. 그리고 BLE 통신을 위한 안드로이드 코드도 확인할 수 있구요.
BLE 통신을 위해 BLE Chat 앱 소스가 아래 링크에 공개되어 있으며, Google Play Store 에서 설치할 수도 있습니다.
- 앱 소스 다운로드 : https://github.com/godstale/iot_prototyping/tree/master/3_7_BLE_Sensor_Mobile/Android/BLEChat
- Google Play Store 에서 “blechat” 검색 후 설치 (제작자 : tortuga)
- 앱 설치 : https://play.google.com/store/apps/details?id=com.hardcopy.blechat
BLE Chat앱을 설치하고 실행해보면 classic bluetooth 실습할 때 사용했던 BtChat 앱과 유사한 화면이 나옵니다. UI 는 거의 동일하고 이번에는 BLE 장치를 찾아서 연결할 뿐입니다.
먼저 [Scan for devices] 버튼을 눌러 주변 BLE 장치를 스캔하세요.”MyBLE” 장치가 리스트에 보이면 선택해서 연결할 수 있습니다.
연결되면 앱은 Service Discovery 과정을 수행합니다. 그리고 센서장치에서 가져온 Service/Characteristic 정보를 채팅창에 뿌려줍니다.
각각의 Service/Characteristic 에는 번호가 부여되어 있습니다. 우리가 채팅창에 메시지를 입력하면 센서장치의 어떤 Characteristic 에 데이터를 써야할지 앱에게 알려줘야 합니다. 채팅 입력창에 아래 명령어를 입력하면 앱이 4번 Characteristic 을 메시지를 전달할 Characteristic 으로 인식합니다.
- @write 4
센서장치에서 보낼 메시지가 있으면 notify 기능으로 핸드폰에 알려줄겁니다. 하지만 notify 기능을 사용하기 위해서는 핸드폰에서 먼저 notify on 작업을 해줘야 합니다. 아래처럼 채팅 입력창에 명령어를 입력하세요. 그럼 5번 Characteristic 의 notify 기능이 활성화 됩니다.
- @noti 5
이제 채팅 준비가 끝났습니다. [시리얼 모니터]와 핸드폰이 서로 데이터를 주고 받는지 확인해보세요!!
센서장치 소스코드 분석
ESP32 모듈에 올린 소스에서 setup() 함수와 loop() 함수를 먼저 살펴봐야 합니다.
void setup() { initBle(); startAdvertising(); } void loop() { if(isConnected) { ...... while(Serial.available()) { char input = Serial.read(); ...... } if(i > 0) { pCharOut->setValue((uint8_t*)input_char, i); pCharOut->notify(); } } ...... }
초기화 함수인 setup() 에서는 블루투스 초기화 작업을 합니다. 그리고 메인 루프 함수인 loop() 는 BLE 연결이 되었을 때, 시리얼 모니터(PC)에서 데이터를 전달받아 BLE 로 전송하는 역할을 합니다. pCharOut->setValue() 가 해당 characteristic 에 전송할 메시지를 쓰는 코드이고, pCharOut->notify() 가 연결된 BLE 장치에게 notify를 보내는 코드입니다. 코드를 자세히 보면 PC 에서 전달 받은 데이터를 저장하는 버퍼가 20 byte만 할당되어 있습니다. notify 로 데이터를 전송할 때 메시지 길이 제한이 20 byte 이기 때문입니다.
센서장치(BLE peripheral / GATT server)로 동작하기 위한 설정은 iniBle() 함수 안에서 이루어집니다.
void initBle() { // Create the BLE Device BLEDevice::init(""); // Create the BLE Server pServer = BLEDevice::createServer(); pServer->setCallbacks(new ServerEventCallback()); setService(); } void setService() { pService = pServer->createService(SERVICE_UUID); pCharIn = pService->createCharacteristic( IN_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_WRITE ); pCharOut = pService->createCharacteristic( OUT_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); pCharOut->addDescriptor(new BLE2902()); pCharInCallback = new InCharEventCallback(); pCharOutCallback = new OutCharEventCallback(); pCharIn->setCallbacks(pCharInCallback); pCharOut->setCallbacks(pCharOutCallback); pService->start(); }
GATT server 로 동작하기 위해 BLEServer 인스턴스를 생성합니다. 이때 pServer->setCallbacks() 코드를 통해 콜백 함수를 등록합니다. 그러면 BLE 장치가 연결/해제 되었을 때 콜백 함수를 호출해줍니다.
setService() 함수안에서 센서장치의 Service/Characteristic 구조를 만들어줍니다. 미리 계획해 둔 구조대로 BLEService 인스턴스를 먼저 생성하고, 하위에 BLECharacteristic 인스턴스를 생성해서 등록해줍니다. BLECharacteristic 인스턴스를 생성할 때는 속성을 지정합니다.
Notify 속성을 지정했다면 아래 코드를 이용해 CCCD descriptor 를 설정해줘야 합니다. 그래야 원격 장치에서 추후에 notify 기능을 on/off 해줄 수 있습니다.
- pCharOut->addDescriptor(new BLE2902());
이렇게 서비스 설정을 모두 했으면 pService->start() 를 호출해주면 됩니다. 이후 BLE 연결/해제 및 BLE 통신 작업은 ESP32 framework 이 알아서 해줍니다. 우리는 Characteristic 으로 들어온 데이터가 있는지 확인하고, 전달할 데이터가 있을 때 Characteristic 에 써주는 작업만 신경쓰면 됩니다. 이런 작업을 위해 각 Characteristic 에 콜백 함수를 등록합니다.
pCharInCallback = new InCharEventCallback(); pCharOutCallback = new OutCharEventCallback(); pCharIn->setCallbacks(pCharInCallback); pCharOut->setCallbacks(pCharOutCallback);
위 코드를 참고하면 어떤 BLE 센서장치를 만들든 원하는대로 Service/Characteristic 을 설정해서 동작시킬 수 있을 것입니다.
안드로이드 앱 소스코드 분석
안드로이드 앱은 상대적으로 소스코드의 양도 많고 복잡하고, 사전에 알아야 할 내용도 많습니다. 이런 내용들을 상세히 설명하는건 이 강좌의 범위를 벗어나기 때문에 핵심이 되는 코드 위주로 소개하겠습니다.
아래에서 실습에 사용된 안드로이드 앱 BLE Chat 코드 전체를 받을 수 있습니다.
아래 폴더를 확인해보면 우리가 다룰 안드로이드 코드들이 있습니다. (참고로 안드로이드 버전 4.3 이상에서만 BLE 를 사용할 수 있습니다.)
- BLEChat/app/src/main/java/com/hardcopy/blechat/
[3-4 모바일-모바일 CLASSIC BT 통신] 파트에서 다뤘던 BT Chat 앱의 소스코드 구조와 거의 같습니다. 각 소스코드의 역할도 비슷합니다. 크게 차이가 나는 부분은 BLE 스캔과 연결을 담당하는 bluetooth 폴더 안의 파일들입니다.
- Beacon.kt
- Advertising packet 을 파싱해서 얻어낸 iBeacon 정보를 저장하기 위한 클래스입니다.
- BleConnector.kt
- BLE 연결을 시작/해제합니다. BLE 연결이 시작되면 Service Discovery 를 수행하고 Service/Characteristic 구조를 받아온 후 저장합니다. 특정 Characteristic 을 read/write 할 수 있으며 notify 기능을 on/off 할 수 있습니다. notify 메시지가 들어오면 UI로 전달해줍니다.
- BleGattListener.kt
- BLE 연결/해제, read/write 등의 이벤트를 전달하기 위한 interface 입니다.
- BleManager.kt
- 현재는 별다른 기능이 없습니다. 무시하셔도 됩니다.
- BleScanListener.kt
- BLE 스캔을 과정에서 나오는 이벤트를 전달하기 위한 interface 입니다.
- BleScanner.kt
- BLE 장치를 스캔하기 위한 도구들이 들어있는 클래스입니다.
이 중에서도 BleScanner 와 BleConnector 가 중요합니다.
먼저 BleScanner를 살펴보면…
/** * Scan 결과를 전달받을 listener 등록 (Scan 전에 반드시 등록) * @param listener */ fun setScanListener(listener: BleScanListener) { ...... } /** 스캔 필터 설정 관련 **/ fun addFilter(type: Int, filteringText: String?, address: String?, uuid: ParcelUuid?) { ...... } fun clearScanFilter(type: Int) { ...... } /** * Scan 시작/종료 * @param enable Scan 시작/종료 * @param scanTime Scan 지속시간 (단위 ms, 0일 경우 무한 스캔) */ fun scheduleLeScan(enable: Boolean, scanTime: Long) { if (enable) { if (isBleScanning) { // Do not interfere current scanning } else { if (mBtAdapter.isEnabled) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { // 롤리팝 미만인 경우 사용되는 scan mBtAdapter.startLeScan(mScanCallback) ...... } else { // 롤리팝 이상인 경우 사용되는 Scan ...... // Start scan mLeScanner.startScan(filters, settings, mScanCallbackLp) ...... } } else { Logs.d(TAG, "##### Cannot start scanning... BluetoothAdapter is null or not enabled") } } } else { isBleScanning = false if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { mBtAdapter.stopLeScan(mScanCallback) } else { mLeScanner.stopScan(mScanCallbackLp) } mConnectTimer?.cancel() mConnectTimer = null mScanListener!!.onScanDone() } } /** * scanRecord 버퍼에서 Beacon 정보 추출 * @param device * @param rssi * @param scanRecord * @return */ private fun extractBeaconInfo(device: BluetoothDevice?, rssi: Int, scanRecord: ByteArray): Beacon? { if (device == null) return null val beacon = BeaconUtils.beaconFromLeScan(device, rssi, scanRecord) return if (beacon?.proximityUUID == null || beacon?.proximityUUID.length < 16) { null } else beacon } /** * 롤리팝 미만인 경우 사용되는 scan callback */ private val mScanCallback = BluetoothAdapter.LeScanCallback { device, rssi, scanRecord -> val beacon = extractBeaconInfo(device, rssi, scanRecord) if(scanIBeaconOnly) { if (mScanListener != null && beacon != null && beacon.proximityUUID.isNotEmpty() && beacon.proximityUUID.length > 15) { // TODO: UUID filtering //if (beacon.proximityUUID.contains(Const.BLE_SCAN_FILTERING_MASK)) { // mScanListener!!.onDeviceFound(device, beacon) //} mScanListener?.onDeviceFound(device, beacon) } } else { mScanListener?.onDeviceFound(device, beacon) } } /** * 롤리팝 이상인 경우 사용되는 Scan callback * @return */ @RequiresApi(Build.VERSION_CODES.LOLLIPOP) private val mScanCallbackLp = object: ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val device = result.device val rssi = result.rssi val scanRecord = result.scanRecord!!.bytes val beacon = extractBeaconInfo(device, rssi, scanRecord) if(scanIBeaconOnly) { if (mScanListener != null && beacon != null && beacon.proximityUUID.isNotEmpty() && beacon.proximityUUID.length > 15) { // TODO: UUID filtering //if (beacon.proximityUUID.contains(Const.BLE_SCAN_FILTERING_MASK)) { // mScanListener!!.onDeviceFound(device, beacon) //} mScanListener?.onDeviceFound(device, beacon) } } else { mScanListener?.onDeviceFound(device, beacon) } } } override fun onBatchScanResults(results: List<ScanResult>?) { ...... } override fun onScanFailed(errorCode: Int) { mScanListener?.onScanFailed(errorCode) } }
주의해야 할 점은, 안드로이드 버전에 따라 BLE 장치를 스캔하는 방법이 다릅니다.
- 안드로이드 5.0 미만
- 개발자가 명시적으로 스캔을 시작하고 종료합니다. 스캔된 모든 BLE 장치들이 콜백함수로 전달됩니다.
- 안드로이드 5.0 이상
- 개발자가 Scan 자체를 on/off 한다기 보단 Android Bluetooth Scanner에게 특정 조건에 맞는 BLE 기기가 발견되면 알려달라고 요청하는 형태에 가깝습니다. Filter를 설정함으로써 특정 조건에 맞는 BLE 장치만 콜백함수로 전달하도록 할 수 있습니다. 또한 스캔 간격 등을 지정해서 에너지 소모율과 스캔 속도를 조절할 수 있습니다.
어느 경우든 BLE 장치가 발견되면 콜백함수가 호출됩니다. 그리고 콜백 함수에서 advertising packet 중 센서장치가 임의로 데이터를 넣어 보내는 Advertiser’s Data 영역(31 bytes)을 받아 처리할 수 있습니다.
Advertiser’ Data 를 확인하고 iBeacon 인지 확인하는 코드가 BeaconUtils.kt – beaconFromLeScan() 입니다. 만약 iBeacon 데이터가 맞다면 Beacon 인스턴스를 생성해서 UI 에 알려줍니다.
내가 원하는 BLE 또는 iBeacon 장치를 찾았고, 이 장치가 연결 가능한 상태라면 연결을 시도할 수 있습니다. Scan 과정에서 BLE 장치가 발견되면 BluetoothDevice 인스턴스를 전달받는데, 이 디바이스에 GATT 연결을 시도하면 됩니다.
fun connect(context: Context, device: BluetoothDevice) { ...... mBluetoothGatt = device.connectGatt(context, false, mGattCallback) mBluetoothGatt?.connect() }
이때 등록하는 BluetoothGattCallback 에 의해 BLE 연결, Service Discovery, 데이터 통신, 연결 해제 등 모든 작업이 관리됩니다.
private val mGattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { Logs.i(TAG, "onConnectionStateChange") when (newState) { BluetoothGatt.STATE_CONNECTED -> { Logs.i(TAG, "STATE_CONNECTED") gatt.discoverServices() } BluetoothGatt.STATE_CONNECTING -> Logs.i(TAG, "STATE_CONNECTING") BluetoothGatt.STATE_DISCONNECTED -> { Logs.i(TAG, "STATE_DISCONNECTED") mGattListener?.onDisconnected() } BluetoothGatt.STATE_DISCONNECTING -> Logs.i(TAG, "STATE_DISCONNECTING") else -> { mGattListener?.onConnectionFail() } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { Logs.i(TAG, "onServicesDiscovered") if (BluetoothGatt.GATT_SUCCESS == status) { Logs.i(TAG, "GATT_SUCCESS") discoverService(gatt) } else { Logs.i(TAG, "GATT_FAIL") } } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { Logs.i(TAG, "onCharacteristicWrite") if (Const.SERVICE_UUID.equals(characteristic.service.uuid.toString())) { if (status == BluetoothGatt.GATT_SUCCESS) { mGattListener?.onWriteSuccess(characteristic) } else { mGattListener?.onWriteFailure(characteristic) } } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { Logs.i(TAG, "onCharacteristicChanged") mGattListener?.onNotify(characteristic) // data is in characteristic.value } override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { Logs.i(TAG, "onCharacteristicRead") super.onCharacteristicRead(gatt, characteristic, status) if (BluetoothGatt.GATT_SUCCESS == status) { mGattListener?.onRead(characteristic) // data is in characteristic.value } } override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Logs.i(TAG, "onDescriptorRead") super.onDescriptorRead(gatt, descriptor, status) } override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Logs.i(TAG, "onDescriptorWrite=$status") //gatt.writeCharacteristic(mWrite) super.onDescriptorWrite(gatt, descriptor, status) } override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) { Logs.i(TAG, "onReliableWriteCompleted") super.onReliableWriteCompleted(gatt, status) } override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { Logs.i(TAG, "onReadRemoteRssi") super.onReadRemoteRssi(gatt, rssi, status) } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { Logs.i(TAG, "onMtuChanged") super.onMtuChanged(gatt, mtu, status) } }
등록된 콜백 함수들 이름을 보면 대강 어떤 역할을 하는지 알 수 있을 것입니다.
활용
구상하고 있는 사물인터넷 서비스에 웨어러블 장치 등이 포함된다면 BLE 통신을 피해갈 수 없는 요소입니다. 특히 센서장치 또는 웨어러블 장치와 모바일 장치의 통신은 가장 흔하게 사용되는 시나리오기 때문에 양쪽 플랫폼에서 사용되는 코드를 유심히 봐 둘 필요가 있습니다. 이런 서비스를 직접 구현해야 하는 순간이 오면 이런 코드들이 여러분의 시간과 노력을 아껴줄 수 있을겁니다.
참고
주의!!! [사물 인터넷 네트워크와 서비스 구축 강좌] 시리즈 관련 문서들은 무단으로 내용의 일부 또는 전체를 게시하여서는 안됩니다. 계속 내용이 업데이트 되는 문서이며, 문서에 인용된 자료의 경우 원작자의 라이센스 문제가 있을 수 있습니다.
강좌 전체보기
.