안녕하세요? 닉네임간편입니다. 이번 시간에는 블루투스 기기를 연결하는 법에 대해 설명드리겠습니다.
전체 소스는 여기에 있습니다.
https://github.com/creativeduck/MyLED
앱을 미리 사용해보고 계신 분들은, 이 링크를 타고 설치해주시면 됩니다.
https://play.google.com/store/apps/details?id=com.mybest.myled
1. 기기 연결 준비
// 블루투스 페어링된 목록에서 디바이스 기기 가져오기
private BluetoothDevice getPairedDevice(String name) {
BluetoothDevice selectedDevice = null;
for(BluetoothDevice device : pairedDevices) {
if(name.equals(device.getName())) {
selectedDevice = device;
break;
}
}
return selectedDevice;
}
// 페어링되지 않은 기기 목록에서 디바이스 기기 가져오기
private BluetoothDevice getUnpairedDevice(String name) {
BluetoothDevice selectedDevice = null;
for(BluetoothDevice device : unpairedDevices) {
if(name.equals(device.getName())) {
selectedDevice = device;
break;
}
}
return selectedDevice;
}
먼저 기기 연결을 위한 메서드를 정의하겠습니다. 이 메서드는 파라미터로 전달한 기기의 이름에 해당하는 블루투스 기기를 가져오는 메서드입니다.
이때 페어링 된 기기 목록에서 디바이스를 가져오는 것과 페어링 되지 않은 기기 목록에서 디바이스를 가져오는 것은 코드의 측면에서 동일하므로 전자만 설명드리겠습니다.
1) 변수 정의
블루투스 기기를 정의합니다.
2) 이름과 동일한 장치 가져오기
forEach 구문을 통해 파라미터로 전달된 이름과 동일한 장치가 있다면, 그 장치를 가져와 selectedDevice가 참조하도록 합니다.
이후 break를 통해 반복문을 빠져나온 후 해당 장치를 반환합니다.
다음으로 기기 연결에 필요한 변수들을 정의합니다.
BluetoothSocket bluetoothSocket;
OutputStream outputStream;
InputStream inputStream;
1) BluetoothSocket
소켓은 두 기기 간 통신을 위한 끝점(endpoint)입니다. 끝점이라는 것은 아이피 주소(IP Address)와 포트(port) 번호의 조합을 뜻하며, 스마트폰이나 블루투스 이어폰과 같은 '데이터를 송수신할 종착점'(실제 데이터를 송수신할 곳)을 나타냅니다.
쉽게 말해, 프로그램이 네트워크 상에서 데이터를 송수신할 수 있도록 연결해주는 연결부가 바로 소켓이라고 할 수 있습니다.
블루투스 통신은 블루투스 소켓을 이용하여 동작하므로 이 객체를 미리 정의합니다.
2) OutputStream, InputStream
데이터를 읽고 쓸 수 있는 입출력 스트림입니다.
스트림은 단방향으로 데이터가 전달되므로 데이터를 교환하기 위해선 입력 스트림과 출력 스트림이 모두 필요합니다.
OutputStream은 출력 스트림으로 데이터를 출력하고, 쉽게 말해 데이터를 보냅니다.
write(byte[]) 메서드를 통해 바이트 배열의 데이터를 출력할 수 있습니다.
InputStream은 입력 스트림으로 데이터를 입력받고, 쉽게 말해 데이터를 받아서 읽습니다.
read(byte[]) 메서드를 통해 바이트 배열의 데이터를 입력할 수 있습니다.
2. 기기 연결 개요
'연결'은 기기가 현재 RFCOMM 채널을 공유하고 있고 데이터를 서로 전송할 수 있다는 것을 의미합니다. RFCOMM은 기기 간 통신을 할 수 있도록 하는 체계 중 하나입니다.
두 기기를 연결하려면 서버와 클라이언트 역할이 필요합니다. 한 기기(서버 역할)는 서버 소켓을 열고 다른 기기(클라이언트 역할)는 클라이언트 소켓을 통해 서버 기기의 MAC 주소를 사용하여 연결을 시작해야 하기 때문입니다.
클라이언트 소켓은 서버 소켓으로 연결을 요청하고, 서버 소켓은 들어오는 연결 요청을 받을 수 있도록 대기합니다. 그리고 서버 소켓이 들어온 요청을 수락하면, 서버 측은 연결을 관리할 블루투스 소켓을 반환합니다.
쉽게 말하면, 클라이언트 측 기기는 연결을 요청하고 수행하는 역할을, 서버 측 기기는 연결을 대기하고 요청이 오면 수락하는 역할을 합니다.
3. 주요 사항
1) 클라이언트 메커니즘만 구현
제가 사용하는 블루투스 모듈 HC-06은 블루투스 클래식이라는 기술을 사용하며, 여기엔 슬레이브와 마스터라는 개념이 있습니다.
통상 마스터는 기기를 검색하고 연결을 요청하는 역할을 하며, 슬레이브는 검색 및 연결을 대기하는 역할을 합니다. 각각 클라이언트와 서버에 대응되는 개념으로 생각하시면 되겠습니다.
HC-06 모듈은 기본적으로 슬레이브 역할(서버와 유사)을 하기 때문에, 스마트폰이 마스터 역할(클라이언트와 유사)을 할 수 있도록 만들어 연결하면 되겠습니다. 즉, 스마트폰이 클라이언트 측 역할만 하면 됩니다.
따라서 본 예제에서는 클라이언트로 연결하는 과정만 서술하겠습니다.
2) 페어링 여부에 관계없음
안드로이드 블루투스 API는 RFCOMM 연결을 설정하기 전에 기기를 페어링 하도록 요청합니다. 따라서 이전에 페어링이 되어있지 않았다고 해도 연결을 시작하면 페어링이 자동으로 실행됩니다. 이 과정은 안드로이드 프레임워크가 진행하므로 페어링을 위한 조치는 따로 취하지 않아도 됩니다.
바꿔 말하면 연결을 수행하면서 페어링이 되어있지 않다면 페어링을 자동으로 요청한 다음 연결을 수행합니다. 그렇기에 기기 연결 메서드는 페어링 여부에 관계없이 동일하게 동작하기 때문에, 메서드는 하나만 만들었습니다. 단지 메서드 내부에서 페어링 여부에 따라 가져오는 블루투스 기기의 차이만 있을 뿐입니다.
이는 코드를 통해 더 자세히 알아보겠습니다.
4. 기기 연결 메서드
설명에 앞서, 이 메서드는 다소 길기 때문에 핸들러 부분과 스레드 부분을 구분하여 먼저 설명드린 이후 전체 코드를 보여드리겠습니다.
A. 별도 스레드
private void connectDevice(String selectedDeviceName, boolean paired) {
// 별도 스레드 생성
Thread thread = new Thread(new Runnable() {
public void run() {
if(paired) // 페어링된 기기라면
bluetoothDevice = getPairedDevice(selectedDeviceName);
else // 페어링되지 않은 기기라면
bluetoothDevice = getUnpairedDevice(selectedDeviceName);
// UUID 생성
UUID uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");
try {
bluetoothSocket = bluetoothDevice.createRfcommSocketToServiceRecord(uuid); // 소켓 생성
bluetoothSocket.connect(); // 소켓 연결
mHandler.sendEmptyMessage(1); // 핸들러에 메시지 1 보내기
} catch (IOException e) {
e.printStackTrace();
mHandler.sendEmptyMessage(-1); // 핸들러에 메시지 -1 보내기
}
}
});
thread.start(); // 별도 스레드 시작
}
1) 별도 스레드 생성
안드로이드 기기에선 언제 블루투스로부터 데이터를 받을지 모르기 때문에, 메인 스레드와 별도로 블루투스로부터 데이터를 받는 스레드를 만들어야 합니다.
만일 메인 스레드가 하나만 존재하고 블루투스로부터 데이터를 받는 작업을 메인 스레드에서 하면, 사용자가 불편함을 느낄 수 있습니다. 메인 스레드는 블루투스로부터 데이터를 받기 위해 기다리고 있고, 이 과정에서 사용자가 다른 동작을 할 수 없기 때문입니다. 즉, 화면을 터치하거나 위젯을 클릭해도 다른 기능을 사용할 수 없게 됩니다.
따라서 별도의 데이터를 처리하는 스레드를 만들고 이를 핸들러로 관리합니다.
스레드 생성자의 파라미터로는 Runnable 객체를 전달하고, 이 객체의 run() 메서드를 재정의해서 별도의 스레드가 실행할 작업을 적습니다.
2) 페어링 여부에 따른 기기 참조
paired가 true라면, 즉 페어링 된 기기를 연결하는 것이라면 페어링 된 기기 목록에서 기기를 가져옵니다. 그렇지 않다면, 페어링 되지 않은 기기 목록에서 가져옵니다.
3) UUID (범용 고유 식별자)
네트워크 상에 존재하는 개체의 이름을 정하는 방법이며, 국제기구에서 표준으로 정하고 있습니다. 128비트의 숫자들을 조합해 만듭니다. 이를 이용해 개체 이름의 고유성을 보장하고 원하는 개체와 연결할 수 있도록 만들어줍니다.
UUID의 이름은 어떻게 정해도 상관이 없지만, 만일 특정 프로토콜의 기기에 접근하기 위해선 특정 UUID를 사용해야 합니다.
저는 블루투스 통신을 해야 하기 때문에 SerialPortServiceClass_UUID의 값을 사용했습니다. 이 값은 코드에 나와있는 것처럼 "00001101-0000-1000-8000-00805F9B34FB"입니다.
특정 UUID 값은 인터넷에 검색하면 나오기 때문에 만일 다른 제품을 만들면서 특정 UUID가 필요하시다면 해당 UUID를 사용하시면 되겠습니다.
4) 블루투스 소켓
createRfcommSocketToServiceRecord() 메서드를 사용하여 블루투스 소켓(클라이언트 소켓)을 만듭니다.
이때 파라미터로 앞서 만든 UUID를 전달합니다.
클라이언트 측과 서버 측의 UUID가 일치해야 연결이 가능하기 때문입니다.
5) connect()
connect() 메서드를 호출하여 연결을 시작합니다.
연결이 성공할 경우 핸들러에 1 값을 메시지로 보냅니다.
연결에 실패하거나 connect() 메서드의 시간(12초 정도)이 초과되면 IOException 예외를 발생시킵니다. 이 경우 핸들러에 -1 값을 메시지로 보냅니다.
이때 connect() 메서드는 차단 호출이므로, 이 연결 절차는 항상 기본 액티비티의 스레드와 분리된 스레드에서 수행되어야 합니다.
차단 호출이란 해당 역할이 끝나기 완료되기 전까지 반환하지 않는 호출입니다. 만일 메인 스레드에서 connect() 메서드가 호출된다면 연결이 되기 전까지, 혹은 연결이 실패하거나 하는 등 연결 부분에 있어 어떤 결과가 나오기 전까지는 사용자는 아무런 동작도 할 수 없습니다.
따라서 별도의 스레드를 만든 후 이곳에서 connect() 메서드를 호출하는 것이 좋습니다.
createRfcommSocketToServiceRecord()와 connect() 메서드는 둘 모두 IOException 예외를 발생시킬 수 있기 때문에 예외처리를 해줍니다.
6) start()
start() 메서드를 통해 스레드를 시작합니다.
B. 핸들러
private void connectDevice(String selectedDeviceName, boolean paired) {
final Handler mHandler = new Handler() { // 핸들러 객체 생성
public void handleMessage(Message msg) { // handleMessage() 메서드 재정의
if(msg.what==1) { // 받은 메시지가 1이라면
try{
// 소켓으로 데이터 송수신을 위한 스트림 객체 얻는다.
outputStream = bluetoothSocket.getOutputStream();
inputStream = bluetoothSocket.getInputStream();
} catch(IOException e) {
e.printStackTrace();
}
}
else { // 연결 오류가 발생하면
Toast.makeText(getApplicationContext(), "연결 오류", Toast.LENGTH_SHORT).show();
try {
bluetoothSocket.close(); // 소켓을 닫는다.
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
}
1) 핸들러 생성
핸들러는 메인 스레드 이외에 별도 스레드로부터 메시지 혹은 Runnable 객체를 전달받아 특정 작업을 메인 스레드에서 할 수 있도록 해줍니다.
앞서 별도 스레드를 만든 것처럼 안드로이드는 멀티 스레드 방식을 통해 다양한 작업을 동시에 수행할 수 있습니다. 그러나 같은 프로세스 안에 메모리 리소스를 공유하기 때문에, 여러 개의 스레드가 동시에 같은 메모리 리소스에 접근하게 될 경우 데드락이 발생할 수 있습니다.
데드락(교착상태)이란 두 개 이상의 스레드가 서로 작업이 끝나기를 기다리며 아무것도 작업을 완료할 수 없는 상태를 말합니다.
예를 들어 외나무다리에서 서로 앞을 가려고 양보하지 않는다고 가정하겠습니다. 누구도 다리를 건너는 작업을 완료할 수 없으므로, 이것이 데드락, 교착상태라고 할 수 있겠습니다.
이렇게 데드락이 발생하면 시스템이 비정상적으로 동작할 수 있습니다. 따라서 이를 방지하기 위해 별도 스레드의 작업을 순서대로 처리하도록 조치를 취해야 하며, 이 역할을 핸들러가 담당합니다.
앞서 별도 스레드가 핸들러로 메시지를 보내면, handleMessage() 메서드 내부에서 해당 메시지에 상응하는 동작을 수행합니다.
2) 입출력 스트림
msg.what이 1이라면, 즉 연결에 성공했다면 입출력 스트림을 만듭니다.
getInputStream()과 getOutputStream() 메서드를 호출하면 블루투스 소켓을 통해 데이터 송수신을 처리하는 InputStream 및 OutputStream 객체를 가져올 수 있습니다.
이후 데이터 송수신 메서드에서 read()와 write() 메서드를 사용해 스트림에 데이터를 읽고 쓸 수 있습니다.
이때 두 메서드 모두 IOException을 발생시킬 수 있으므로 try-catch 구문을 통해 예외처리를 해줍니다.
3) 연결 오류
만일 메시지가 1이 아니라면 연결 오류가 발생한 것이므로, 토스트 메시지로 이를 알려줍니다. 이후 소켓을 닫고 리소스를 해제해 자원이 누수되는 걸 방지합니다.
연결 오류가 발생한 경우 다음과 같이 됩니다.
5. 전체 소스
private void connectDevice(String selectedDeviceName, boolean paired) {
final Handler mHandler = new Handler() { // 핸들러 객체 생성
public void handleMessage(Message msg) { // handleMessage() 메서드 재정의
if(msg.what==1) { // 받은 메시지가 1이라면
try{
// 입출력 스트림 객체 생성
outputStream = bluetoothSocket.getOutputStream();
inputStream = bluetoothSocket.getInputStream();
} catch(IOException e) {
e.printStackTrace();
}
} else { // 연결 오류나면
Toast.makeText(getApplicationContext(), "연결 오류", Toast.LENGTH_SHORT).show();
try {
bluetoothSocket.close(); // 소켓 닫아주고 리소스 해제
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
// 별도 스레드 생성
Thread thread = new Thread(new Runnable() {
public void run() {
if(paired) // 페어링된 기기라면
bluetoothDevice = getPairedDevice(selectedDeviceName);
else // 페어링되지 않은 기기라면
bluetoothDevice = getUnpairedDevice(selectedDeviceName);
// UUID 생성
UUID uuid = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb");
try {
bluetoothSocket = bluetoothDevice.createRfcommSocketToServiceRecord(uuid); // 소켓 생성
bluetoothSocket.connect(); // 소켓 연결
mHandler.sendEmptyMessage(1); // 핸들러에 메시지 1 보내기
} catch (IOException e) {
e.printStackTrace();
mHandler.sendEmptyMessage(-1); // 핸들러에 메시지 -1 보내기
}
}
});
thread.start(); // 별도 스레드 시작
}
6. OnDestroy()
@Override
protected void onDestroy() {
try{
inputStream.close();
outputStream.close();
bluetoothSocket.close();
} catch(IOException e) {
e.printStackTrace();
}
unregisterReceiver(mReceiver);
super.onDestroy();
}
앱이 종료되기 전에 스트림과 소켓을 모두 닫아주어야 합니다. 따라서 onDestroy() 메서드를 재정의한 부분에 close() 메서드를 통해 소켓과 스트림을 모두 닫고 관련된 내부 리소스를 해제합니다.
이렇게 하는 이유는 시스템 자원과 관련이 있습니다. 자원은 한정적이기에 최대한 효율적으로 사용해야 하며, 어떤 기능을 사용하지 않는다면 해당 기능이 자원을 차지하지 않도록 해야 합니다. 따라서 앱이 종료될 때 스트림과 소켓은 더 이상 사용되지 않으므로 이들이 여전히 시스템 자원을 차지하고 있는 것을 막아주어야 합니다. 따라서 close() 메서드를 통해 스트림과 소켓을 닫아주고 관련된 리소스를 해제합니다.
7. 마무리
이번 시간에는 페어링 된 기기, 혹은 검색한 기기를 연결하는 법에 대해 알아보았습니다.
다음 시간에는 만들었던 스트림 객체를 통해 데이터를 송수신하는 법에 대해 알아보겠습니다.
오늘 알려드렸던 정보가 많은 도움이 되길 바랍니다.
'프로젝트 > 블루투스 무드등' 카테고리의 다른 글
[비전공자도 만들 수 있는 블루투스 무드등] 13. 안드로이드 앱 part 3-1 with 비트맵(Bitmap) (0) | 2021.07.26 |
---|---|
[비전공자도 만들 수 있는 블루투스 무드등] 12. 안드로이드 앱 part 2-6, 블루투스 데이터 송수신 (0) | 2021.07.25 |
[비전공자도 만들 수 있는 블루투스 무드등] 10. 안드로이드 앱 part 2-4, 블루투스 페어링되지 않은 기기 탐색 (0) | 2021.07.23 |
[비전공자도 만들 수 있는 블루투스 무드등] 9. 안드로이드 앱 part 2-3 with 위험 권한 (8) | 2021.07.22 |
[비전공자도 만들 수 있는 블루투스 무드등] 8. 안드로이드 앱 part 2-2, 블루투스 기기 탐색 (0) | 2021.07.21 |