강좌 전체보기

.

비컨이 구현도 간단하고 쓰임새도 다양함은 두 말 할 필요가 없습니다. 하지만 비컨으로 전달 가능한 데이터는 한계가 명확합니다. 스마트 밴드나 스마트 워치를 떠올려 보면 왜 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 에서 설치할 수도 있습니다.

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 통신을 피해갈 수 없는 요소입니다. 특히 센서장치 또는 웨어러블 장치와 모바일 장치의 통신은 가장 흔하게 사용되는 시나리오기 때문에 양쪽 플랫폼에서 사용되는 코드를 유심히 봐 둘 필요가 있습니다. 이런 서비스를 직접 구현해야 하는 순간이 오면 이런 코드들이 여러분의 시간과 노력을 아껴줄 수 있을겁니다.

참고

주의!!! [사물 인터넷 네트워크와 서비스 구축 강좌] 시리즈 관련 문서들은 무단으로 내용의 일부 또는 전체를 게시하여서는 안됩니다. 계속 내용이 업데이트 되는 문서이며, 문서에 인용된 자료의 경우 원작자의 라이센스 문제가 있을 수 있습니다.

강좌 전체보기

.