[사물 인터넷 네트워크와 서비스 구축 강좌] #3-4 모바일-모바일 classic BT 통신
강좌 전체보기
.
이번 파트에서는 안드로이드 폰 2대를 블루투스를 이용해 연결하는 방법입니다. 별다른 외부 통신 모듈이 필요치 않고 phone to phone 직접 연결이 가능하다는 장점이 있어 유용한 경우가 있을겁니다.
안드로이드 블루투스 연결 테스트
블루투스 연결을 위해 안드로이드 폰 하나를 Master 로 만들고 다른 폰 하나는 Slave 로 동작하도록 만들어야 합니다. 각각의 역할은 아래와 같습니다.
- Master
- 일반적으로 센서 장치를 스캔해서 연결할 때의 안드로이드가 Master 역할입니다.
- [스캔 -> 페어링 -> PIN 코드 입력 -> 데이터 전송] 순서로 동작합니다.
- Bluetooth Socket 을 생성해서 연결을 시도합니다.
- Slave
- 센서 장치의 역할을 합니다.
- 외부에서 자신을 스캔할 수 있도록 discoverable 상태로 만들어줘야 합니다.
- discoverable 상태는 300 초 간 유지할 수 있습니다.
- Bluetooth Server Socket 을 생성하고 외부에서의 연결을 기다립니다.
테스트는 간단합니다. 2 대의 안드로이드 폰을 준비하고 앞선 예제에 사용했던 BtChat 앱을 양쪽에 깔아줍니다.
앱을 실행하면 상단에 2개의 버튼이 있습니다. Slave 로 동작할 폰은 [Make discoverable] 을 클릭합니다. 그리고 Master로 동작할 폰에서 [Scan for devices] 버튼을 눌러 다른 폰을 스캔하면 됩니다.
연결할 폰의 이름이 보이면 페어링 후 접속하면 됩니다. 그럼 두 폰이 서로 채팅을 할 수 있습니다.
테스트 자체는 간단합니다. 이제 여기에 사용된 안드로이드 소스코드를 확인해보도록 하겠습니다.
안드로이드 블루투스 코드 분석
테스트에 사용된 BtChat 앱 소스에는 안드로이드에서 클래식 블루투스 사용을 위해 필요한 코드가 모두 들어있습니다. 아래 링크에 안드로이드 Kotlin, Java 소스들이 들어있습니다.
주요한 파일들은 아래와 같습니다.
- SplashActivity.kt
- 앱을 실행했을 때 진입하는 첫 화면입니다. 첫 실행인 경우 사용자가 ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION 권한을 승인하도록 요청합니다. 이 권한은 주변의 블루투스 장치를 scan 하기 위한 필수 권한으로 사용자 동의를 얻어야 합니다. 권한이 이미 승인된 상태라면 바로 MainActivity 로 이동합니다.
- MainActivity.kt
- 채팅 UI 가 표시되는 메인 화면입니다. 상단의 버튼 두 개를 통해 안드로이드 폰을 Master / Slave 로 동작시킬 수 있습니다. Master 로 동작하기 위해 [Scan for devices] 버튼을 누르면 DeviceListActivity 화면을 팝업으로 띄웁니다. DeviceListActivity 화면에서 특정 블루투스 기기를 선택하고 PIN 코드 입력 완료되면 채팅창을 통해 메시지를 주고받을 수 있습니다.
- [Make discoverable] 버튼을 누르면 사용자 승인 후 외부 장치에서 이 폰을 scan 할 수 있습니다. 이 때 폰은 Server socket 을 열고 연결을 기다립니다.
- DeviceListActivity.kt
- 주변에 있는 클래식 블루투스 장치를 스캔하고 결과를 리스트로 알려주는 팝업 화면입니다. 특정 장치를 선택하면 연결을 시도할 수 있습니다.
- bluetooth / BluetoothManager.kt
- 실제 블루투스를 제어하기 위한 핵심 코드들 입니다. 블루투스 동작에 관련된 코드가 모두 여기에 있으므로 이 파일 위주로 분석해야 합니다.
메인 화면에서 [Scan for devices] 버튼을 누르면 DeviceListActivity 화면이 팝업으로 뜹니다.
여기서 스캔을 실행한 경우 BluetoothManager의 코드 호출 순서는 아래와 같습니다.
fun startDiscovery() { mAdapter.startDiscovery() }
mAdapter 는 안드로이드에서 제공하는 BluetoothAdapter 입니다. startDiscovery() 함수를 호출해 스캔을 시작합니다. 스캔 결과는 Broadcast 를 통해 전달됩니다.
그래서 DeviceListActivity 에서 해당 Broadcast 를 수신할 수 있는 Receiver 를 등록해 두었습니다.
// The BroadcastReceiver that listens for discovered devices and // changes the title when discovery is finished private val mReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val action = intent.action // When discovery finds a device if (BluetoothDevice.ACTION_FOUND == action) { // Get the BluetoothDevice object from the Intent val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) // If it's already paired, skip it, because it's been listed already if (device.bondState != BluetoothDevice.BOND_BONDED) { for(cached_device in mDeviceList) { if(cached_device.address.equals(device.address)) return } Logs.d("# Device found... ${device.name}, ${device.address}, state=${device.bondState}") mNewDevicesArrayAdapter.add(device.name + "\n" + device.address) } // When discovery is finished, change the Activity title } else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED == action) { setProgressBarIndeterminateVisibility(false) setTitle(R.string.select_device) if (mNewDevicesArrayAdapter.count == 0) { val noDevices = resources.getText(R.string.none_found).toString() mNewDevicesArrayAdapter.add(noDevices) } showScanButton(true) } } }
장치가 발견되면 리스트에 장치를 추가해서 표시합니다.
리스트에서 장치를 선택하면 해당 장치에 대한 정보를 MainActivity 로 전달해줍니다.
// The on-click listener for all devices in the ListViews private val mDeviceClickListener = AdapterView.OnItemClickListener { av, v, arg2, arg3 -> // Cancel discovery because it's costly and we're about to connect mBluetoothManager.cancelDiscovery() // Get the device MAC address, which is the last 17 chars in the View val info = (v as TextView).text.toString() if (info.length > 16) { val address = info.substring(info.length - 17) Log.d(TAG, "User selected device : $address") // Create the result Intent and include the MAC address val intent = Intent() intent.putExtra(EXTRA_DEVICE_ADDRESS, address) // Set result and finish this Activity setResult(Activity.RESULT_OK, intent) finish() } }
MainActivity 에서 선택된 블루투스 장치 정보를 받아 연결을 시도합니다.
public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { Logs.d(TAG, "onActivityResult $resultCode") when (requestCode) { Const.REQUEST_CONNECT_DEVICE -> { // When DeviceListActivity returns with a device to connect if (resultCode == Activity.RESULT_OK) { // Get the device MAC address val address = data?.extras?.getString(DeviceListActivity.EXTRA_DEVICE_ADDRESS) // Attempt to connect to the device mBluetoothManager.connect(address) } } ...... } // End of switch(requestCode) }
BluetoothManager 에 있는 connect() 함수를 호출합니다. 이때 장치의 MAC address 정보를 함께 넘겨줍니다.
fun connect(device: BluetoothDevice?) { Log.d(TAG, "Connecting to: $device") device ?: return if (state == STATE_CONNECTED) return // Cancel any thread attempting to make a connection if (state == STATE_CONNECTING) { if (mConnectThread != null) { mConnectThread!!.cancel() mConnectThread = null } } // Cancel any thread currently running a connection if (mConnectedThread != null) { mConnectedThread!!.cancel() mConnectedThread = null } // Start the thread to connect with the given device mConnectThread = ConnectThread(device) mConnectThread?.start() state = STATE_CONNECTING }
ConnectThread 를 생성해서 동작시킵니다. ConnectThread 코드는 아래와 같습니다.
/** * This thread runs while attempting to make an outgoing connection * with a device. It runs straight through; the connection either * succeeds or fails. */ private inner class ConnectThread(private val mmDevice: BluetoothDevice) : Thread() { private val mmSocket: BluetoothSocket? init { var tmp: BluetoothSocket? = null // Get a BluetoothSocket for a connection with the // given BluetoothDevice try { tmp = mmDevice.createRfcommSocketToServiceRecord(MY_UUID) } catch (e: IOException) { Log.e(TAG, "create() failed", e) } mmSocket = tmp } override fun run() { Log.i(TAG, "BEGIN mConnectThread") name = "ConnectThread" // Always cancel discovery because it will slow down a connection mAdapter.cancelDiscovery() // Make a connection to the BluetoothSocket try { // This is a blocking call and will only return on a // successful connection or an exception mmSocket!!.connect() } catch (e: IOException) { connectionFailed() // Close the socket try { mmSocket!!.close() } catch (e2: IOException) { Log.e(TAG, "unable to close() socket during connection failure", e2) } // Start the service over to restart listening mode this@BluetoothManager.start() return } // Reset the ConnectThread because we're done synchronized(this@BluetoothManager) { mConnectThread = null } // Start the connected thread connected(mmSocket, mmDevice) } fun cancel() { try { mmSocket!!.close() } catch (e: IOException) { Log.e(TAG, "close() of connect socket failed", e) } } } // End of class ConnectThread
코드가 장황하지만 중요한 내용은 createRfcommSocketToServiceRecord() 함수를 호출해서 해당 블루투스 장치와 연결하기 위한 RFcomm 소켓을 생성하고 연결을 시도합니다. 해당 블루투스 장치의 ServerSocket 과 연결이 완료되면 지금 실행중인 ConnectThread 는 종료하고 ConnectedThread()를 생성하기 위해 connected() 함수를 호출합니다.
/** * This thread runs during a connection with a remote device. * It handles all incoming and outgoing transmissions. */ private inner class ConnectedThread(private val mmSocket: BluetoothSocket) : Thread() { private val mmInStream: InputStream? private val mmOutStream: OutputStream? init { Log.d(TAG, "create ConnectedThread") var tmpIn: InputStream? = null var tmpOut: OutputStream? = null // Get the BluetoothSocket input and output streams try { tmpIn = mmSocket.inputStream tmpOut = mmSocket.outputStream } catch (e: IOException) { Log.e(TAG, "temp sockets not created", e) } mmInStream = tmpIn mmOutStream = tmpOut } override fun run() { Log.i(TAG, "BEGIN mConnectedThread") var bytes: Int // Keep listening to the InputStream while connected while (true) { try { // Read from the InputStream val buffer = ByteArray(128) Arrays.fill(buffer, 0x00.toByte()) bytes = mmInStream!!.read(buffer) // Send the obtained bytes to the main thread mHandler?.obtainMessage(MESSAGE_READ, bytes, -1, buffer)?.sendToTarget() } catch (e: IOException) { Log.e(TAG, "disconnected", e) connectionLost() break } } } /** * Write to the connected OutStream. * @param buffer The bytes to write */ fun write(buffer: ByteArray) { try { mmOutStream!!.write(buffer) // Disabled: Share the sent message back to the main thread // mHandler.obtainMessage(Constants.MESSAGE_WRITE, -1, -1, buffer) // .sendToTarget(); } catch (e: IOException) { Log.e(TAG, "Exception during write") } } fun cancel() { try { mmSocket.close() } catch (e: IOException) { Log.e(TAG, "close() of connect socket failed") } } } // End of class ConnectedThread
ConnectedThread 는 두 블루투스 장치가 연결되어 있는 동안은 계속 실행되는 thread 입니다. 소켓 연결이 완료되었으므로 input stream, output stream 을 생성하고 이를 통해 메시지를 보내거나 읽는 과정을 반복합니다. 메시지를 보낼 때는 ConnectedThread.write() 함수만 호출하면 됩니다. ConnectedThread 의 메인 루프인 run() 함수는 input stream 에서 블루투스 수신 데이터가 있는지 계속 체크해서 데이터를 가져와 UI로 보내주는 역할을 합니다.
ConnectedThread 가 실행되면 두 장치는 블루투스 통신할 준비가 완료된 것입니다.
메인 화면에서 [Make discoverable] 버튼을 누르면, 앞선 과정과는 반대로 외부 블루투스 장치가 이 장치를 스캔할 수 있도록 advertising packet 을 broadcast 합니다.
이때 호출되는 MainActivity 의 코드는 아래와 같습니다.
private fun ensureDiscoverable() { if (mBluetoothManager.getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) { val intent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE) intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 30) startActivityForResult(intent, Const.REQUEST_DISCOVERABLE) ...... } else { ...... } }
안드로이드 framework 에 discoverable 요청을 보냅니다. 그러면 안드로이드 framework 에서 scan 모드를 SCAN_MODE_CONNECTABLE_DISCOVERABLE 로 변경합니다.
스캔 모드가 변경된 결과를 BroadcastReceiver 로 받아서 연결 요청이 왔을 때 처리할 수 있도록 작업을 해줘야 합니다.
private val mReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val action = intent.action // When discovery finds a device if (BluetoothAdapter.ACTION_SCAN_MODE_CHANGED == action) { val scanMode = intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1) val prevMode = intent.getIntExtra(BluetoothAdapter.EXTRA_PREVIOUS_SCAN_MODE, -1) when(scanMode) { BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE -> { btn_scan.isEnabled = false btn_discover.isEnabled = false text_chat.append("\nSCAN_MODE_CONNECTABLE_DISCOVERABLE") text_chat.append("\nMake server socket") mBluetoothManager.start() } BluetoothAdapter.SCAN_MODE_CONNECTABLE -> { btn_scan.isEnabled = true btn_discover.isEnabled = true text_chat.append("\nSCAN_MODE_CONNECTABLE") } BluetoothAdapter.SCAN_MODE_NONE -> { // Bluetooth is not enabled btn_scan.isEnabled = false btn_discover.isEnabled = false text_chat.append("\nBluetooth is not enabled!!") } } } } }
Scan 모드가 SCAN_MODE_CONNECTABLE_DISCOVERABLE 로 변경되면 BluetoothManager.start() 함수를 호출해 줬습니다.
/** * Start the chat service. Specifically start AcceptThread to begin a * session in listening (server) mode. Called by the Activity onResume() */ @Synchronized fun start() { Log.d(TAG, "Starting BluetoothManager...") // Cancel any thread attempting to make a connection if (mConnectThread != null) { mConnectThread!!.cancel() mConnectThread = null } // Cancel any thread currently running a connection if (mConnectedThread != null) { mConnectedThread!!.cancel() mConnectedThread = null } // Start the thread to listen on a BluetoothServerSocket if (mAcceptThread == null) { mAcceptThread = AcceptThread() mAcceptThread?.start() } state = STATE_LISTEN mIsServiceStopped = false }
기존에 실행중인 ConnectThread, ConnectedThread 가 있으면 모두 닫고 AcceptThread 를 실행해줍니다.
/** * This thread runs while listening for incoming connections. It behaves * like a server-side client. It runs until a connection is accepted * (or until cancelled). */ private inner class AcceptThread : Thread() { // The local server socket private val mmServerSocket: BluetoothServerSocket? init { var tmp: BluetoothServerSocket? = null // Create a new listening server socket try { tmp = mAdapter.listenUsingRfcommWithServiceRecord(NAME, MY_UUID) } catch (e: IOException) { Log.e(TAG, "listen() failed" + e.toString()) } mmServerSocket = tmp } override fun run() { Log.d(TAG, "BEGIN mAcceptThread" + this) var socket: BluetoothSocket? = null // Listen to the server socket if we're not connected while (this@BluetoothManager.state != STATE_CONNECTED) { try { // This is a blocking call and will only return on a // successful connection or an exception if (mmServerSocket != null) { this@BluetoothManager.state = STATE_CONNECTING socket = mmServerSocket.accept() } } catch (e: IOException) { this@BluetoothManager.state = STATE_NONE Log.e(TAG, "accept() failed", e) break } // If a connection was accepted if (socket != null) { when (this@BluetoothManager.state) { STATE_LISTEN, STATE_CONNECTING -> // Situation normal. Start the connected thread. connected(socket, socket!!.remoteDevice) STATE_NONE, STATE_CONNECTED -> // Either not ready or already connected. Terminate new socket. try { socket!!.close() } catch (e: IOException) { Log.e(TAG, "Could not close unwanted socket", e) } else -> { } } } } Log.i(TAG, "END mAcceptThread") } fun cancel() { Log.d(TAG, "cancel " + this) try { mmServerSocket?.close() } catch (e: IOException) { Log.e(TAG, "close() of server failed" + e.toString()) } this@BluetoothManager.state = STATE_NONE } } // End of class AcceptThread
AcceptThread 의 주요 역할은 ServerSocket을 생성하고 외부 블루투스 장치의 소켓 연결 요청이 올 때까지 대기합니다. 아래 코드가 바로 이 일을 하는 코드입니다.
- socket = mmServerSocket.accept()
연결이 완료되어 socket 을 리턴받으면, 이제 이 소켓을 ConnectedThread로 넘깁니다. ConnectedThread 는 앞서서 설명했듯 해당 소켓의 input, output stream 을 열과 데이터 전송과 수신을 계속 처리해주는 thread 입니다. 연결이 종료될 때 까지 ConnectedThread 는 유지됩니다.
활용
사실 안드로이드 폰을 서로 블루투스로 연결해야 할 상황은 많지 않습니다. 두 개의 폰을 서로 연결해서 파일 전송을 할 때 정도가 유용한 시나리오 일겁니다.
이번 예제에서 중요한 점은 안드로이드에서 블루투스 프로그래밍을 어떻게 하는지 익혀두는 것입니다. 안드로이드 Master/Slave 역할로 동작하는 코드를 작성해두면 추후 모바일 폰에서 블루투스를 사용하는 어떤 상황이든 대처가 가능해지니까요!!
참고
주의!!! [사물 인터넷 네트워크와 서비스 구축 강좌] 시리즈 관련 문서들은 무단으로 내용의 일부 또는 전체를 게시하여서는 안됩니다. 계속 내용이 업데이트 되는 문서이며, 문서에 인용된 자료의 경우 원작자의 라이센스 문제가 있을 수 있습니다.
.
.
강좌 전체보기
.