Core Bluetooth框架之一:Central与Peripheral

iOS和Mac应用使用Core Bluetooth framework来与BLE(低功耗蓝牙)设备通信。我们的程序可以发现、搜索并与低功耗外围(Peripheral)蓝牙设备通信,如心跳监听器、数字温控器、甚至是其它iOS设备。这个框架抽象了支持蓝牙4.0标准低功耗设备的基本操作,隐藏了4.0标准的底层实现细节,让我们可以方便的使用BLE设备。

蓝牙通信中的角色

在BLE通信中,主要有两个角色:Central和Peripheral。类似于传统的客户端-服务端架构,一个Peripheral端是提供数据的一方(相当于服务端);而Central是使用Peripheral端提供的数据完成特定任务的一方(相当于客户端)。下图以心跳监听器为例展示了这样一个架构:

image

Peripheral端以广告包的形式来广播一些数据。一个广告包(advertising packet)是一小束相关数据,可能包含Peripheral提供的有用的信息,如Peripheral名或主要功能。在BLE下,广告是Peripheral设备表现的主要形式。

Central端可以扫描并监听其感兴趣的任何广播信息的Peripheral设备。

数据的广播及接收需要以一定的数据结构来表示。而服务就是这样一种数据结构。Peripheral端可能包含一个或多个服务或提供关于连接信号强度的有用信息。一个服务是一个设备的数据的集合及数据相关的操作。

而服务本身又是由特性或所包含的服务组成的。一个特性提供了关于服务的更详细的信息。下图展示了心率监听器中的各种数据结构

image

在一个Central端与Peripheral端成功建立连接后,Central可以发现Peripheral端提供的完整的服务及特性的集合。一个Central也可以读写Peripheral端的服务特性的值。我们将会在下面详细介绍。

Central、Peripherals及Peripheral数据的表示

当我们使用本地Central与Peripheral端交互时,我们会在BLE通信的Central端执行操作。除非我们设置了一个本地Peripheral设备,否则大部分蓝牙交互都是在Central端进行的。(下文也会讲Peripheral端的基本操作)

在Central端,本地Central设备由CBCentralManager对象表示。这个对象用于管理发现与连接Peripheral设备(CBPeripheral对象)的操作,包括扫描、查找和连接。下图本地Central端与peripheral对象

image

当与peripheral设备交互时,我们主要是在处理它的服务及特性。在Core Bluetooth框架中,服务是一个CBService对象,特性是一个CBCharacteristic对象,下图演示了Central端的服务与特性的基本结构:

image

苹果在OS X 10.9和iOS 6版本后,提供了BLE外设(Peripheral)功能,可以将设备作为Peripheral来处理。在Peripheral端,本地Peripheral设备表示为一个CBPeripheralManager对象。这些对象用于管理将服务及特性发布到本地Peripheral设备数据库,并广告这些服务给Central设备。Peripheral管理器也用于响应来自Central端的读写请求。如下图展示了一个Peripheral端角色:

image

当在本地Peripheral设备上设置数据时,我们实际上处理的是服务与特性的可变版本。在Core Bluetooth框架中,本地Peripheral服务由CBMutableService对象表示,而特性由CBMutableCharacteristic对象表示,下图展示了本地Peripheral端服务与特性的基本结构:

image

Peripheral(Server)端操作

一个Peripheral端操作主要有以下步骤:

  1. 启动一个Peripheral管理对象
  2. 在本地Peripheral中设置服务及特性
  3. 将服务及特性发布给设备的本地数据库
  4. 广告我们的服务
  5. 针对连接的Central端的读写请求作出响应
  6. 发送更新的特性值到订阅Central端

我们将在下面结合代码对每一步分别进行讲解

启动一个Peripheral管理器

要在本地设备上实现一个Peripheral端,我们需要分配并初始化一个Peripheral管理器实例,如下代码所示

1
2
3
4
// 创建一个Peripheral管理器
// 我们将当前类作为peripheralManager,因此必须实现CBPeripheralManagerDelegate
// 第二个参数如果指定为nil,则默认使用主队列
peripheralManager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil];

创建Peripheral管理器后,Peripheral管理器会调用代理对象的peripheralManagerDidUpdateState:方法。我们需要实现这个方法来确保本地设备支持BLE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)peripheral
{
NSLog(@"Peripheral Manager Did Update State");
switch (peripheral.state) {
case CBPeripheralManagerStatePoweredOn:
NSLog(@"CBPeripheralManagerStatePoweredOn");
break;
case CBPeripheralManagerStatePoweredOff:
NSLog(@"CBPeripheralManagerStatePoweredOff");
break;
case CBPeripheralManagerStateUnsupported:
NSLog(@"CBPeripheralManagerStateUnsupported");
break;
default:
break;
}
}

设置服务及特性

一个本地Peripheral数据库以类似树的结构来组织服务及特性。所以,在设置服务及特性时,我们将其组织成树结构。

一个Peripheral的服务和特性通过128位的蓝牙指定的UUID来标识,该标识是一个CBUUID对象。虽然SIG组织没的预先定义所有的服务与特性的UUID,但是SIG已经定义并发布了一些通过的UUID,这些UUID被简化成16位以方便使用。例如,SIG定义了一个16位的UUID作为心跳服务的标识(180D)。

CBUUID类提供了方法,以从字符串中生成一个CBUUID对象。当字条串使用的是预定义的16位UUID时,Core Bluetooth使用它时会预先自动补全成128位的标识。

1
CBUUID *heartRateServiceUUID = [CBUUID UUIDWithString:@"180D"];

当然我们也可以自己生成一个128位的UUID来标识我们的服务与特性。在命令行中使用uuidgen命令会生成一个128位的UUID字符串,然后我们可以使用它来生成一个CBUUID对象。

生成UUID对象后,我们就可以用这个对象来创建我们的服务及特性,然后再将它们组织成树状结构。

创建特性的代码如下所示

1
2
3
4
5
6
CBUUID *characteristicUUID1 = [CBUUID UUIDWithString:@"C22D1ECA-0F78-463B-8C21-688A517D7D2B"];
CBUUID *characteristicUUID2 = [CBUUID UUIDWithString:@"632FB3C9-2078-419B-83AA-DBC64B5B685A"];
CBMutableCharacteristic *character1 = [[CBMutableCharacteristic alloc] initWithType:characteristicUUID1 properties:CBCharacteristicPropertyRead value:nil permissions:CBAttributePermissionsReadable];
CBMutableCharacteristic *character2 = [[CBMutableCharacteristic alloc] initWithType:characteristicUUID2 properties:CBCharacteristicPropertyNotify value:nil permissions:CBAttributePermissionsWriteable];

我们需要设置特性的属性、值及权限。属性及权限值确定了属性值是可读的还是可写的,及连接的Central端是否可以订阅特性的值。另外,如果我们指定了特性的值,则这个值会被缓存且其属性及权限被设置成可读的。如果我们要让特性的值是可写的,或者期望属性所属的服务的生命周期里这个值可以被修改,则必须指定值为nil。

创建的特性之后,我们便可以创建一个与特性相关的服务,然后将特性关联到服务上,如下代码所示:

1
2
3
CBUUID *serviceUUID = [CBUUID UUIDWithString:@"3655296F-96CE-44D4-912D-CD83F06E7E7E"];
CBMutableService *service = [[CBMutableService alloc] initWithType:serviceUUID primary:YES];
service.characteristics = @[character1, character2]; // 组织成树状结构

上例中primary参数传递的是YES,表示这是一个主服务,即描述了一个设备的主要功能且能被其它服务引用。与之相对的是次要服务(secondary service),其只在引用它的另一个服务的上下文中描述一个服务。

发布服务及特性

创建服务及特性后交将其组织成树状结构后,我们需要将这些服务发布到设备的本地数据库上。我们可以使用CBPeripheralManager的addService:方法来完成此工作。如下代码所示:

1
[peripheralManager addService:service];

在调用些方法发布服务时,CBPeripheralManager对象会调用它的代理的peripheralManager:didAddService:error:方法。如果发布过程中出现错误导致无法以布,则可以实现该代理方法来处理错误,如下代码所示:

1
2
3
4
5
6
7
8
9
- (void)peripheralManager:(CBPeripheralManager *)peripheral didAddService:(CBService *)service error:(NSError *)error
{
NSLog(@"Add Service");
if (error)
{
NSLog(@"Error publishing service: %@", [error localizedDescription]);
}
}

在将服务与特性发布到设备数据库后,服务将会被缓存,且我们不能再修改这个服务。

广告服务

处理完以上步骤,我们便可以将这些服务广告给对服务感兴趣的Central端。我们可以通过调用CBPeripheralManager实例的startAdvertising:方法来完成这一操作,如下代码所示:

1
[peripheralManager startAdvertising:@{CBAdvertisementDataServiceUUIDsKey: @[service.UUID]}];

startAdvertising:的参数是一个字典,Peripheral管理器支持且仅支持两个key值:CBAdvertisementDataLocalNameKey与CBAdvertisementDataServiceUUIDsKey。这两个值描述了数据的详情。key值所对应的value期望是一个表示多个服务的数组。

当广告服务时,CBPeripheralManager对象会调用代码对象的peripheralManagerDidStartAdvertising:error:方法,我们可以在此做相应的处理,如下代码所示:

1
2
3
4
5
6
7
8
9
- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)peripheral error:(NSError *)error
{
NSLog(@"Start Advertising");
if (error)
{
NSLog(@"Error advertising: %@", [error localizedDescription]);
}
}

广告服务之后,Central端便可以发现设备并初始化一个连接。

对Central端的读写请求作出响应

在与Central端进行连接后,可能需要从其接收读写请求,我们需要以适当的方式作出响应。

当连接的Central端请求读取特性的值时,CBPeripheralManager对象会调用代理对象的peripheralManager:didReceiveReadRequest:方法,代理方法提供一个CBATTRequest对象以表示Central端的请求,我们可以使用它的属性来填充请求。下面代码简单展示了这样一个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)peripheralManager:(CBPeripheralManager *)peripheral didReceiveReadRequest:(CBATTRequest *)request
{
// 查看请求的特性是否是指定的特性
if ([request.characteristic.UUID isEqual:cha1.UUID])
{
NSLog(@"Request character 1");
// 确保读请求所请求的偏移量没有超出我们的特性的值的长度范围
// offset属性指定的请求所要读取值的偏移位置
if (request.offset > cha1.value.length)
{
[peripheralManager respondToRequest:request withResult:CBATTErrorInvalidOffset];
return;
}
// 如果读取位置未越界,则将特性中的值的指定范围赋给请求的value属性。
request.value = [cha1.value subdataWithRange:(NSRange){request.offset, cha1.value.length - request.offset}];
// 对请求作出成功响应
[peripheralManager respondToRequest:request withResult:CBATTErrorSuccess];
}
}

在每次调用代理对象的peripheralManager:didReceiveReadRequest:时调用respondToRequest:withResult:方法以对请求做出响应。

处理写请求类似于上述过程,此时会调用代理对象的peripheralManager:didReceiveWriteRequests:方法。不同的是代理方法会给我们一个包含一个或多个CBATTRequest对象的数组,每一个都表示一个写请求。我们可以使用请求对象的value属性来给我们的特性属性赋值,如下代码所示:

1
2
3
4
5
6
7
8
- (void)peripheralManager:(CBPeripheralManager *)peripheral didReceiveWriteRequests:(NSArray *)requests
{
CBATTRequest *request = requests[0];
cha1.value = request.value;
[peripheralManager respondToRequest:request withResult:CBATTErrorSuccess];
}

响应处理与请求类似。

发送更新的特性值给订阅的Central端

如果有一个或多个Central端订阅了我们的服务的特性时,当特性发生变化时,我们需要通知这些Central端。为此,代理对象需要实现peripheralManager:central:didSubscribeToCharacteristic:方法。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)peripheralManager:(CBPeripheralManager *)peripheral central:(CBCentral *)central didUnsubscribeFromCharacteristic:(CBCharacteristic *)characteristic
{
NSLog(@"Central subscribed to characteristic %@", characteristic);
NSData *updatedData = characteristic.value;
// 获取属性更新的值并调用以下方法将其发送到Central端
// 最后一个参数指定我们想将修改发送给哪个Central端,如果传nil,则会发送给所有连接的Central
// 将方法返回一个BOOL值,表示修改是否被成功发送,如果用于传送更新值的队列被填充满,则方法返回NO
BOOL didSendValue = [peripheralManager updateValue:updatedData forCharacteristic:(CBMutableCharacteristic *)characteristic onSubscribedCentrals:nil];
NSLog(@"Send Success ? %@", (didSendValue ? @"YES" : @"NO"));
}

在上述代码中,当传输队列有可用的空间时,CBPeripheralManager对象会调用代码对象的peripheralManagerIsReadyToUpdateSubscribers:方法。我们可以在这个方法中调用updateValue:forCharacteristic:onSubscribedCentrals:来重新发送值。

我们使用通知来将单个数据包发送给订阅的Central。当我们更新订阅的Central时,我们应该通过调用一次updateValue:forCharacteristic:onSubscribedCentrals:方法将整个更新的值放在一个通知中。

由于特性的值大小不一,所以不是所有值都会被通知传输。如果发生这种情况,需要在Central端调用CBPeripheral实例的readValueForCharacteristic:方法来处理,该方法可以获取整个值。

Central(Client)端操作

一个Central端主要包含以下操作:

  1. 启动一个Central端管理器对象
  2. 搜索并连接正在广告的Peripheral设备
  3. 在连接到Peripheral端后查询数据
  4. 发送一个对特性值的读写请求到Peripheral端
  5. 当Peripheral端特性值改变时接收通知

我们将在下面结合代码对每一步分别进行讲解

启动一个Central管理器

CBCentralManager对象在Core Bluetooth中表示一个本地Central设备,我们在执行任何BLE交互时必须分配并初始化一个Central管理器对象。创建代码如下所示:

1
2
3
// 指定当前类为代理对象,所以其需要实现CBCentralManagerDelegate协议
// 如果queue为nil,则Central管理器使用主队列来发送事件
centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:nil];

创建Central管理器时,管理器对象会调用代理对象的centralManagerDidUpdateState:方法。我们需要实现这个方法来确保本地设备支持BLE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)centralManagerDidUpdateState:(CBCentralManager *)central
{
NSLog(@"Central Update State");
switch (central.state) {
case CBCentralManagerStatePoweredOn:
NSLog(@"CBCentralManagerStatePoweredOn");
break;
case CBCentralManagerStatePoweredOff:
NSLog(@"CBCentralManagerStatePoweredOff");
break;
case CBCentralManagerStateUnsupported:
NSLog(@"CBCentralManagerStateUnsupported");
break;
default:
break;
}
}

发现正在广告的Peripheral设备

Central端的首要任务是发现正在广告的Peripheral设备,以备后续连接。我们可以调用CBCentralManager实例的scanForPeripheralsWithServices:options:方法来发现正在广告的Peripheral设备。如下代码所示:

1
2
3
4
// 查找Peripheral设备
// 如果第一个参数传递nil,则管理器会返回所有发现的Peripheral设备。
// 通常我们会指定一个UUID对象的数组,来查找特定的设备
[centralManager scanForPeripheralsWithServices:nil options:nil];

在调用上述方法后,CBCentralManager对象在每次发现设备时会调用代理对象的centralManager:didDiscoverPeripheral:advertisementData:RSSI:方法。

1
2
3
4
5
6
7
8
9
- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary *)advertisementData RSSI:(NSNumber *)RSSI
{
NSLog(@"Discover name : %@", peripheral.name);
// 当我们查找到Peripheral端时,我们可以停止查找其它设备,以节省电量
[centralManager stopScan];
NSLog(@"Scanning stop");
}

连接Peripheral设备

在查找到Peripheral设备后,我们可以调用CBCentralManager实例的connectPeripheral:options:方法来连接Peripheral设备。如下代码所示

1
[centralManager connectPeripheral:peripheral options:nil];

如果连接成功,则会调用代码对象的centralManager:didConnectPeripheral:方法,我们可以实现该方法以做相应处理。另外,在开始与Peripheral设备交互之前,我们需要设置peripheral对象的代理,以确保接收到合适的回调。

1
2
3
4
5
6
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral
{
NSLog(@"Peripheral Connected");
peripheral.delegate = self;
}

查找所连接Peripheral设备的服务

建立到Peripheral设备的连接后,我们就可以开始查询数据了。首先我们需要查找Peripheral设备中可用的服务。由于Peripheral设备可以广告的数据有限,所以Peripheral设备实际的服务可能比它广告的服务要多。我们可以调用peripheral对象的discoverServices:方法来查找所有的服务。如下代码所示:

1
[peripheral discoverServices:nil];

参数传递nil可以查找所有的服务,但一般情况下我们会指定感兴趣的服务。

当调用上述方法时,peripheral会调用代理对象的peripheral:didDiscoverServices:方法。Core Bluetooth创建一个CBService对象的数组,数组中的元素是peripheral中找到的服务。

1
2
3
4
5
6
7
8
9
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error
{
NSLog(@"Discover Service");
for (CBService *service in peripheral.services)
{
NSLog(@"Discovered service %@", service);
}
}

查找服务中的特性

假设我们已经找到感兴趣的服务,接下来就是查询服务中的特性了。为了查找服务中的特性,我们只需要调用CBPeripheral类的discoverCharacteristics:forService:方法,如下所示:

1
2
NSLog(@"Discovering characteristics for service %@", service);
[peripheral discoverCharacteristics:nil forService:service];

当发现特定服务的特性时,peripheral对象会调用代理对象的peripheral:didDiscoverCharacteristicsForService:error:方法。在这个方法中,Core Bluetooth会创建一个CBCharacteristic对象的数组,每个元素表示一个查找到的特性对象。如下代码所示:

1
2
3
4
5
6
7
8
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(NSError *)error
{
NSLog(@"Discover Characteristics");
for (CBCharacteristic *characteristic in service.characteristics)
{
NSLog(@"Discovered characteristic %@", characteristic);
}
}

获取特性的值

一个特性包含一个单一的值,这个值包含了Peripheral服务的信息。在获取到特性之后,我们就可以从特性中获取这个值。只需要调用CBPeripheral实例的readValueForCharacteristic:方法即可。如下所示:

1
2
NSLog(@"Reading value for characteristic %@", characteristic);
[peripheral readValueForCharacteristic:characteristic];

当我们读取特性中的值时,peripheral对象会调用代理对象的peripheral:didUpdateValueForCharacteristic:error:方法来获取该值。如果获取成功,我们可以通过特性的value属性来访问它,如下所示:

1
2
3
4
5
6
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error
{
NSData *data = characteristic.value;
NSLog(@"Data = %@", data);
}

订阅特性的值

虽然使用readValueForCharacteristic:方法读取特性值对于一些使用场景非常有效,但对于获取改变的值不太有效。对于大多数变动的值来讲,我们需要通过订阅来获取它们。当我们订阅特性的值时,在值改变时,我们会从peripheral对象收到通知。

我们可以调用CBPeripheral类的setNotifyValue:forCharacteristic:方法来订阅感兴趣的特性的值。如下所示:

1
[peripheral setNotifyValue:YES forCharacteristic:characteristic];

当我们尝试订阅特性的值时,会调用peripheral对象的代理对象的peripheral:didUpdateNotificationStateForCharacteristic:error: 方法。如果订阅失败,我们可以实现该代理方法来访问错误,如下所示:

1
2
3
4
5
6
7
8
9
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error
{
...
if (error)
{
NSLog(@"Error changing notification state: %@", [error localizedDescription]);
}
}

在成功订阅特性的值后,当特性值改变时,peripheral设备会通知我们的应用。

写入特性的值

一些场景下,我们需要写入特性的值。例如我们需要与BLE数字恒温器交互时,可能需要给恒温器提供一个值来设定房间的温度。如果特性的值是可写的,我们可以通过调用CBPeripheral实例的writeValue:forCharacteristic:type:方法来写入值。

1
2
NSData *data = [NSData dataWithBytes:[@"test" UTF8String] length:@"test".length];
[peripheral writeValue:data forCharacteristic:characteristic type:CBCharacteristicWriteWithResponse];

当尝试写入特性值时,我们需要指定想要执行的写入类型。上例指定了写入类型是CBCharacteristicWriteWithResponse,表示peripheral让我们的应用知道是否写入成功。

指定写入类型为CBCharacteristicWriteWithResponse的peripheral对象,在响应请求时会调用代理对象的peripheral:didWriteValueForCharacteristic:error:方法。如果写入失败,我们可以在这个方法中处理错误信息。

小结

Core Bluetooth框架已经为我们封装了蓝牙通信的底层实现,我们只需要做简单的处理就可以在程序中实现基于蓝牙的通信。不过在游戏中,一般使用Game Kit中自带的蓝牙处理功能,以实现大数据量的通信。Core Bluetooth框架还是比较适合小数据量的通信。

参考

  1. Core Bluetooth Programming Guide