【HarmonyOS Next】三天撸一个BLE调试精灵
一、功能介绍
BLE调试精灵APP属于工具类APP,在用户使用的过程中,负责调试BLE设备从机端,比如蓝牙耳机、低功耗设备、带有BLE的空调等设备,可以在页面中清晰看到设备的厂商,拥有扫描设备、连接设备、发送测试数据等主要的功能。当通过BLE调试精灵APP调试时,可以方便快捷的查看设备的属性。
本APP包含以下功能:
- 扫描BLE从机设备
- 区分从机设备的厂商
- 广播包解析展示
- 连接设备
- 展示服务和特征值
- 特征值的读、写、通知
- 根据MTU分包大数据发送
二、基本知识
在实现BLE调试APP之前,需要对BLE有基本的了解。
- BLE是低功耗蓝牙,用于可穿戴设备,IoT智能设备等众多物联网设备,功耗低、带宽也低。不同于经典蓝牙,经典蓝牙功耗高、带宽高。
- BLE分为主机和从机,主动连接其它设备的是主机,比如手机是主机,可穿戴设备等是从机
- 在有些平台下需要先扫描才能进行连接。
- 在纯血鸿蒙平台下,从机的MAC地址无法获取,而是被包装成了deviceId,类似于某水果平台。
- 广播中厂商信息、UUID有一定的规范,厂商可对应具体的厂家,由蓝牙技术联盟分配,UUID有比如获取电量等服务。
- 连接的过程中通常会自定义超时时间、重连次数。
- 下发数据时通常会根据MTU进行数据包的分割。
- 基本的字节操作。
三、技术解析
1. 侧边栏容器
SideBarContainer 组件是鸿蒙的内置组件,配合状态管理,可以很轻松的实现侧边栏展示与隐藏的效果。
用内置属性controlButton
展示不同的按钮,用@State tabShow
控制侧边栏展示与隐藏的状态,用背景颜色达到蒙版的效果。
SideBarContainer(SideBarContainerType.Overlay) {
Column() {
DrawerTab();
}
.height('100%')
Column() {
MainPage({ tabShow: this.tabShow })
}
.onClick(() => {
animateTo({
duration: 500,
curve: Curve.EaseOut,
playMode: PlayMode.Normal,
}, () => {
this.tabShow = false;
})
})
.width('100%')
.height('100%')
.backgroundColor(this.tabShow ? '#c1c2c4' : '')
}
.showSideBar(this.tabShow)
.controlButton({
left: 6,
top: 6,
height: 40,
width: 40,
icons: {
shown: $r("app.media.tab_change_back"),
hidden: $r("app.media.tab_change"),
switching: $r("app.media.tab_change")
}
})
.onChange((value: boolean) => {
this.tabShow = value;
})
2. BLE扫描
startBLEScan
方法进行扫描,使用ScanFilter
进行扫描过滤,使用ScanOptions
可传入扫描的配置,比如用最快速的响应扫描所有的设备。
BLEDeviceFind
监听该事件接收扫描的结果回调。
ble.on("BLEDeviceFind", this.onReceiveEvent);
let scanFilter: ble.ScanFilter = {
//name: scanName,
};
let scanOptions: ble.ScanOptions = {
interval: 0,
dutyMode: ble.ScanDuty.SCAN_MODE_LOW_LATENCY,
matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE
}
ble.startBLEScan([scanFilter], scanOptions);
当扫描到一定时间时,停止扫描。
// 取消上一次定时器
if (this.mScanTimerId != 0) {
clearTimeout(this.mScanTimerId)
}
this.mScanTimerId = setTimeout(() => {
BleLogger.debug(TAG, "setTimeout")
this.stopBLEScan();
if (this.mCallback) {
this.mCallback.scanFinish();
}
}, scanTime);
扫描到结果ble.ScanResult
对象,包含deviceId、广播包等数据,deviceId相当于本次扫描过程中的设备的唯一标识,可在后续的流程中用于连接;广播包一般是31个字节,在BLE5.0及以上可超出31个字节,使用拓展广播包,广播包由LTV格式构成,可以在LTV格式中解析出厂商代码,厂商代码可对应成具体厂家。
解析LTV格式:
/**
* LTV格式数据
*/
export class LtvInfo {
length: number;
tag: number;
value: Uint8Array;
constructor(length: number, tag: number, value: Uint8Array) {
this.length = length;
this.tag = tag;
this.value = value;
}
}
export class BleBeaconUtil2 {
/**
* 解析 BLE 广播数据
* @param advData 广播数据字节数组
* @returns 解析结果的 Map,键是数据类型,值是对应的数据内容
*/
public static parseData(advData: Uint8Array): Array<LtvInfo> {
let result: Array<LtvInfo> = []; // 存放解析后的结果
let index = 0; // 用于遍历数据
while (index < advData.length) {
let length = advData[index]; // 获取当前数据单元的长度
index++;
if (length === 0) {
break; // 长度为 0 时结束解析
}
const type = advData[index]; // 获取数据类型
index++;
const data = advData.slice(index, index + length - 1); // 获取实际数据
index += length - 1; // 更新索引
// 根据不同类型解析数据
let ltvInfo: LtvInfo = BleBeaconUtil2.parseDataType(length, type, data);
result.push(ltvInfo);
}
return result; // 返回解析后的数据
}
/**
* 根据广播数据的类型解析具体的数据
* @param type 数据类型
* @param data 对应类型的数据
* @param result 存放解析结果的对象
*/
private static parseDataType(length: number, type: number, data: Uint8Array): LtvInfo {
return new LtvInfo(length, type, data);
}
}
获取厂商代码,厂商代码和厂家对应信息应构成Map<number, string>
数据结构,具体厂商代码在该网址下进行获取
https://bitbucket.org/bluetooth-SIG/public/raw/HEAD/assigned_numbers/company_identifiers/company_identifiers.yaml
/**
* 获取厂商代码
* @returns
*/
public getManufacturerData(ltvArray: Array<LtvInfo>): number {
let manufacturerData = new Uint8Array(2);
if (ltvArray.length == 0) {
return 0;
}
for (const ltvInfo of ltvArray) {
if (ltvInfo.tag === 0xff) {
if (ltvInfo.value && ltvInfo.value.length > 2) {
manufacturerData[0] = ltvInfo.value[1];
manufacturerData[1] = ltvInfo.value[0];
return ByteUtils.byteToShortBig(manufacturerData);
}
}
}
return 0;
}
3. BLE连接
GattClientDevice.connect
传入deviceId
用于连接,可自定义超时逻辑。
/**
* 开始链接
* @param deviceId
*/
private connect(deviceId: string) {
this.notifyConnectStart(deviceId);
this.mDevice = ble.createGattClientDevice(deviceId);
this.mDevice.on('BLEConnectionStateChange', this.ConnectStateChanged.bind(this));
try {
this.mDevice.connect();
} catch (e) {
// todo 比如,蓝牙突然关闭时的错误
this.notifyConnectError(BleException.ERROR_CODE_CONNECT_10001, this.mDeviceId);
return;
}
this.cancelTimeoutRunnable();
this.startTimeoutRunnable(this.mConnectTimeout);
}
在核心回调中处理连接成功或失败的状态,抛给业务层。值得注意的是,并不是连接成功之后就算业务上的连接成功,还需要发现服务,如需进行数据交互,还需要启用通知->设置MTU操作,都完成之后,才是业务层面的连接成功。
4. 获取服务和特征值
BLE丛机包含多个服务,每个服务都有一个UUID,主服务下可包含多个子服务,子服务下可以包含多个特征值。
连接成功之后得到services: Array<ble.GattService>
,从中循环取出服务和特征值并展示。
List() {
ForEach(this.deviceInfo.services, (item: ble.GattService) => {
ListItem() {
/* item view */
ServiceItemView({ item: item, deviceId: this.deviceInfo.deviceId })
}
})
}
List() {
ForEach(this.item.characteristics, (itemChild: ble.BLECharacteristic) => {
ListItem() {
Column() {
Text(this.getName(itemChild.characteristicUuid))
.fontSize($r('app.float.font_normal'))
.fontWeight(FontWeight.Bold)
BlockView({ block: 2 })
Text('UUID:' + itemChild.characteristicUuid).fontSize(12)
BlockView({ block: 2 })
Text('可操作属性')
ServiceItemButtonView({ itemChild: itemChild, deviceId: this.deviceId })
// 子服务-描述符
if (itemChild.descriptors) {
ForEach(itemChild.descriptors, (itemChildDes: ble.BLEDescriptor) => {
ListItem() {
Column() {
Text('Descriptors')
.fontSize($r('app.float.font_normal'))
.fontWeight(FontWeight.Bold)
BlockView({ block: 2 })
Text('UUID:' + itemChildDes.descriptorUuid).fontSize(12)
}.alignItems(HorizontalAlign.Start)
}
})
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({
left: 30,
right: 10,
top: 10,
bottom: 10
})
}
})
}.divider({
strokeWidth: 1,
color: '#cccccc'
}).backgroundColor('#eeeeee')
5. 特征值
特征值一共有五个。
- Read:可读取数据(如设备名称、电量)
- Write:可写入数据(如配置参数)
- Notify:丛机主动通知主机(无需确认)
- Indicate:丛机通知主机(需确认,比Notify多了一个确认)
- Write Without Response:写入无需回复(低延迟,如控制指令)
用最常见的数据通信举例,主机给丛机发送一个文件数据,丛机给主机回复收到。
this.mDevice
是ble.GattClientDevice
对象,在连接时根据createGattClientDevice
获取。
首先请求设置MTU,拿到MTU之后,再对数据进行分包
private mtuChangeCallback = (mtu: number) => {
BleLogger.debug(TAG, 'set mtu change:' + mtu)
if (!this.mDeviceId) {
this.notifyError(BleException.ERROR_CODE_CONNECT_10009, this.mDeviceId);
return;
}
let bluetoothGatt = BleConnectionList.getInstance().get(this.mDeviceId);
if (!bluetoothGatt) {
this.notifyError(BleException.ERROR_CODE_CONNECT_10009, this.mDeviceId);
return;
}
bluetoothGatt.off('BLEMtuChange', this.mtuChangeCallback);
BLESdkConfig.getInstance().setBleMaxMtu(mtu);
this.notifySuccess(mtu, this.mDeviceId);
}
将分隔的组成一个队列,队列中每个数据的长度是MTU
let queuePacket: Queue<Uint8Array> =
BleDataUtils.splitByte(requestPacket, BLESdkConfig.getInstance().getBleMaxMtu());
将数据依次取出,然后发送数据
let data: Uint8Array = this.mDataQueue.pop();
let bluetoothGatt = BleConnectionList.getInstance().get(this.mDeviceId!);
// 携带数据
this.mBleCharacteristic.characteristicValue = this.typedArrayToBuffer(data);
bluetoothGatt.writeCharacteristicValue(this.mBleCharacteristic, this.mWriteType,
this.writeCharacteristicValueCallBack.bind(this))
至此功能讲解完毕,有问题欢迎沟通。