Flutter App开发蓝牙协议
Summary
BLE低功耗蓝牙,是我们常说的蓝牙4.0, 该技术有极低的运行待机功耗,本文记录使用Flutter开发安卓App的过程,使用蓝牙模块的配置和一些细节。
Background
公司需求一款用于欧洲的海外版离线交流充电桩APP,通过蓝牙方式与交流桩连接。主要控制充电桩的启停,及查看当前充电记录等参数。 高级功能如充电桩设置界面,远程固件升级、网络连接、高级配置等。
Goals
Linkcharging App
- 安卓版 : 用于操作充电桩,查看充电桩参数信息的移动端软件。可以在主流厂家优化后的
android 平台使用,需要上架于 Google Play。 - iOS版: 用于操作充电桩,查看充电桩参数信息的移动端软件。在 IOS 平台使用,需要上架于
APP Store
Non-Goals
To narrow the scope of what we’re working on, outline what this proposal will not accomplish.
Proposed Solution
Describe the solution to the problems outlined above. Include enough detail to allow for productive discussion and comments from readers.
流程
一、声明蓝牙权限和定位权限
<!--蓝牙权限-->
<uses-permission android:name="android.permission.BLUETOOTH"/><!--让应用启动设备发现或操纵蓝牙设置-->
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/><!-- LE Beacons位置相关权限-->
<!-- 如果设配Android9及更低版本,可以申请 ACCESS_COARSE_LOCATION -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><!--ble模块 设置为true表示只有支持ble的手机才能安装-->
<uses-feature
android:name="android.hardware.bluetooth_le"android:required="true" />
由于蓝牙扫描需要用到模糊定位权限( `Android10` 后需要精准定位权限 ),所以`android6.0`之后,除了在 `AndroidManifest.xml`中 申明权限之外,还需要动态申请定位权限,才可进行蓝牙扫描,否则不会扫描到任何Ble设备。
二、中心设备与外围设备
Ble
开发中,存在着两个角色:中心设备角色和外围设备角色。粗略了解下:
- 外围设备:一般指非常小或者低功耗设备,更强大的中心设备可以连接外围设备为中心设备提供数据。外设会不停的向外广播,让中心设备知道它的存在。 例如小米手环。
- 中心设备:可以扫描并连接多个外围设备,从外设中获取信息。
外围设备会设定一个广播间隔,每个广播间隔中,都会发送自己的广播数据。广播间隔越长,越省电。**一个没有被连接的`Ble`外设会不断发送广播数据**,这时可以被多个中心设备发现。**一旦外设被连接,则会马上停止广播。**
`android 4.3` 时引入的`Ble`核心`Api`只支持android手机作为**中心设备角色**,当`android 5.0` 更新`Api`后,android手机支持充当作为**外设角色和中心角色**。即 `android 5.0` 引入了外设角色的`Api`,同时也更新了部分中心角色的`Api`。比如:中心角色中,更新了蓝牙扫描的`Api`。
三、打开蓝牙
安装flutter_blue 0.8.0,需要将github下载的实例中的android\app\src\main\java\com\pauldemarco\flutter_blue_example里的java文件拷过来同样位置
再配置AndroidManifest.xml, 否则开启蓝牙关闭蓝牙的状态切换App无法检测到,不能够呈现出来预期的结果。
<activity
android:name=".EmbeddingV1Activity"
android:theme="@android:style/Theme.Black.NoTitleBar"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
</activity>
Android {
defaultConfig {
minSdkVersion: 19
四、扫描
1.扫描及状态获取
FlutterBlue.instance.startScan(timeout: Duration(seconds: 4));
使用该插件, 可以直接StreamBuilder来分发数据,插件中的StreamBuilder部分代码已经实现了,可以用它直接在Widget上面使用。
body: StreamBuilder<BluetoothState>(
stream: FlutterBlue.instance.state,
initialData: BluetoothState.unknown,
builder: (c, snapshot) {
final state = snapshot.data;
if (state == BluetoothState.on) {
return Index();
} else {
return BluetoothOffscreen(state: state); // 蓝牙未打开的呈现
}
}),
//Application.router.navigateTo(context, '/login', transition: TransitionType.fadeIn);
);
此处可以呈现出关闭|打开状态下两种不同的widget。扫描状态获取,并呈现不同的widget
floatingActionButton: StreamBuilder<bool>(
stream: FlutterBlue.instance.isScanning,
initialData: false,
builder: (c, snapshot) {
if (snapshot.data!) {
return FloatingActionButton(
child: Icon(Icons.stop),
onPressed: () => FlutterBlue.instance.stopScan(),
backgroundColor: Colors.red,
);
} else {
return FloatingActionButton(
child: Icon(Icons.search),
onPressed: () => FlutterBlue.instance.startScan(timeout: Duration(seconds: 4)),
);
}
},
),
2.扫描结果呈现
写一个弹窗,里面是列表的形式呈现扫描的结果,这里有个注意点,Column中嵌套ListView,需要给listView嵌套一个container,然后设置一个高度。否则滚动会有问题。
class DeviceListDialog {
static Widget contentWidget(
BuildContext context, {
double maxWidth = double.infinity,
double maxHeight = double.infinity,
}) {
return Container(
constraints: BoxConstraints(maxHeight: maxHeight, maxWidth: maxWidth),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 20, spreadRadius: 10)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 10),
child: Text(
S.of(context).devicelist,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
),
Container(
height: maxHeight - 66,
child: ListView(
children: [
StreamBuilder<List<ScanResult>>(
stream: FlutterBlue.instance.scanResults,
initialData: [],
builder: (c, snapshot) => Column(
children: snapshot.data!
.map(
(r) => ScanResultTile(
result: r,
onTap: () => r.device.connect(),
),
)
.toList(),
),
)
],
),
),
],
),
);
}
}
五、扫描回调
通信
一、蓝牙基础协议
GAP(Generic Access Profile) 和 GATT(Generic Attribute Profile)。 GPA主要控制蓝牙连接和广播。GAP使蓝牙设备对外界可见,并决定设备是否可以或者怎样与其他设备进行交互。
GAP广播数据
GAP 中外围设备通过两种方式向外广播数据:广播数据 和 扫描回复( 每种数据最长可以包含 31 byte。)。
广播数据是必需的,因为外设必需不停的向外广播,让中心设备知道它的存在。而扫描回复是可选的,中心设备可以向外设请求扫描回复,这里包含一些设备额外的信息。
GATT(Generic Attribute Profile)
GATT配置文件是一个通用规范,用于在BLE链路上发送和接收被称为“属性”的数据块。目前所有的BLE应用都基于GATT。
BLE设备通过叫做 Service 和 Characteristic 的东西进行通信
GATT使用了 ATT(Attribute Protocol)协议,ATT 协议把 Service, Characteristic
对应的数据保存在一个查询表中,次查找表使用 16 bit ID 作为每一项的索引。
GATT 连接是独占的。也就是一个 BLE 外设同时只能被一个中心设备连接。一旦外设被连接,它就会马上停止广播,这样它就对其他设备不可见了。当外设与中心设备断开,外设又开始广播,让其他中心设备感知该外设的存在。而中心设备可同时与多个外设进行连接。
二、Services and Characteristics
BLE GATT通信是基于嵌套的Profiles, Services andCharacteristics结构之上的,下图是其框架:
如上图所示:蓝牙设备可以包括多个Profile,一个Profile中有多个Service,一个Service中有多个Characteristic,一个Characteristic中包括一个value和多个Descriptor。
- Profiles
Profile并不是真实存在的一种结构,而是多个完成某一特定功能services的集合,或者说是对这个特定结合的功能的描述,或者名称。为了更容易的保持Bluetooth设备之间的兼容,Bluetooth规范中定义了Profile; **Profile定义了设备如何实现一种连接或者应用,你可以把Profile理解为连接层或者应用层协议。**以Heart Rate Profile为例,它包括Heart Rate Service 和 Device Information Service,他们都是为了完成测量心率这个功能而存在的service。
更详细的可以查看Profiles Overview 以及 蓝牙Profile的概念和常见种类
- 服务(Services)
每个service拥有一个唯一标识UUID,可以是官方认证的16bit的id,也可以是128位的自定义id。service是GATT数据的逻辑分类,它包含一个或者多个characteristic。
官方通过了一些标准 Service,完整列表在这里。以 Heart Rate Service为例,可以看到它的官方通过 16 bit UUID 是 0x180D,包含 3 个 Characteristic: Heart Rate Measurement, Body Sensor Location 和 Heart Rate Control Point,实现该service第一个Heart Rate Measurement是必选的,其他两个是可选的。
- 特征(Characteristics)
Characteristic也拥有一个16-bit 或者128-bit的UUID,它是GATT通信中的最小的逻辑数据单元,它封装了一个单一的数据点,当然这个数据点可能包含一组相关的数据,比如加速度传感器的x/y/z三个坐标轴的数据。
实际上,和 BLE 外设打交道,主要是通过 Characteristic。可以从 Characteristic 读取数据,也可以往 Characteristic 写数据。这样就实现了双向的通信。你可以使用Characteristic实现一个类似串口(UART)的 Sevice,这个 Service 中包含两个 Characteristic,一个被配置只读的通道(RX),另一个配置为只写的通道(TX)。
- 描述符(descriptor)
—descriptor是被定义的attributes,用来描述一个characteristic的值。例如,一个descriptor可以指定一个人类可读的描述中,在可接受的范围里characteristic值,或者是测量单位,用来明确characteristic的值。
- UUID
UUID,统一识别码,我们刚才提到的service和characteristic,都需要一个唯一的uuid来标识
三、 蓝牙4.0和4.1
**蓝牙4.0实际是个三位一体的蓝牙技术,它将传统蓝牙、低功耗蓝牙和高速蓝牙技术融合在一起,这三个规格可以组合或者单独使用。**也就是说 BLE是蓝牙4.0增加的,之前没有?(TBD)
蓝牙4.0专门面向对成本和功耗都有较高要求的无线方案,其主打特性就是省电、省电、省电。极低的运行和待机功耗使得一粒纽扣电池甚至可连续工作一年之久。它有低功耗、经典、高速三种协议模式。其中:高速蓝牙主攻数据交换与传输;经典蓝牙则以信息沟通、设备连接为重点;低功耗蓝牙以不需占用太多带宽的设备连接为主。这三种协议规范能够互相组合搭配,从而适应更广泛的应用模式。正因为有了三种可以互相组合搭配的协议,蓝牙4.0因此成为唯一一个综合协议规范。它有着极低的运行和待机功耗。此外,低成本和跨厂商互操作性,3毫秒低延迟、AES-128加密等诸多特色,可以用于计步器、心律监视器、智能仪表、传感器物联网等众多领域,大大扩展蓝牙技术的应用范围。
蓝牙4.1主打IOT(Internet Of Things全联网),最新的蓝牙4.1标准是个很有前途的技术,其智能、低功耗、高传输速度、连接简单的特性将适合用在许多新兴设备上。
蓝牙4.1设备可以同时作为发射方和接受方,并且可以连接到多个设备上。举个例子,智能手表可以作为发射方向手机发射身体健康指数,同时作为接受方连接到蓝牙耳机、手环或其他设备上。蓝牙4.1使得批量数据可以以更高的速率传输,当然这并不意味着可以用蓝牙高速传输流媒体视频,这一改进的主要针对的还是刚刚兴起的可穿戴设备。例如已经比较常见的健康手环,其发送出的数据流并不大,通过蓝牙4.1能够更快速地将跑步、游泳、骑车过程中收集到。因为新标准加入了对IPv6专用通道联机的支持,通过IPv6连接到网络,实现与Wi-Fi相同的功能,解决可穿戴设备上网不易的问题**。**
四、蓝牙4.0通信实现
每个从机都会有一个叫做profile的东西存在,不管是上面的自定义的simpleprofile,还是标准的防丢器profile,他们都是由一些列service组成,然后每个service又包含了多个characteristic,主机和从机之间的通信,均是通过characteristic来实现。
实际产品中,每个蓝牙4.0的设备都是通过服务和特征来展示自己的,服务和特征都是用UUID来唯一标识的。一个设备必然包含一个或多个服务,每个服务下面又包含若干个特征。特征是与外界交互的最小单位。**蓝牙设备硬件厂商通常都会提供他们的设备里面各个服务(service)和特征(characteristics)的功能,比如哪些是用来交互(读写),哪些可获取模块信息(只读)等。**比如说,一台蓝牙4.0设备,用特征A来描述自己的出厂信息,用特征B来与收发数据等。
风险和里程碑
Risks
需要解决的几个问题
- 蓝牙协议走通
- 国际化的处理,根据手机系统语言适配
- flutter的状态管理
- 3个月左右,需要测试版本
Milestones
Break down the solution into key tasks and their estimated deadlines.
- flutter的状态管理
- 蓝牙协议交互
Open Questions
Ask any unresolved questions about the proposed solution here.
Follow-up Tasks
What needs to be done next for this proposal?
硬件开发包里的wear\uart\UARTProfile.java 包括UUID和CHARACTERISTIC,部分代码,我们需要的是flutter实现这个App功能
public class UARTProfile extends BleProfile {
/** Broadcast sent when a UART message is received. */
public static final String BROADCAST_DATA_RECEIVED = "no.nordicsemi.android.nrftoolbox.uart.BROADCAST_DATA_RECEIVED";
/** The message. */
public static final String EXTRA_DATA = "no.nordicsemi.android.nrftoolbox.EXTRA_DATA";
/** Nordic UART Service UUID */
private static final UUID UART_SERVICE_UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
/** RX characteristic UUID */
private static final UUID UART_RX_CHARACTERISTIC_UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E");
/** TX characteristic UUID */
private static final UUID UART_TX_CHARACTERISTIC_UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E");
/** The maximum packet size is 20 bytes. */
private static final int MAX_PACKET_SIZE = 20;
/**
* This method should return true if the profile matches the given device. That means if the device has the required services.
* @param gatt the GATT device
* @return true if the device is supported by that profile, false otherwise.
*/
public static boolean matchDevice(final BluetoothGatt gatt) {
final BluetoothGattService service = gatt.getService(UART_SERVICE_UUID);
return service != null && service.getCharacteristic(UART_TX_CHARACTERISTIC_UUID) != null && service.getCharacteristic(UART_RX_CHARACTERISTIC_UUID) != null;
}
private BluetoothGattCharacteristic mTXCharacteristic;
private BluetoothGattCharacteristic mRXCharacteristic;
private byte[] mOutgoingBuffer;
private int mBufferOffset;
@Override
protected Deque<BleManager.Request> initGatt(final BluetoothGatt gatt) {
final BluetoothGattService service = gatt.getService(UART_SERVICE_UUID);
mTXCharacteristic = service.getCharacteristic(UART_TX_CHARACTERISTIC_UUID);
mRXCharacteristic = service.getCharacteristic(UART_RX_CHARACTERISTIC_UUID);
final int rxProperties = mRXCharacteristic.getProperties();
boolean writeRequest = (rxProperties & BluetoothGattCharacteristic.PROPERTY_WRITE) > 0;
// Set the WRITE REQUEST type when the characteristic supports it. This will allow to send long write (also if the characteristic support it).
// In case there is no WRITE REQUEST property, this manager will divide texts longer then 20 bytes into up to 20 bytes chunks.
if (writeRequest)
mRXCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
// We don't want to enable notifications on TX characteristic as we are not showing them here. A watch may be just used to send data. At least now.
// final LinkedList<BleProfileApi.Request> requests = new LinkedList<>();
// requests.add(BleProfileApi.Request.newEnableNotificationsRequest(mTXCharacteristic));
// return requests;
return null;
}
@Override
protected void release() {
mTXCharacteristic = null;
mRXCharacteristic = null;
}
@Override
protected void onCharacteristicNotified(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
// This method will not be called as notifications were not enabled in initGatt(..).
// final Intent intent = new Intent(BROADCAST_DATA_RECEIVED);
// intent.putExtra(EXTRA_DATA, characteristic.getStringValue(0));
// LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
}
@Override
protected void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
// When the whole buffer has been sent
final byte[] buffer = mOutgoingBuffer;
if (mBufferOffset == buffer.length) {
mOutgoingBuffer = null;
} else { // Otherwise...
final int length = Math.min(buffer.length - mBufferOffset, MAX_PACKET_SIZE);
getApi().enqueue(BleProfileApi.Request.newWriteRequest(mRXCharacteristic, buffer, mBufferOffset, length));
mBufferOffset += length;
}
}
/**
* Sends the given text to RX characteristic.
* @param text the text to be sent
*/
public void send(final String text) {
// Are we connected?
if (mRXCharacteristic == null)
return;
// An outgoing buffer may not be null if there is already another packet being sent. We do nothing in this case.
if (!TextUtils.isEmpty(text) && mOutgoingBuffer == null) {
final byte[] buffer = mOutgoingBuffer = text.getBytes();
mBufferOffset = 0;
// Depending on whether the characteristic has the WRITE REQUEST property or not, we will either send it as it is (hoping the long write is implemented),
// or divide it into up to 20 bytes chunks and send them one by one.
final boolean writeRequest = (mRXCharacteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE) > 0;
if (!writeRequest) { // no WRITE REQUEST property
final int length = Math.min(buffer.length, MAX_PACKET_SIZE);
mBufferOffset += length;
getApi().enqueue(BleProfileApi.Request.newWriteRequest(mRXCharacteristic, buffer, 0, length));
} else { // there is WRITE REQUEST property, let's try Long Write
mBufferOffset = buffer.length;
getApi().enqueue(BleProfileApi.Request.newWriteRequest(mRXCharacteristic, buffer, 0, buffer.length));
}
}
}