同专项组同事协作的 Side Project,一个蓝牙自动浇花系统

    概述

    一个简单的 Android-Ardunio 蓝牙通信 Demo,代码位于 Github Repo.

    需求分析

    • 建立蓝牙连接,根据已定义的协议、同 Arduino 通信
    • 解析应答数据,显示湿度或任何通信过程中异常信息
    • 提供 UI 来呈现指令按钮、配对设备列表、通信日志等

    通信协议

    指令 请求 应答
    获取湿度/湿度阈值 0x57 0x55 0x01 0xFF 0x53 0x53 0x01 <当前值> <高> <低> 0xFF
    设定阈值 0x57 0x55 0x02 <高> <低> 0xFF 0x53 0x53 0x02 0xFF
    启动浇花 0x57 0x55 0x03 0xFF 0x53 0x53 0x03 0xFF
    停止浇花 0x57 0x55 0x04 0xFF 0x53 0x53 0x04 0xFF

    湿度值两位数据表示,校验位暂定0xFF

    程序实现

    主界面 BTConnActivity 处理用户交互,BTService 负责建立蓝牙 Socket、并在单独线程管理蓝牙连接。

    由于 Demo 实现得简单,因此仅将 Android 蓝牙开发的部分内容整理归档下。


    Android 蓝牙应用开发

    1. 权限声明

    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    

    2. 蓝牙开发常用的类

    2.1 BluetoothAdapter

    代表本地的蓝牙适配器设备,BluetoothAdapter 类能让用户执行基本的蓝牙任务。例如:初始化设备的搜索、查询可匹配的设备集、使用一个已知的MAC地址来初始化一个 BluetoothDevice 类,创建一个 BluetoothServerSocket 类以监听其他设备对本机的连接请求等。

    2.2 BluetoothDevice

    代表远端蓝牙设备,可通过 BluetoothAdapter.getRemoteDevice(String) 创建一个表示已知MAC地址的设备(通过 BluetoothAdapter 来完成对设备的查找),或通过 Bluetooth.getBondedDevices() 返回的已匹配的设备集中得到设备对象。

    2.3 BluetoothServerSocket

    蓝牙端口监听接口和TCP端口类似:Socket 和 ServerSocket 类。在服务器端,使用 BluetoothServerSocket 类来创建一个监听服务端口。当一个连接被 BluetoothServerSocket 所接受,它会返回一个新的 BluetoothSocket 来管理该连接。在客户端,使用一个单独的 BluetoothSocket 类去初始化一个外接连接和管理该连接。

    最常使用的蓝牙端口是 RFCOMM,它是被 Android API 支持的类型。RFCOMM 是一个面向连接,通过蓝牙模块进行的数据流传输方式,它也被称为串行端口规范(Serial Port Profile, SPP)

    一些基础概念:

    蓝牙 ( Bluetooth ) 是一种无线技术,用于建立带宽为2.4GHz,波长为10m的私有网络。

    通道 ( Channel ) 是位于基带连接之上的逻辑连接,每个通道以多对一的方式绑定一个单一协议。多个通道可以绑定同一个协议,但是一个通道不可以绑定多个协议。

    RFCOMM 协议 是为了兼容传统的串口应用,同时取代有线的通信方式,蓝牙协议需要提供与有线串口一致的通信接口而开发出的协议。

    设备配对 ( Pairing of Device ) 蓝牙设备可以选择通过验证以提供某种特殊服务,蓝牙验证一般使用 PIN 码 (最长为16个字符的 ASCII 字符串),用户需要在两个设备中输入相同的PIN码。用户输入 PIN 码后,两个设备会生成一个连接密匙 ( link key ),接着连接密钥可以存储在设备或存储器中。连接时两个设备会使用该连接密钥,该过程称为结对 ( pairing )。如果任一方丢失了连接密钥,必须重新进行结对。

    2.4 BluetoothSocket

    代表一个蓝牙套接字的接口,是应用程序通过输入输出流与其他蓝牙设备通信的连接点。

    3. 蓝牙应用开发示例

    3.1 获取 BluetoothAdapter 对象

    BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
    if (mBluetoothAdapter == null) {
        // Device does not support Bluetooth
    }
    

    3.2 启用蓝牙

    if (!mBluetoothAdapter.isEnabled()) {
        Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
        startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT);
    }
    

    系统会弹窗提升用户是否要允许开启蓝牙。如果开启成功,onActivityResult() 回调中收到 RESULT_OK

    3.3 查询已配对设备集

    已绑定/匹配的蓝牙设备,不同于已连接状态(设备间存在一个 RFCOMM 通道并能够互相传递数据)。建立连接首先要求建立配对,如果是第一次连接的话,配对请求会显示给用户;配对成功后会保存设备名称及 MAC 地址。

    Set<BluetoothDevice> pairedDevices = mBluetoothAdapter.getBondedDevices();
    if (pairedDevices.size() > 0 ) {
        // Loop through paired devices
        for (BluetoothDevice device : pairedDevices) {
            // Add the name and address to an array adapter to show in a ListView
            mArrayAdapter.add(device.getName() + "\n" + device.getAddress());
        }
    }
    

    3.4 发现设备

    调用 startDiscovery() 方法,注意这是一个异步方法,整个扫描过程大概12s,应用程序需要注册一个 BroadcastReceiver 来接收扫描到的信息,对于每一个设备,系统都会广播 ACTION_FOUND 动作。

    // Create a BroadcastReceiver for ACTION_FOUND
    private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            // When discovery finds a device
            if (BluetoothDevice.ACTION_FOUND.equals(action)) {
                // Get the BluetoothDevice object from the Intent
                BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
                // Add the name and address to an array adapter to show in a ListView
                mArrayAdapter.add(device.getName() + "\n" + device.getAddress());
            }
        }
    }
    
    // Register the BroadcastReceiver
    IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND);
    registerReceiver(mReceiver, filter);    // Don't forget to unregister during onDestory
    

    注意,扫描是一个很耗费资源的过程,一旦找到需要的设备后,在发起请求之前,确保你的程序调用 cancelDiscovery() 停止扫描。

    3.5 开启可见

    ACTION_REQUEST_DISCOVERABLE 动作封装在 Intent 中并调用 startActivityForResult(Intent, int) 方法就可以了,可以通过 EXTRA_DISCOVERABLE_DURATION 字段改变可见时长(缺省120s)

    Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
    discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300);
    startActivity(discoverableIntent);
    

    3.6 作为服务端连接

    持有一个打开的 BluetoothServerSocket 以监听外来连接请求,当监听到以后提供一个连接上的 BluetoothSocket 给客户端、并可以销毁 BluetoothServerSocket (除非还想监听更多的连接请求,一般都是单点传输)

    详细步骤:

    • 通过 listenUsingRfcommWithServiceRecord(String, UUID) 方法来获取 BluetoothServerSocket 对象,系统会在设备上建立 SDP 数据库;UUID 必须双方匹配才能建立连接
    • 调用 accept() 方法来监听可能到来的连接请求,当监听到以后、返回一个连接上的 BluetoothSocket
    • 在监听到一个连接后,调用 close() 方法关闭监听程序。(因accept是一种阻塞调用,单独开线程管理)

    蓝牙服务发现协议(Service Discovery Protocol, SDP):让客户端应用发现存在的服务端应用所提供的服务、以及这些服务的属性。SDP 只提供侦测 service 的机制,不提供使用服务的方法。每个蓝牙设备都需要一个 SDP Service,只做客户端的蓝牙设备除外。

    SDP Server 维护的服务条目包含在 Service Record中,由一个32位数唯一区分。

    from Android SDK Docs:

    If you are connecting to a Bluetooth serial board then try using the well-known SPP UUID 00001101-0000-1000-8000-00805F9B34FB. However if you are connecting to an Android peer then please generate your own unique UUID.

    private class AcceptThread extends Thread {
        private final BluetoothServerSocket mmServerSocket;
    
        public AcceptThread() {
            // Use a temporary object that is later assigned to mmServerSocket,
            // because mmServerSocket is final
            BluetoothServerSocket tmp = null;
            try {
                // MY_UUID is the app's UUID string, also used by the client code
                tmp = mBluetoothAdapter.listenUsingRfcommWithServiceRecord(NAME, MY_UUID);
            } catch (IOException e) {
                // IGNORE
            }
            mmServerSocket = tmp;
        }
    
        public void run() {
            BluetoothSocket socket = null;
            // Keep listening until exception occurs or a socket is returned
            while (true) {
                try {
                    socket = mmServerSocket.accept();
                } catch (IOException e) {
                    break;
                }
    
                // If a connection was accepted
                if (socket != null) {
                    // Do work to manage the connection (in a separate thread)
                    manageConnectedSocket(socket);
                    mmServerSocket.close();
                    break;
                }
            }
        }
    
        /** Will cancel the listening socket, and cause the thread to finish */
        public void cancel () {
            try {
                mmServerSocket.close();
            } catch (IOException e) {
                // IGNORE
            }
        }
    }
    

    3.7 作为客户端连接

    为了初始化一个与远端设备的连接,需要先获取代表该设备的一个 BluetoothDevice 对象,再通过 createRfcommSocketToServiceRecord(UUID) 获取 BluetoothSocket,并调用 connect() 方法初始化连接。如果成功建立连接,将在通信过程中共享 RFCOMM 信道,且 connect() 方法返回。

    private class ConnectThread extends Thread {
        private final BluetoothSocket mmSocket;
        private final BluetoothDevice mmDevice;
    
        public ConnectThread(BluetoothDevice device) {
            BluetoothSockcet tmp = null;
            mmDevice = device;
    
            // Get a BluetoothSocket to connect with the given BluetoothDevice
            try {
                tmp = device.createRfcommSocketToServiceRecord(MY_UUID);
            } catch (IOException e) {
                // IGNORE
            }
            mmSocket = tmp;
        }
    
        public void run() {
            // Cancel discovery because it will slow down the connection
            mBluetoothAdapter.cancelDiscovery();
    
            try {
                // Connect the device through the socket. This will block
                // until it succeeds or throws an exception
                mmSocket.Connect();
            } catch (IOException connectException) {
                // Unable to connect; close the socket and get out
                try {
                    mmSocket.close();
                } catch (IOException closeException) {
                    // IGNORE
                }
                return;
            }
    
            // Do work to manage the connection (in a separate thread)
            manageConnectedSocket(mmSocket);
        }
    
        /** Will cancel an in-progress connection, and close the socket */
        public void cancel() {
            try {
                mmSocket.close();
            } catch (IOException e) {
                // IGNORE
            }
        }
    }
    

    3.8 管理连接(主要涉及数据的传输)

    当成功建立连接后,两端都持有BluetoothSocket,以流的方式通信。由于读写操作都是阻塞调用,需要开一个线程来管理。

    private class ConnectedThread extends Thread {
        private final BluetoothSocket mmSocket;
        private final InputStream mmInStream;
        private final OutputStream mmOutStream;
    
        public ConnectedThread(BluetoothSocket socket) {
            mmSocket = socket;
            InputStream tmpIn = null;
            OutputStream tmpOut = null;
    
            // Get the input and output streams, using temp objects because
            // member streams are final
            try {
                tmpIn = socket.getInputStream();
                tmpOut = socket.getOutputStream();
            } catch (IOException e) {
                // IGNORE
            }
    
            mmInStream = tmpIn;
            mmOutStream = tmpOut;
        }
    
        public void run() {
            byte[] buffer = new byte[1024]; // buffer store for the stream
            int bytes; // bytes returned from read()
    
            // Keep listening to the InputStream until an exception occurs
            while (true) {
                try {
                    // Read from the InputStream
                    bytes = mmInStream.read(buffer);
                    // Send  the obtained bytes to the UI activity
                    mHandler.obtainMessage(MESSAGE_READ, bytes, -1, buffer).sendToTarget();
                } catch (IOException e) {
                    // IGNORE
                    break;
                }
            }
        }
    
        /** Call this from the main activity to send data to the remote device */
        public void write(byte[] bytes) {
            try {
                mmOutStream.write(bytes);
            } catch (IOException e) {
                // IGNORE
            }
        }
    
        /** Call this from the main activity to shutdown the connection */
        public void cancel() {
            try {
                mmSocket.close();
            } catch (IOException e) {
                // IGNORE
            }
        }
    }