同专项组同事协作的 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_FOUNDprivate 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 BroadcastReceiverIntentFilter 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 } }}