안드로이드 블루투스 개발: BLE 기초 및 BluetoothKit 프레임워크 활용

  1. 블루투스 기본 개념 ================

1.1 BLE 블루투스 소개

안드로이드 4.3(API 레벨 18)부터 Bluetooth Low Energy(BLE, 저전력 블루투스)의 핵심 기능이 도입되었으며, 관련 API가 제공되기 시작했습니다. 애플리케이션은 이 API를 통해 블루투스 장치를 검색하고 서비스를 쿼리하며 장치의 특성(characteristics)을 읽고 쓰는 등의 작업을 수행할 수 있습니다.

안드로이드 BLE는 GATT 프로토콜을 사용하며, 이 프로토콜에 대한 자세한 내용은 블루투스 공식 문서 또는 블로그 참고문서에서 확인할 수 있습니다. 안드로이드 개발에서 사용되는 주요 전문 용어를 설명하기 위해 공식 문서의 이미지를 인용합니다. (전문 용어는 1.2절 참조)

1.2 안드로이드 BLE 관련 시스템 API

프로필(Profile) 블루투스 통신을 위한 일반적인 규격으로, BLE 블루투스는 이 규격에 따라 데이터를 송수신해야 합니다.

서비스(Service) 하나의 저전력 블루투스 장치는 여러 서비스를 정의할 수 있으며, 서비스는 기능의 집합으로 이해할 수 있습니다. 장치의 각 서비스는 128비트 UUID로 고유하게 식별됩니다. 블루투스 핵심 규격은 두 가지 종류의 UUID를 정의했습니다: 기본 UUID와 16비트 축약 UUID. 블루투스 특수기술그룹(SIG)이 정의한 모든 UUID는 기본 UUID(0x0000xxxx-0000-1000-8000-00805F9B34FB)를 공유합니다. 기본 UUID를 단순화하기 위해, 각 블루투스 특수기술그룹이 정의하는 속성은 고유한 16비트 UUID를 가지며, 이는 기본 UUID의 'xxxx' 부분을 대체합니다. 예를 들어, 심박수 측정 특성은 0X2A37을 16비트 UUID로 사용하므로, 전체 128비트 UUID는 0x00002A37-0000-1000-8000-00805F9B34FB가 됩니다.

BluetoothAdapter 시스템의 기본 블루투스 작업을 담당하며, 블루투스 스캔, 연결, MAC 주소를 사용하여 BluetoothDevice 인스턴스화 등을 수행합니다.

BluetoothDevice 원격 블루투스 장치를 나타냅니다. 이 클래스를 통해 해당 장치에 연결하거나 이름, 주소, 바인딩 상태 등 정보를 얻을 수 있습니다.

RSSI (Received Signal Strength Indication) 검색된 장치의 신호 강도를 나타내는 값입니다.

BluetoothGatt 블루투스 GATT의 기본 기능을 제공합니다. 예를 들어, 블루투스 장치 재연결, 서비스 검색 등을 수행하며, 중앙 장치(예: 스마트폰)와 주변장치(예: 스마트밴드 등 BLE 장치) 간의 데이터 채널을 설정합니다.

UUID 하나의 서비스는 하나의 UUID에 해당합니다.

BluetoothGattService 하나의 저전력 블루투스 장치는 여러 서비스를 정의할 수 있으며, 서비스는 기능의 집합으로 이해할 수 있습니다. 각 서비스는 128비트 UUID로 고유하게 식별됩니다. 이 클래스는 BluetoothGatt#getService를 통해 얻을 수 있으며, 현재 서비스가 보이지 않으면 null을 반환합니다. 이 클래스는 위에서 설명한 Service에 해당하며, getCharacteristic(UUID uuid) 메서드를 통해 Characteristic을 얻어 블루투스 데이터 양방향 전송을 구현할 수 있습니다.

BluetoothGattCharacteristic 서비스 아래에는 독립적인 데이터 항목이 여러 개 포함되며, 이를 Characteristic이라고 합니다. 각 Characteristic도 고유한 UUID로 식별됩니다. 안드로이드 개발에서 블루투스 연결 후, 블루투스를 통해 주변장치에 데이터를 전송하는 것은 이러한 Characteristic의 Value 필드에 데이터를 쓰는 것을 의미합니다. 주변장치에서 스마트폰으로 데이터를 전송하는 것은 이러한 Characteristic의 Value 필드가 변경되었는지 모니터링하는 것을 의미하며, 변경되면 안드로이드 BLE API가 콜백을 수신합니다.

BluetoothGattDescriptor Characteristic 아래에 있는 설명자로, Characteristic의 범위, 측정 단위 등을 설명합니다.

알림(Notification)과 표시(Indication)

  • Notification: 주변장치(하드웨어)가 중앙 장치(스마트폰)에 데이터를 전송하며 수신자 확인이 필요 없습니다.
  • Indication: 주변장치가 중앙 장치에 데이터를 전송하며 수신자 확인이 필요합니다. 둘의 관계는 TCP 프로토콜과 UDP 프로토콜의 관계와 유사하며, 효율성 측면에서 Notification이 Indication보다 높습니다. 블루투스 API에서는 notify() 메서드와 indicate() 메서드로 구현됩니다.

1.3 기존 블루투스(Classic Bluetooth)와의 비교

1.3.1 블루투스 모듈 분류

블루투스 모듈은 크게 두 가지로 분류됩니다:

  • 클래식 블루투스: 블루투스 프로토콜 4.0 미만을 지원하며, 음성, 음악, 대용량 데이터 전송 등에 사용됩니다.
  • 저전력 블루투스(BLE): 블루투스 프로토콜 4.0 이상을 지원하며, 비용과 전력 소모가 감소하는 것이 특징입니다.

1.3.2 BLE와 클래식 블루투스의 비교

클래식 블루투스 모듈(BT):

  • 블루투스 프로토콜 4.0 미만을 지원하며, 주로 데이터 양이 많은 전송에 사용됩니다(예: 음성, 음악, 대용량 데이터 전송 등).
  • 전통 블루투스 모듈(2004년 출시)과 고속 블루투스 모듈(2009년 출시)로 세분화될 수 있습니다.
  • 고속 블루투스 모듈은 약 24Mbps의 속도를 제공하며, 전통 블루투스 모듈보다 8배 빠릅니다.

저전력 블루투스 모듈(BLE):

  • 블루투스 프로토콜 4.0 이상을 지원하며, BLE 모듈(Bluetooth Low Energy Module)이라고도 합니다.
  • 비용과 전력 소모가 감소하는 것이 가장 큰 특징입니다.
  • 실시간성 요구는 높지만 데이터 전송률은 낮은 제품에 적용됩니다(예: 리모컨류(마우스, 키보드), 센서 장치 데이터 전송(심박대, 혈압계, 온도 센서 등)).

이제 실제 작업에서 사용되는 블루투스 라이브러리인 BluetoothKit 사용법을 알아보겠습니다.

  1. 프레임워크 소개 ===============

2.1 프레임워크 프로젝트 주소

https://github.com/dingjikerbo/BluetoothKit

2.2 프레임워크 장점

  • 안드로이드 블루투스 통신 과정의 호환성 문제를 통합 해결
  • 가능한 한 간단하고 사용하기 쉬운 인터페이스를 제공하며, 블루투스 통신의 기술적 세부 사항을 숨기고 연결, 읽기, 쓰기, 알림 등의 의미만 노출
  • 직렬화된 작업 큐 구현, 통신 실패 및 타임아웃을 통일 처리하고 구성 가능한 오류 처리 지원
  • 연결 핸들을 통일 관리하여 핸 누수 방지
  • 각 장치 연결 상태를 모니터링하고, 연결을 최대한 유지하면서 가장 활동적이지 않은 장치를 자동으로 연결 해제
  • 다중 프로세스 앱 아키텍처에서 블루투스 연결의 통합 관리 지원
  • 모든 블루투스 네이티브 인터페이스 호출을 가로챌 수 있음

2.3 기본 사용법

2.3.1 의존성 추가

implementation 'com.inuker.bluetooth:library:1.4.0'
또는 라이브러리 모듈 직접 임포트

2.3.2 Manifest에 블루투스 권한 추가

<!-- 블루투스 관련 권한 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

2.3.3 스캔

BluetoothManager bluetoothManager = new BluetoothManager(context);
SearchRequest request = new SearchRequest.Builder()
        .searchBluetoothLeDevice(3000, 3)   // BLE 장치 3회 스캔, 각각 3초
        .searchBluetoothClassicDevice(5000) // 클래식 블루투스 5초 스캔
        .searchBluetoothLeDevice(2000)      // 추가로 BLE 장치 2초 스캔
        .build();

bluetoothManager.search(request, new SearchCallback() {
    @Override
    public void onSearchStarted() {
        // 스캔 시작
    }
    
    @Override
    public void onDeviceFounded(SearchResult result) {
        // 장치 발견 시, 제조사로 필터링 가능
        Beacon beacon = new Beacon(result.scanRecord);
        Log.d("Bluetooth", String.format("beacon for %s\n%s", result.getAddress(), beacon.toString()));
    }
    
    @Override
    public void onSearchStopped() {
        // 스캔 중지
    }
    
    @Override
    public void onSearchCanceled() {
        // 스캔 취소
    }
});

2.3.4 연결

// 연결 옵션 설정
BleConnectionOptions options = new BleConnectionOptions.Builder()
        .setConnectRetry(3)   // 연결 실패 시 3회 재시도
        .setConnectTimeout(30000)   // 연결 타임아웃 30초
        .setServiceDiscoveryRetry(3)  // 서비스 검색 실패 시 3회 재시도
        .setServiceDiscoveryTimeout(20000)  // 서비스 검색 타임아웃 20초
        .build();

bluetoothManager.connect(deviceMac, options, new BleConnectionCallback() {
    @Override
    public void onConnectionResult(int code, BleProfile profile) {
        // 연결 결과 처리
    }
});

// 연결 상태 리스너 등록
bluetoothManager.registerConnectionListener(deviceMac, connectionStatusListener);

private final BleConnectionStatusListener connectionStatusListener = new BleConnectionStatusListener() {
    @Override
    public void onConnectionStatusChanged(String mac, int status) {
        if (status == ConnectionStatus.CONNECTED) {
            // 연결됨
        } else if (status == ConnectionStatus.DISCONNECTED) {
            // 연결 해제됨
        }
    }
};

// 연결 상태 리스너 해제
bluetoothManager.unregisterConnectionListener(deviceMac, connectionStatusListener);

2.3.5 통신

// Characteristic 읽기
bluetoothManager.readCharacteristic(deviceMac, serviceUuid, characteristicUuid, new BleReadCallback() {
    @Override
    public void onReadResult(int code, byte[] data) {
        if(code == ResultCode.SUCCESS) {
            // 데이터 읽기 성공
        }
    }
});

// Characteristic 쓰기
// 주의: byte[]는 20바이트를 초과할 수 없습니다. 초과 시 여러 번에 나누어 써야 합니다.
byte[] data = ...; // 쓸 데이터
bluetoothManager.writeCharacteristic(deviceMac, serviceUuid, characteristicUuid, data, new BleWriteCallback() {
    @Override
    public void onWriteResult(int code) {
        if(code == ResultCode.SUCCESS) {
            // 데이터 쓰기 성공
        }
    }
});

// 응답 없는 쓰기 (WRITE_TYPE_NO_RESPONSE)
// 일반 쓰기보다 2~3배 빠르며, 펌웨어 업데이트에 권장됩니다.
bluetoothManager.writeNoResponse(deviceMac, serviceUuid, characteristicUuid, data, new BleWriteCallback() {
    @Override
    public void onWriteResult(int code) {
        if(code == ResultCode.SUCCESS) {
            // 데이터 쓰기 성공
        }
    }
});

// Descriptor 읽기
bluetoothManager.readDescriptor(deviceMac, serviceUuid, characteristicUuid, descriptorUuid, new BleReadCallback() {
    @Override
    public void onReadResult(int code, byte[] data) {
        // Descriptor 데이터 처리
    }
});

// Descriptor 쓰기
bluetoothManager.writeDescriptor(deviceMac, serviceUuid, characteristicUuid, descriptorUuid, data, new BleWriteCallback() {
    @Override
    public void onWriteResult(int code) {
        // Descriptor 쓰기 결과 처리
    }
});

// 알림(Notification) 활성화
bluetoothManager.enableNotification(deviceMac, serviceUuid, characteristicUuid, new BleNotificationCallback() {
    @Override
    public void onNotificationReceived(UUID service, UUID characteristic, byte[] value) {
        // 알림 수신 처리
    }
    
    @Override
    public void onNotificationEnabled(int code) {
        if(code == ResultCode.SUCCESS) {
            // 알림 활성화 성공
        }
    }
});

// 알림(Notification) 비활성화
bluetoothManager.disableNotification(deviceMac, serviceUuid, characteristicUuid, new BleCallback() {
    @Override
    public void onResult(int code) {
        if(code == ResultCode.SUCCESS) {
            // 알림 비활성화 성공
        }
    }
});

// 표시(Indication) 활성화
bluetoothManager.enableIndication(deviceMac, serviceUuid, characteristicUuid, new BleNotificationCallback() {
    @Override
    public void onIndicationReceived(UUID service, UUID characteristic, byte[] value) {
        // 표시 수신 처리
    }
    
    @Override
    public void onIndicationEnabled(int code) {
        if(code == ResultCode.SUCCESS) {
            // 표시 활성화 성공
        }
    }
});

// 표시(Indication) 비활성화
bluetoothManager.disableIndication(deviceMac, serviceUuid, characteristicUuid, new BleCallback() {
    @Override
    public void onResult(int code) {
        if(code == ResultCode.SUCCESS) {
            // 표시 비활성화 성공
        }
    }
});

// RSSI 읽기
bluetoothManager.readRssi(deviceMac, new BleRssiCallback() {
    @Override
    public void onRssiRead(int code, Integer rssi) {
        if(code == ResultCode.SUCCESS) {
            // RSSI 값 처리
        }
    }
});

2.3.6 연결 해제

bluetoothManager.disconnect(deviceMac);
  1. 블루투스 프레임워크 소스 코드 분석 ===================

이제 프레임워크의 "연결" 기능을 예로 소스 코드를 추적해 보겠습니다.

3.1 연결 API 호출

@Override
public void connect(String deviceAddress, BleConnectionOptions options, BleConnectionCallback callback) {
    Log.v("Bluetooth", String.format("Connecting to %s", deviceAddress));
    callback = ProxyUtils.getUIProxy(callback);
    bluetoothManager.connect(deviceAddress, options, callback);
}

3.2 BluetoothManagerImpl의 실제 구현

@Override
public void connect(String deviceAddress, BleConnectionOptions options, final BleConnectionCallback callback) {
    Bundle params = new Bundle();
    params.putString(EXTRA_ADDRESS, deviceAddress);
    params.putParcelable(EXTRA_OPTIONS, options);
    safeCallBluetoothApi(CODE_CONNECT, params, new BluetoothResponse() {
        @Override
        protected void onAsyncResponse(int code, Bundle data) {
            checkRuntime(true);
            if (callback != null) {
                data.setClassLoader(getClass().getClassLoader());
                BleProfile profile = data.getParcelable(EXTRA_GATT_PROFILE);
                callback.onConnectionResult(code, profile);
            }
        }
    });
}

3.3 핵심 메서드: safeCallBluetoothApi

private void safeCallBluetoothApi(int code, Bundle params, final BluetoothResponse response) {
    checkRuntime(true);
    
    try {
        IBluetoothService service = getBluetoothService();
        
        if (service != null) {
            params = (params != null ? params : new Bundle());
            service.executeBluetoothApi(code, params, response);
        } else {
            response.onResponse(ServiceCode.SERVICE_UNAVAILABLE, null);
        }
    } catch (Throwable e) {
        Log.e("Bluetooth", e);
    }
}

이 메서드에서는 블루투스 서비스(bindService를 통해)를 가져온 후 executeBluetoothApi 메서드를 호출합니다. 다음으로 BluetoothServiceImpl의 executeBluetoothApi 메서드를 살펴보겠습니다:

3.4 BluetoothServiceImpl의 API 실행

@Override
public void executeBluetoothApi(int code, Bundle params, final IResponse response) throws RemoteException {
    Message msg = handler.obtainMessage(code, new BleGeneralCallback() {
        @Override
        public void onResponse(int code, Bundle data) {
            if (response != null) {
                if (data == null) {
                    data = new Bundle();
                }
                try {
                    response.onResponse(code, data);
                } catch (Throwable e) {
                    Log.e("Bluetooth", e);
                }
            }
        }
    });
    
    params.setClassLoader(getClass().getClassLoader());
    msg.setData(params);
    msg.sendToTarget();
}

여기서는 핸들러를 사용하여 메시지를 전송하며, handleMessage 메서드에서 메시지를 처리합니다:

3.5 메시지 처리

@Override
public boolean handleMessage(Message msg) {
    Bundle params = msg.getData();
    String address = params.getString(EXTRA_ADDRESS);
    UUID serviceUuid = (UUID) params.getSerializable(EXTRA_SERVICE_UUID);
    UUID characteristicUuid = (UUID) params.getSerializable(EXTRA_CHARACTER_UUID);
    UUID descriptorUuid = (UUID) params.getSerializable(EXTRA_DESCRIPTOR_UUID);
    byte[] value = params.getByteArray(EXTRA_BYTE_VALUE);
    BleGeneralCallback callback = (BleGeneralCallback) msg.obj;
    
    switch (msg.what) {
        case CODE_CONNECT:
            BleConnectionOptions options = params.getParcelable(EXTRA_OPTIONS);
            BleConnectionManager.connect(address, options, callback);
            break;
            
        case CODE_DISCONNECT:
            BleConnectionManager.disconnect(address);
            break;
            
        // 다른 케이스들...
    }
    return true;
}

handleMessage 메서드에서는 Bundle을 먼저 파싱한 후 다른 CODE에 따라 다른 작업을 처리합니다. "연결"에 해당하는 connect 메서드는 다음으로 BleConnectionManager.connect로 이어집니다:

3.6 연결 관리자

@Override
public void connect(BleConnectionOptions options, BleGeneralCallback callback) {
    getConnectionDispatcher().connect(options, callback);
}

여기서는 dispatcher라는 분류 클래스를 통해 작업을 처리하며, dispatcher의 addNewRequest 메서드로 이어집니다:

3.7 요청 추가

private void addNewRequest(BleRequest request) {
    checkRuntime();
    
    if (requestQueue.size() < MAX_REQUEST_COUNT) { // 최대 요청 수는 100
        request.setRuntimeChecker(this);
        request.setAddress(deviceAddress);
        request.setWorker(connectionWorker); // worker가 실제로 작업을 수행
        requestQueue.add(request);
    } else {
        request.onResponse(Code.REQUEST_OVERFLOW);
    }
    
    scheduleNextRequest(10);
}

3.8 다음 요청 스케줄링

private void scheduleNextRequest(long delayInMillis) {
    handler.sendEmptyMessageDelayed(MSG_SCHEDULE_NEXT, delayInMillis);
}

@Override
public boolean handleMessage(Message msg) {
    switch (msg.what) {
        case MSG_SCHEDULE_NEXT:
            scheduleNextRequest();
            break;
    }
    return true;
}

private void scheduleNextRequest() {
    if (currentRequest != null) {
        return;
    }
    
    if (!ListUtils.isEmpty(requestQueue)) {
        currentRequest = requestQueue.remove(0);
        // 핵심 코드
        currentRequest.process(this);
    }
}

여기서 핵심 코드인 currentRequest.process(this)가 있습니다. 다음으로 BleRequest가 실제로 어떻게 작업을 수행하는지 살펴보겠습니다:

3.9 요청 처리

@Override
final public void process(IBleConnectionDispatcher dispatcher) {
    checkRuntime();
    
    this.dispatcher = dispatcher;
    
    Log.w("Bluetooth", String.format("Processing %s, status = %s", 
            getClass().getSimpleName(), getStatusText()));
    
    // 호환성 확인
    if (!BluetoothUtils.isBleSupported()) {
        onRequestCompleted(Code.BLE_NOT_SUPPORTED);
    } else if (!BluetoothUtils.isBluetoothEnabled()) {
        onRequestCompleted(Code.BLUETOOTH_DISABLED);
    } else {
        try {
            registerGattCallbackListener(this);
            processRequest();
        } catch (Throwable e) {
            Log.e("Bluetooth", e);
            onRequestCompleted(Code.REQUEST_EXCEPTION);
        }
    }
}

여기서 핵심 메서드인 processRequest()는 추상 메서드이며, 하위 클래스에서 구현해야 합니다. "연결"의 경우 BleConnectionRequest로 추적할 수 있습니다:

3.10 연결 요청 처리

@Override
public void processRequest() {
    processConnection();
}

private void processConnection() {
    handler.removeCallbacksAndMessages(null);
    serviceDiscoveryCount = 0;
    
    switch (getCurrentStatus()) {
        case STATUS_DEVICE_CONNECTED: // 연결 성공
            processDiscoverService(); // 서비스 처리 - 서비스 읽기
            break;
            
        case STATUS_DEVICE_DISCONNECTED: // 연결 실패, Gatt 열기
            if (!doOpenNewGatt()) {
                closeGatt();
            } else {
                handler.sendEmptyMessageDelayed(MSG_CONNECT_TIMEOUT, 
                        connectionOptions.getConnectTimeout());
            }
            break;
            
        case STATUS_DEVICE_SERVICE_READY:
            onConnectionSuccess();
            break;
    }
}

이상으로 연결 과정의 소스 코드 분석을 마칩니다. 다음으로 연결 콜백 처리를 살펴보겠습니다. 연결을 예로 들어, BleConnectionWorker에는 연결 콜백이 있으며, 다양한 상황에 따라 콜백이 반환됩니다.

태그: 안드로이드 블루투스 BLE BluetoothKit 저전력블루투스

6월 17일 16:34에 게시됨