iOS端智能硬件BLE通信技术实现
[toc]
当前开发的智能硬件项目中涉及蓝牙通信的目前有三处:
- 配网时手机端向硬件端请求获取wifi列表
- 配网时手机将ssid、pwd、userid的信息告知硬件,同时硬件端告知配网结果
- 特定模式下硬件端向手机端请求信息(涉及项目隐私隐去)
本项目中BLE通信分三层设计:蓝牙层、传输层、应用层。
- 蓝牙层:主要封装蓝牙基本的通信方式,包括蓝牙开启/关闭的通知、扫描、读数据等;
- 传输层:按照智能硬件的BLE通信协议规范实现数据发送时的拆包和接收时的组包,借助蓝牙层实现基本的收发;
- 应用层:对数据通信结果做封装,暴露给上层用户调用。
通信过程中的基本数据结构有两类,Packet和Slice,其中Slice组成Packet,下文会做详细描述。
蓝牙层
BLE基础
BLE是Bluetooth Low Energy——蓝牙低功耗技术的简称,基于蓝牙4.0规范实现。值得一提的是,基于4.0之前规范实现的蓝牙技术称为传统蓝牙。在BLE开发中,有两种角色:中央设备(Central, 如手机)和外围设备(Peripheral,如智能硬件)。
BLE技术是基于GATT(Generic Attribute Profile,一种属性传输协议)进行通信的:
- 每个GATT由完成不同功能的服务(Service)组成;
- 每个Service由不同的特征(Characteristic)组成;
- 每个Characteristic由一个value和一个或多个描述(Descriptor)组成。
项目中智能硬件就是一个外围设备,它包括三个Service,其中UUID为0xFFF0的Service是我们要扫描的目标,该Service中包括了写、读和控制三个Characteristic。
typedef NS_ENUM(NSInteger, VBUUIDType)
{
VBUUIDNone = 0,
VBUUIDService = 0xFFF0,
VBUUIDTxCharacteristic = 0xFFF1, // 手机向硬件发送BLE数据的链路
VBUUIDRxCharacteristic = 0xFFF2, // 手机从硬件接收BLE数据的链路
VBUUIDCtsCharacteristic = 0xFFF3, // 标识手机是否可以继续向硬件发送数据的链路,
};
BLE写操作
BLE写操作是指手机端向硬件端发送数据,写操作可分为有响应和无响应两种,后者写数据较快。项目中手机给硬件发送数据是通过tx Characteristic链路来写的,并且是无响应式, 根据《PV1低功耗蓝牙通信架构模块设计文档》,写数据前cts和rx链路都必须确认处于开启状态,才能保证写数据后快速收到硬件的数据:
- (BOOL)canSendDataForPeripheral:(CBPeripheral *)peripheral {
BOOL isRxCharacterNotify = NO;
BOOL isCtsCharacterNotify = NO;
for (CBCharacteristic *character in [peripheral.services.firstObject characteristics]) {
VBUUIDType uuidType = [VBUUIDUtil typeForUUID:character.UUID];
switch (uuidType) {
case VBUUIDRxCharacteristic:
isRxCharacterNotify = character.isNotifying;
break;
case VBUUIDCtsCharacteristic:
isCtsCharacterNotify = character.isNotifying;
default:
break;
}
}
// 只有在cts和rx都开启的情况下,才能发送数据
BOOL canSendData = isRxCharacterNotify && isCtsCharacterNotify;
return canSendData;
}
// Method from VBDataSender class
/// 发送当前数据
- (void)sendCurrentPacket
{
if (_curPacketIndex >= _packetsToSend.count)
{
return;
}
NELogVerbose(@"发送第%ld个包", (_curPacketIndex+1));
VBPacket *curPacket = _packetsToSend[_curPacketIndex];
_state = VBSenderStateWritePackets;
NSArray<NSData *> *slices = [curPacket splitIntoSlices];
for (NSData *slice in slices)
{
[_peripheral writeValue:slice forCharacteristic:_writeCharacteristic type:CBCharacteristicWriteWithoutResponse];
}
_state = VBSenderStateWaitPacketAck;
[self startTimer];
}
BLE读操作
BLE读操作是指手机读取硬件指定Characteristic的值, 项目中是通过实现CBPeripheralDelegate
的方法来读取值的,代码如下:
// Method from VBBluetoothManager class
// 3. 读取特征的值
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error
{
[[NSNotificationCenter defaultCenter] postNotificationName:VBBluetoothBLEDidReceivePeripheralResponse object:characteristic userInfo:nil];
if (!characteristic.value)
{
return;
}
NELogVerbose(@"%s %@", __func__, characteristic.value);
VBDataBridge *bridge = [self findDataBridgeByPeripheral:peripheral];
if (bridge) {
[bridge handlePeripheralResponse:characteristic.value];
} else {
NELogError(@"data bridge为空了....");
}
}
BLE通知
BLE通知是指硬件主动给手机发送数据,手机接收硬件rx链路的数据通过setNotify来实现,参考代码如下:
// Method from VBBluetoothManager class
- (void)constructDataBridges:(CBPeripheral *)peripheral {
CBService *primaryService = peripheral.services.firstObject;
NSArray<CBCharacteristic *> *characteristics = primaryService.characteristics;
if (!characteristics)
{
return;
}
CBCharacteristic *txWriteCharacter;
CBCharacteristic *rxReceiveCharacter;
for (CBCharacteristic *character in characteristics)
{
VBUUIDType uuidType = [VBUUIDUtil typeForUUID:character.UUID];
if (uuidType == VBUUIDNone) {
break;
} else if (uuidType == VBUUIDTxCharacteristic) {
txWriteCharacter = character;
} else {
if (uuidType == VBUUIDRxCharacteristic) {
rxReceiveCharacter = character;
}
if (!character.isNotifying) {
[peripheral setNotifyValue:YES forCharacteristic:character];
}
}
}
// 省略
...
}
其他注意事项
手机的读和写操作均通过调用Core Bluetooth
框架中的API完成,项目中写数据分成两个步骤:
- 手机发送数据给硬件
- 硬件回复手机数据接收成功
只有第2步硬件返回表示写数据成功的Ack才表示数据写成功。
传输层
传输层的主要作⽤用是接收来⾃自应⽤层的数据,经过拆包后,发送给智能硬件; 同
时接收智能硬件回复的结果信息,组包后,结果回调给应⽤用层。
传输层可以分为发送端和接收端两个部分,发送端负责发送相关命令数据给⾳箱,接收端负责接收硬件回复的结果信息,并通过接⼝回调给发送端。
基本数据结构
手机和硬件通信过程中有两个基本的数据结构:数据包(Packet)和切⽚ (Slice)。Packet规定了一个标准的数据结构,⽆论发送还是接收,均需要按照这个结构发送数据,Slice则是将Packet按照20字节等⻓长切分后的结果,设成20字节主要原因Android端同学说BLE写限制一次最多只能写20字节,但是经过我查找相关资料验证,iOS的限制受诸多因素影响,并且可以超过20字节,甚至达到100多字节,具体可参考这篇文章。
Packet
一个Packet的结构包含包头Header和包体Payload两部分,示意图如下:
8949C43A-FE55-419A-866D-7B6964E643CA.png
本项目中和嵌入式协定Packet有如下特点:
- 每个Packet的长度限制为3000字节,头部长度固定占用12字节,故包体Payload部分最多可有2988字节;
- 每接收到一个Packet均需校验,同时回复该Packet(即Packet Ack),Packet Ack不超过20字节,因此发送Packet Ack时无需拆包;
包头
传输层报文的包头格式定义:
typedef struct pkgTransHeader{
Uint8 magic;
Uint8 ver;
Uint8 rev;
Uint8 seq;
Uint16 cmdid;
Uint16 checksum;
Uint32 length;
} pkgTransHeaderSdef;
56FEE129-44CE-49BF-A2A8-B9C2E6D3F185.png
对应的OC实现类是VBTransportHeader
:
#import "VBTransportHeader.h"
#import "VBPacketUtil.h"
#import <NESafeKit/NSData+NESafeKit.h>
UInt32 const VBTransportHeaderLength = 12;
UInt8 const VBTransportHeaderOkMagic = 0xEF;
UInt8 const VBTransportHeaderOkVer = 0x01;
UInt8 const VBTransportHeaderOkRev = 0x00;
static const UInt8 VBTransportHeaderDefaultSeq = 0x01;
@interface VBTransportHeader ()
{
// 前导标识
UInt8 _magic; // 前导标识
UInt8 _ver; // 版本号
UInt8 _rev; // 保留
UInt8 _seq; // 序列号
VBCmdId _cmdid; // 命令号
UInt32 _length; // 包头+包体的长度
UInt16 _checksum; // 包体校验和
NSData *_data; // 包头数据
}
@end
@implementation VBTransportHeader
- (instancetype)init
{
self = [super init];
if (self)
{
_magic = VBTransportHeaderOkMagic;
_ver = VBTransportHeaderOkVer;
_rev = VBTransportHeaderOkRev;
_seq = VBTransportHeaderDefaultSeq;
}
return self;
}
@end
其中通信的命令枚举定义如下:
/**
app和硬件通信的各个指令
- VBCmdCentralToPeripheral: 中央设备(手机)向硬件发送数据
- VBCmdPeripheralToCentral: 硬件向中央设备发送数据
- VBCmdWifiConfigNetRequest: 手机向硬件发送配网请求
- VBCmdWifiConfigNetResponse: 硬件回复配网结果响应
- VBCmdNearbyWifiListRequest: 手机向硬件请求附近的wifi列表
- VBCmdNearbyWifiListResponse: 硬件向手机回复获取的wifi列表结果
- VBCmdLoopDataRequest: 透传,设备会将手机发送的包原封不动传回来
- VBCmdLoopDataResponse: 透传的响应
*/
typedef NS_ENUM(UInt16, VBCmdId)
{
VBCmdCentralToPeripheral = 0xE001,
VBCmdPeripheralToCentral = 0xE002,
VBCmdWifiConfigNetRequest = 0x1001,
VBCmdWifiConfigNetResponse = 0x1002,
VBCmdNearbyWifiListRequest = 0x1003,
VBCmdNearbyWifiListResponse = 0x1004,
VBCmdLoopDataRequest = 0x0100,
VBCmdLoopDataResponse = 0x0101
};
包体
传输层报文中根据传输的包体内容可以将报文划分为两类:
- 链路控制报文,包体内容为链路控制响应码
- 数据传输报文,包体内容为应用层协议包或者其分包
链路控制报文与数据传输报文使用cmdid来区分:
类型 | cmdid |
---|---|
链路控制报文 | 0xE001 手机向设备, 0xE002设备向手机 |
数据报文 | 其他 |
其中链路控制报文的ack/nack响应码定义如下:
typedef struct ackbody
{
Uint8 ackCode;
}ackBodySdef;
AF939E68-427E-46B9-9999-B29D68E105DD.png
对应的OC枚举是VBTransportAck
:
typedef NS_ENUM(UInt8, VBTransportAck)
{
VBTransportAckOk = 0x00,
VBTransportAckOtherError = 0x01,
VBTransportAckReservedUsage = 0x02,
VBTransportAckChecksumError = 0x03,
VBTransportAckHeaderIncorrect = 0x04,
VBTransportAckOutOfMemory = 0x05,
VBTransportAckTimeoutReassemble = 0x06,
VBTransportAckSequenceIncorrect = 0x07,
VBTransportAckCmdidInconsistent = 0x08,
VBTransportAckTimeoutReceive = 0x09,
};
Packet校验
接收到一个Packet时,需要进行如下流程的校验:
- 是否是ack包
- 是ack包的情况下,依次检查头部的合法性,校验和的正确性
- 当条件1、2都满足时,还需校验ack是否是表示传输层正确接收的结果
对应实现代码如下:
/// 是否是ack回复
///
/// - Parameter data: 待检验的数据
/// - Returns: true是ack,false不是
+ (BOOL)isAck:(NSData *)data
{
if (data.length != [VBPacket packetAckSize])
{
return NO;
}
// 获取cmd, 第4和5字节是cmd
VBCmdId cmd = 0;
[data ne_getBytes:&cmd range:NSMakeRange(4, 2)];
// 获取长度
UInt32 length = 0;
[data ne_getBytes:&length range:NSMakeRange(8, 4)];
NSData *header = [data ne_subdataWithRange:NSMakeRange(0, VBTransportHeaderLength)];
BOOL isValidHeader = [VBPacketUtil isValidHeader:header] && cmd == VBCmdPeripheralToCentral && length == [VBPacket packetAckSize];
return isValidHeader;
}
/// 校验Packet是否有效
///
/// - Parameter data: 外围设备返回的数据
/// - Returns: true有效, false无效
+ (VBPacketCheckResult *)isValidPacket:(NSData *)data
{
VBPacketCheckResult *result = [VBPacketCheckResult new];
if (!data || data.length < VBTransportHeaderLength)
{
return result;
}
// 检查头部是否合法
NSData *headerData = [data ne_subdataWithRange:NSMakeRange(0, VBTransportHeaderLength)];
if (![self isValidHeader:headerData])
{
return result;
}
// 检查长度是否合法
VBTransportHeader *transportHeader = [[VBTransportHeader alloc] initWithHeaderData:headerData];
if (!transportHeader || transportHeader.length != data.length)
{
return result;
}
// 检查校验和
NSUInteger payloadLen = data.length - VBTransportHeaderLength;
NSData *payload = [data ne_subdataWithRange:NSMakeRange(VBTransportHeaderLength, payloadLen)];
result.valid = transportHeader.checksum == [self generateChecksumWithHeaderData:[transportHeader dataWithZeroChecksum] payload:payload];
result.cmd = transportHeader.cmdid;
result.code = payload;
return result;
}
- (BOOL)isAckOk:(NSData *)data
{
if (![VBPacketUtil isAck:data])
{
return NO;
}
VBPacketCheckResult *packet = [VBPacketUtil isValidPacket:data];
if (!packet.valid)
{
return NO;
}
VBTransportAck ack = VBTransportAckOk;
[packet.code ne_getBytes:&ack length:sizeof(ack)];
NSError *error = [NSError vb_errorWithBLEError:ack];
NELogVerbose(@"%@",error.localizedDescription);
return ack == VBTransportAckOk;
}
Slice
项目中,手机与硬件的所有读写操作,我们都认为是Slice传输,Slice特点如下:
- Slice组成Packet(有些Packet比较短,可能小于20字节,所以有时候一个Slice就是一个Packet)
- Slice没有头部,在写数据时最长20字节,读数据时取决于硬件一次发的数据量
传输层
传输层的设计核心类图如下:
VBDataBridge.png
应用层需要发送数据,后者接收到的数据需要处理时,实际是通过VBDataBridge
桥接类去进行相应的分发处理:
- 发送数据时,桥接类调用自己持有的
sender
类去发送数据 - 接收数据时,桥接类调用自己持有的
receiver
类处理数据,receiver
处理完,将对应的结果回调给桥接类,桥接类再回调给上层应用层。
发送类VBDataSender
VBDataSender
类的主要作用是接收通过桥接类转发的应用层字节流数据,然后切成Packet发送(即拆包);在收到硬件返回的结果后,结束整个发送过程,流程图如下:
每步操作说明如下:
-
sender
初始时处于idle状态,此时处于空闲状态,没有数据发送; -
sender
接收桥接类转发的字节流,进入split packet状态,切成N个符合规范的Packet,准备依次发送; -
sender
发送Packet时,进入write packet状态,开始发送第i个Packet; - 发送第i个Packet后,进入wait packet ack状态,等待该Packet的ack(该Packet的ack,实际是
receiver
在接收到Packet的ack后,通过桥接类接口告诉sender发送下一个包的过程); - 发送第i个Packet成功后,重新进入write packet状态,发送第i+1个Packet;
-
sender
收到的ACK显示硬件收到的该Packet有误,视为第i个Packet发送失败,这时sender
重新进⼊write packet状态,重新发送第i个Packet; 如果重试2次后都失败,则判定为整个发送过程失败,错误信息回调应⽤用层; -
sender
在N个Packet都发送成功后,进⼊wait response状态等待硬件返回相应命令的结果; -
sender
在收到硬件反馈的结果后回到idle状态,整个发送过程结束,桥接类将结果回调给应⽤层。
需注意细节如下:
-
sender
在等待每一个Packet的ack时,均需要设计超时时间,默认是3s。如果发送了了一个Packet后,等待了了3s后没有收到ACK,则判定为发送失败,尝试重新发送; -
sender
在等待硬件返回结果时,也需要设计超时时间,默认30s,如果等待30s后,没有收到响应结果,判定为整个发送过程失败,错误信息通过桥接类回调给应用层。
拆包过程
@implementation NSData (VBPacket)
- (NSArray<VBPacket *> *)splitIntoPacketsWithCmdId:(VBCmdId)cmdId
{
NSMutableArray<VBPacket *> *packets = [NSMutableArray array];
NSUInteger maxPayloadSize = VBPacketMaxPacketSize - VBTransportHeaderLength;
NSUInteger packetNum = self.length == 0 ? 1 : (self.length / maxPayloadSize + (self.length % maxPayloadSize == 0 ? 0 : 1));
for (int i = 0; i < packetNum; ++i)
{
NSUInteger length = 0;
UInt8 seq = i + 1;
if (i == packetNum - 1)
{
length = self.length - i * maxPayloadSize;
seq = VBPacketLastSeq;
}
else
{
length = maxPayloadSize;
}
// 即使payload为空也可以发送数据,因为包头的data一定不为空
NSData *payload = [self ne_subdataWithRange:NSMakeRange(i * maxPayloadSize, length)];
VBPacket *packet = [[VBPacket alloc] initWithSeq:seq cmdId:cmdId payload:payload];
[packets addObject:packet];
}
return [packets copy];
}
@end
切片过程
// Method from VBPacket class
- (NSArray<NSData *> *)splitIntoSlices
{
NSMutableArray<NSData *> *slices = [NSMutableArray array];
NSData *data = [self data];
NSUInteger sliceNum = data.length / VBPackeMaxSliceSize + (data.length % VBPackeMaxSliceSize == 0 ? 0 : 1);
for (int i = 0; i < sliceNum; ++i)
{
NSUInteger length = 0;
if (i == sliceNum - 1)
{
length = data.length - i * VBPackeMaxSliceSize;
}
else
{
length = VBPackeMaxSliceSize;
}
NSData *sliceData = [data ne_subdataWithRange:NSMakeRange(i * VBPackeMaxSliceSize, length)];
if (sliceData)
{
[slices addObject:sliceData];
}
}
return [slices copy];
}
接收类VBDataReceiver
VBDataReceiver
类的主要作用是处理接收到的硬件数据,包括硬件回复的Packet ack和相应命令对应的响应结果,并把结果通过桥接类回调给上层应用层,接收流程图如下:
每步操作说明如下:
- 数据发送完毕等待接收数据的状态有两种:wait packet ack和wait packet response;
- 当前状态是wait packet ack时,
receiver
判断是不是表示成功接收的ack,是则通知发送端发送下一个数据包,否则返回错误回调给上层; - 当前状态是wait packet response时,
receiver
判断当前待接收的Packet序号是否满足条件,满足才接收; - 接收Packet的过程是组包的过程,首先会判断当前接收的数据是否含有Packet Header以及头部检查是否已经check过,未check过则从头部中获取要接收的目标数据的长度;
- 已经check过则判断当前接收数据长度是否小于目标数据长度,进而决定是继续接收数据还是组装数据回调给上层。
需注意以下细节:
- 手机端在接收到⾳箱返回的Packet ack后⽆无需再做回复;
- 无论是⼿手机端还是硬件端,对于Packet的回复都在20字节之内,所以可以省去复杂的组包和拆包操作,发送⼀次即可;
- 手机在接收硬件发送的一个Packet的过程中,会出现下述情况:假如头部指明了了待接收的Packet是150字节长,已经接收了7个Slice,共7 * 20 = 140字节⻓,最后一个Slice硬件发送过来的长度依旧是20字节长的情况,这个时候需要对最后一个Slice切割,前10个字节和前⾯面的140字节长组成一个完整的字节,后10个字节存储起来等待和后面的数据拼接。
组包过程
// Method from VBDataReceiver class
- (void)processResponseData:(NSData *)data
{
NSMutableData *curSlice = [NSMutableData data];
if (_lastSliceTailData.length > 0)
{
[curSlice appendData:_lastSliceTailData];
// remove all data
[_lastSliceTailData setData:[NSData new]];
}
[curSlice appendData:data];
// 当前已接收数据和待接收数据总长度
NSUInteger curLength = _curPacketLen + curSlice.length;
VBHeaderCheckResult *checkResult = [VBPacketUtil containRespHeader:curSlice];
if (!_hasCheckedHeader && checkResult.hasHeader)
{
_hasCheckedHeader = YES;
_targetPacketLen = checkResult.length;
}
// 已接收和待接收的数据总长度小于目标接收长度
if (curLength < _targetPacketLen)
{
[_receivedSlices appendData:curSlice];
_curPacketLen = curLength;
}
else
{
NSUInteger wantingLen = _targetPacketLen - _curPacketLen;
NSData *frontData = [data ne_subdataWithRange:NSMakeRange(0, wantingLen)];
if (frontData.length > 0)
{
[_receivedSlices appendData:frontData];
[self collectPacket];
}
NSUInteger tailLen = data.length - wantingLen;
NSData *tailData = [data ne_subdataWithRange:NSMakeRange(wantingLen, tailLen)];
if (tailData.length > 0)
{
[_lastSliceTailData appendData:tailData];
}
}
}
应用层
应用层对数据收发的桥接类做进一步封装,类图设计如下:
VBBluetoothManager.png以上就是智能硬件BLE通信技术的设计架构和大概实现,所有以和嵌入式端定义的协议文档为实现依据。