본문 바로가기
Mobile Programming/Android

[Kotlin] BLE 기기 연결하기

by 푸고배 2021. 1. 6.

BLE 기기 스캔을 마쳤으면, 스캔한 Devoce 중 원하는 기기와 연결을 할 수 있다.

성공적으로 연결을 마치면 Toast 메세지를 통해 'Connected [Device_name]'를 보여준다.

 

 

스마트폰 블루투스 기능 On/Off, BLE 기기를 Scan하는 코드는 이전 게시물을 참고한다.

 

[Kotlin] BLE 기기 검색하기

블루투스 설정 및 On/Off 제어는 이전 글 참고하기 [Kotlin] 블루투스 On/Off 제어하기 Toggle Button을 통해서 블루투스 기능을 On/Off 해보자. 1. Bluetooth 권한 등록 어플리케이션에서 블루투스 기능을 사용

doqtqu.tistory.com

recyclerview_item.xml 수정

RecyclerView의 Cell에 해당하는 recyclerview_item.xml은 좀 더 가시성 있게 아래와 같이 코드를 변경하였다.

(TextView Margin 조정 밑, 클릭 시 Select Animation 추가)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    tools:context=".MainActivity"
    android:background="?attr/selectableItemBackground">
    <!-- android:background="?attr/selectableItemBackground" : select animation 추가 -->

    <TextView
        android:id="@+id/item_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="20dp"
        android:layout_marginBottom="20dp"
        android:text="item_name"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
        android:textColor="#000000"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/item_address"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="20dp"
        android:layout_marginBottom="20dp"
        android:text="item_address"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
        android:textColor="#000000"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</LinearLayout>

 

MainAcitivity에 OnClickListener를 이용한 DeviceControl Class 호출

itemClickListerRecyclerViewAdapter의 내부에 아래 코드를 추가한 후,

var mListener : OnItemClickListener? = null
interface OnItemClickListener{
	fun onClick(view: View, position: Int)
}

 

onBindViewHolder에 onclickListener를 등록한다.

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    val itemName:TextView = holder.linearView.findViewById(R.id.item_name)
    val itemAddress:TextView = holder.linearView.findViewById(R.id.item_address)
    itemName.text = myDataset[position].name
    itemAddress.text = myDataset[position].address
    // 아래부터 추가코드
    if(mListener!=null){
        holder?.itemView?.setOnClickListener{v->
            mListener?.onClick(v, position)
        }
    }
}

 

MainActivity.kt 내부에 bleGatt(BluetoothGatt 변수), mContext(Context 변수)를 추가하고,

onCreate 내부에 mContextmListener를 정의해준다.

- mContext : Toast 알림을 위한 Context 전달

- mListener : 위에서 추상화한 setOnclickListener에 대한 내용을 구현. scan을 중지하고, Device 정보를 DeviceControlActivity.connectGatt로 넘긴다.

// BLE Gatt 추가하기 
private var bleGatt: BluetoothGatt? = null
private var mContext:Context? = null

override fun onCreate(savedInstanceState: Bundle?) {
	// ...
    recyclerViewAdapter =  RecyclerViewAdapter(devicesArr)
	// 여기부터 추가
    mContext = this
    recyclerViewAdapter.mListener = object : RecyclerViewAdapter.OnItemClickListener{
        override fun onClick(view: View, position: Int) {
        scanDevice(false) // scan 중지
        val device = devicesArr.get(position)
        bleGatt =  DeviceControlActivity(mContext, bleGatt).connectGatt(device)
        }
    }
}

 

DeviceControlActivity로 BLE Deivce 연결

DeviceControlActivity는 생성자의 파라메타로 context와 BluetoothGatt를 전달 받는다.

 

onConnectionsStateChange()의 경우 BLE Connection 상태가 변경될 때 호출되는 함수로,

newSateBluetoothProfile.STATE_CONNECTED일 경우  bluetoothGatt?.discoverServices()를 호출해주고,

BluetoothProfile.STATE_DISCONNECTED인 경우 disconnectGattServer()함수를 이용해 bluetoothGatt 연결을 해제하고 초기화한다.

하지만, onConnectionStateChange의 state log를 찍어보니, "133"이 찍혔다.

BluetoothGatt의 소스코드를 확인해봐도 133 State에 대한 정보는 얻을 수 없어서 구글링 중에 잘 정리된 게시물을 발견했다.

 

[TIP] Android 코드에서 블루투스 연결이 잘 안될때 - 모바일앱 - AiRPAGE

GATT 신호를 잘 감지하고 블루투스 디바이스도 잘 찾아내었지만, 디바이스에 연결하고자 했더니 곧바로 연결이 되지 않는 현상이 생기더군요. (이때의 디바이스는 라즈베리파이 입니다) 당시 코

airpage.org

여기서 제안하는 해결방법은 아래와 같다.

 

1. Scanning 종료 후 100ms 이후에 연결을 시도하라.

2. connectGatt 함수에 BluetoothDevice.TRANSPORT_LE 인자를 추가하라.(SDK 23 이상 지원)

 

나는 1번 방법으로 해결되지 않아, 아래와 같이 소스코드를 변경하였다.

테스트로 이용하는 공기계의 SDK가 23보다 높아 23아래에서는 어떻게 동작하는지는 확인하지 못했다.

 

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
	device?.connectGatt(view.context, false, gattClienCallback,BluetoothDevice.TRANSPORT_LE)
} else {
	device?.connectGatt(view.context, false, gattClienCallback)
}

 

onServicesDiscovered()는 원격 장치에 대한 원격 서비스, 특성 및 설명자 목록이 업데이트되었을 때 호출되는 콜백이다.

 

broadcastUpdate()는 Toast로 보여줄 메세지를 파라메타로 받아, Handler의 handleMessage로 나타낸다.

Toast도 하나의 UI 작업이기 때문에 Thread 안에서 그냥 호출해주면 에러가 발생한다.

 

connectGatt()는 MainActivity에서 호출할 메서드로 위에서 설명한 내용과 같이 SDK의 버전에 따라 두 가지로 나누어 처리해준다. BluetoothDevice의 connectGatt를 호출하면, 기기 연결을 수행 콜백을 호출한다.

 

전체 소스코드는 아래와 같다.

 

DeviceControlActivity.kt

package com.example.bluetoothapp

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothProfile
import android.content.*
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.util.Log
import android.widget.Toast


private val TAG = "gattClienCallback"

class DeviceControlActivity(private val context: Context?, private var bluetoothGatt: BluetoothGatt?) {
    private var device : BluetoothDevice? = null
    private val gattCallback : BluetoothGattCallback = object : BluetoothGattCallback(){
        override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
            super.onConnectionStateChange(gatt, status, newState)
            when (newState) {
                BluetoothProfile.STATE_CONNECTED -> {
                    Log.i(TAG, "Connected to GATT server.")
                    Log.i(TAG, "Attempting to start service discovery: " +
                            bluetoothGatt?.discoverServices())
                }
                BluetoothProfile.STATE_DISCONNECTED -> {
                    Log.i(TAG, "Disconnected from GATT server.")
                    disconnectGattServer()
                }
            }

        }
        override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
            super.onServicesDiscovered(gatt, status)
            when (status) {
                BluetoothGatt.GATT_SUCCESS -> {
                    Log.i(TAG, "Connected to GATT_SUCCESS.")
                    broadcastUpdate("Connected "+ device?.name)
                }
                else -> {
                    Log.w(TAG, "Device service discovery failed, status: $status")
                    broadcastUpdate("Fail Connect "+device?.name)
                }
            }
        }
        private fun broadcastUpdate(str: String) {
            val mHandler : Handler = object : Handler(Looper.getMainLooper()){
                override fun handleMessage(msg: Message) {
                    super.handleMessage(msg)
                    Toast.makeText(context,str,Toast.LENGTH_SHORT).show()
                }
            }
            mHandler.obtainMessage().sendToTarget()
        }
        private fun disconnectGattServer() {
            Log.d(TAG, "Closing Gatt connection")
            // disconnect and close the gatt
            if (bluetoothGatt != null) {
                bluetoothGatt?.disconnect()
                bluetoothGatt?.close()
                bluetoothGatt = null
            }
        }
    }

    fun connectGatt(device:BluetoothDevice):BluetoothGatt?{
        this.device = device

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            bluetoothGatt = device.connectGatt(context, false, gattCallback,
                BluetoothDevice.TRANSPORT_LE)
        }
        else {
            bluetoothGatt = device.connectGatt(context, false, gattCallback)
        }
        return bluetoothGatt
    }
}

 

MainActivity.kt

package com.example.bluetoothapp

import android.Manifest
import android.annotation.TargetApi
import android.bluetooth.*
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Handler
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class MainActivity : AppCompatActivity() {
    private val REQUEST_ENABLE_BT=1
    private val REQUEST_ALL_PERMISSION= 2
    private val PERMISSIONS = arrayOf(
        Manifest.permission.ACCESS_FINE_LOCATION
    )
    private var bluetoothAdapter: BluetoothAdapter? = null
    private var scanning: Boolean = false
    private var devicesArr = ArrayList<BluetoothDevice>()
    private val SCAN_PERIOD = 1000
    private val handler = Handler()
    private lateinit var viewManager: RecyclerView.LayoutManager
    private lateinit var recyclerViewAdapter : RecyclerViewAdapter

    // BLE Gatt
    private var bleGatt: BluetoothGatt? = null
    private var mContext:Context? = null

    private val mLeScanCallback = @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    object : ScanCallback() {
        override fun onScanFailed(errorCode: Int) {
            super.onScanFailed(errorCode)
            Log.d("scanCallback", "BLE Scan Failed : " + errorCode)
        }

        override fun onBatchScanResults(results: MutableList<ScanResult>?) {
            super.onBatchScanResults(results)
            results?.let{
                // results is not null
                for (result in it){
                    if (!devicesArr.contains(result.device) && result.device.name!=null) devicesArr.add(result.device)
                }

            }
        }

        override fun onScanResult(callbackType: Int, result: ScanResult?) {
            super.onScanResult(callbackType, result)
            result?.let {
                // result is not null
                if (!devicesArr.contains(it.device) && it.device.name!=null) devicesArr.add(it.device)
                recyclerViewAdapter.notifyDataSetChanged()
            }
        }

    }
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    private fun scanDevice(state:Boolean) = if(state){
        handler.postDelayed({
            scanning = false
            bluetoothAdapter?.bluetoothLeScanner?.stopScan(mLeScanCallback)
        }, SCAN_PERIOD)
        scanning = true
        devicesArr.clear()
        bluetoothAdapter?.bluetoothLeScanner?.startScan(mLeScanCallback)
    }else{
        scanning = false
        bluetoothAdapter?.bluetoothLeScanner?.stopScan(mLeScanCallback)
    }

    private fun hasPermissions(context: Context?, permissions: Array<String>): Boolean {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context != null && permissions != null) {
            for (permission in permissions) {
                if (ActivityCompat.checkSelfPermission(context, permission)
                    != PackageManager.PERMISSION_GRANTED) {
                    return false
                }
            }
        }
        return true
    }
    // Permission check
    @RequiresApi(Build.VERSION_CODES.M)
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String?>,
        grantResults: IntArray
    ) {
        when (requestCode) {
            REQUEST_ALL_PERMISSION -> {
                // If request is cancelled, the result arrays are empty.
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    Toast.makeText(this, "Permissions granted!", Toast.LENGTH_SHORT).show()
                } else {
                    requestPermissions(permissions, REQUEST_ALL_PERMISSION)
                    Toast.makeText(this, "Permissions must be granted", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    @TargetApi(Build.VERSION_CODES.M)
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        mContext = this
        val bleOnOffBtn:ToggleButton = findViewById(R.id.ble_on_off_btn)
        val scanBtn: Button = findViewById(R.id.scanBtn)
        bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
        viewManager = LinearLayoutManager(this)

        recyclerViewAdapter =  RecyclerViewAdapter(devicesArr)
        recyclerViewAdapter.mListener = object : RecyclerViewAdapter.OnItemClickListener{
            override fun onClick(view: View, position: Int) {
                scanDevice(false) // scan 중지
                val device = devicesArr.get(position)
                bleGatt =  DeviceControlActivity(mContext, bleGatt).connectGatt(device)
            }
        }
        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView).apply {
            layoutManager = viewManager
            adapter = recyclerViewAdapter
        }

        if(bluetoothAdapter!=null){
            if(bluetoothAdapter?.isEnabled==false){
                bleOnOffBtn.isChecked = true
                scanBtn.isVisible = false
            } else{
                bleOnOffBtn.isChecked = false
                scanBtn.isVisible = true
            }
        }

        bleOnOffBtn.setOnCheckedChangeListener { _, isChecked ->
            bluetoothOnOff()
            scanBtn.visibility = if (scanBtn.visibility == View.VISIBLE){ View.INVISIBLE } else{ View.VISIBLE }
            if (scanBtn.visibility == View.INVISIBLE){
                scanDevice(false)
                devicesArr.clear()
                recyclerViewAdapter.notifyDataSetChanged()
            }
        }

        scanBtn.setOnClickListener { v:View? -> // Scan Button Onclick
            if (!hasPermissions(this, PERMISSIONS)) {
                requestPermissions(PERMISSIONS, REQUEST_ALL_PERMISSION)
            }
            scanDevice(true)
        }

    }
    fun bluetoothOnOff(){
        if (bluetoothAdapter == null) {
            // Device doesn't support Bluetooth
            Log.d("bluetoothAdapter","Device doesn't support Bluetooth")
        }else{
            if (bluetoothAdapter?.isEnabled == false) { // 블루투스 꺼져 있으면 블루투스 활성화
                val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
                startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
            } else{ // 블루투스 켜져있으면 블루투스 비활성화
                bluetoothAdapter?.disable()
            }
        }
    }

    class RecyclerViewAdapter(private val myDataset: ArrayList<BluetoothDevice>) :
        RecyclerView.Adapter<RecyclerViewAdapter.MyViewHolder>() {

        var mListener : OnItemClickListener? = null

        interface OnItemClickListener{
            fun onClick(view: View, position: Int)
        }


        class MyViewHolder(val linearView: LinearLayout) : RecyclerView.ViewHolder(linearView)

        override fun onCreateViewHolder(parent: ViewGroup,
                                        viewType: Int): RecyclerViewAdapter.MyViewHolder {
            // create a new view
            val linearView = LayoutInflater.from(parent.context)
                .inflate(R.layout.recyclerview_item, parent, false) as LinearLayout

            return MyViewHolder(linearView)
        }

        // Replace the contents of a view (invoked by the layout manager)
        override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
            val itemName:TextView = holder.linearView.findViewById(R.id.item_name)
            val itemAddress:TextView = holder.linearView.findViewById(R.id.item_address)
            itemName.text = myDataset[position].name
            itemAddress.text = myDataset[position].address
            if(mListener!=null){
                holder?.itemView?.setOnClickListener{v->
                    mListener?.onClick(v, position)
                }
            }
        }

        override fun getItemCount() = myDataset.size
    }
}

private fun Handler.postDelayed(function: () -> Unit?, scanPeriod: Int) {

}
반응형

댓글