CoreBluetooth 101 — your ultimate walkthrough

One of my favorite areas in mobile development is creating mobile applications that interact with objects outside their infrastructure, including various intriguing gadgets, sensors, and devices from the world of the Internet of Things. Today, let’s talk about this particular field, more specifically, about Bluetooth.

📦 Demo project with simple BLE receiver

Probably none of you need an explanation of what Bluetooth is and what it’s used for. This technology for wireless data transmission is currently at version 5.3 and has made significant leaps in its development. I bought my first iPhone in 2009, and even then, I had to endure tons of criticism about the imperfections of this device. One of the most frequent and irritating points for me was the constant reproach for the lack of file transfer via Bluetooth. And, in general, the inadequacy of Bluetooth on iOS and its limitation to just connecting wireless headphones. An amusing fact: just three years later, iOS had the most powerful framework among mobile platforms, which competitors took a long time to catch up with (in my opinion, they still haven’t). Indeed, most of the things I will talk about today were introduced in 2012 (and some even in 2011) and have changed little since then.

So, Bluetooth. I won’t provide formal explanations or delve deeply into its historical overview. Let’s keep it brief. For us, as developers, it’s an interface for interacting with external devices. Its development history began back in 1998, but it gained more widespread adoption in the mid-2000s with the rapid growth of the smartphone and communicator market. In the early days of iOS, up to version 4, developers had no means to interact with devices via Bluetooth at all, but everything changed in 2011 with the release of iOS 5. A key factor in this rapid growth was the adoption of the Bluetooth 4.0 standard in 2010, which met market requirements in terms of speed and energy consumption. Energy efficiency played a crucial role. It was then that the standard, or more precisely, the specification, BLE – Bluetooth Low Energy, was introduced, significantly expanding the applications of this wireless technology.

What are the special features of BLE? Well, as the name suggests, its main highlight is its extremely (certainly for 2010) low energy consumption. The specification allows the development of devices that minimally drain their batteries and can operate for up to one year on a single charge of a small battery. This specification was developed by the Bluetooth Special Interest Group (SIG), and these guys got as intricate as, it seems to me, was even possible. To prevent developers’ imaginations from running wild and to avoid creating a multitude of competing standards, SIG developed a specification for all possible types of devices, from wearable pulse sensors to stationary door locks. It is through Bluetooth LE that interaction is available to us through the built-in CoreBluetooth framework. Apple also did a tremendous job, hiding all the details of working with low-level stuff and providing a very simple API that even a novice iOS developer can understand!

Bluetooth Low Energy in a nutshell

The foundation of BLE is the GATT profile – General Attribute Profile, which provides us with the abstractions of services and characteristics. Roughly speaking, a characteristic is a data cell, and a service is a logical combination of these cells. This might sound confusing, but I’ll give an example and everything will fall into place. The most classic and actually common example is a thermometer. It can have a temperature measurement service with two characteristics: the unit of measurement (℃, ℉) and the actual temperature. Additionally, it might measure humidity, for which there would be a separate service with just one characteristic – the humidity percentage.

All interaction in BLE is based on the classic client-server model: here too, there are clients and servers, as well as requests. From this point on, I will smoothly transition to the CoreBluetooth framework itself and will explain new terms closer to the subject area, as most of the terms coincide with BLE terms, but with a CB prefix — CoreBluetooth.

CoreBluetooth

So, CoreBluetooth. As mentioned earlier, it appeared in iOS 5, was significantly revised in iOS 6, and has existed ever since, without undergoing any major changes. As I’ve already said, all interaction is based on the classic client-server model, and in CB, we have the server – Peripheral, and the client – Central. This naming might be slightly confusing at first, as we are accustomed to the server being some kind of center, but CB turns everything upside down. However, from another perspective, it all makes sense: a Peripheral device is an external device relative to our central one. A Peripheral device could be a fitness tracker, a smart light bulb, a lock, or an entire smart home system, and it could also be another mobile device or computer.

Discovering

Although the process is client-server, unlike HTTP, we don’t know the exact address or alias of the device, so to start interacting with it, we need to find it in the air. The process of discovering a device is called scanning or discovering. It is performed by the Central device, the side that wants to connect to something. The process involves scanning the Bluetooth ether for so-called Advertisement packets, which the peripheral device should send. An Advertisement packet is a tiny packet regularly sent by the peripheral device when it wants to be found. It usually contains basic information necessary to understand the class of the device: a thermometer, a lock, a scooter, or another iPhone. Depending on the operating mode of the device and its energy consumption settings, packets can be sent at varying intervals, from once every few microseconds to once every tens of seconds, so the duration of scanning directly depends on the operating mode of the peripheral device.

As soon as the desired device is found, we can connect to it and inquire about the services it offers and what characteristics are available in these services. Let’s move closer to the code.

So, we want to find a thermometer, which in our case is a peripheral device. This means we will be the client, or the Central Device. The object representing the client in the CoreBluetooth framework is of type CBCentralManager. As you might guess, all interaction with an external device is expected to be asynchronous. And it’s even easier to guess that Apple preferred the delegate pattern to facilitate asynchronous operations. The delegate we need is of type CBCentralManagerDelegate, and the object implementing this protocol will receive notifications about events related to discovery and connection.

As soon as we are ready to scan the ether, we need to call the method scanForPeripherals on the CBCentralManager manager. As soon as our device detects a new advertisement packet, the delegate’s didDiscoverPeripheral method will be called with a CBPeripheral object in the method parameters. When we find the desired device or devices, we need to stop scanning with the stopScan method and call connect , specifying an instance of the CBPeripheral class.





Discovering of services

Once the connection is established, we can begin querying the device. From this point, we are working with the CBPeripheral object and its delegate, CBPeripheralDelegate, which represent the external device we have connected to. To read a value (characteristic), we first need to find the service that contains this characteristic. Let’s initiate a search for services. We can either specify the service or services we are interested in, or not specify them at all and discover all that the device implements.


☝🏻

Here, it’s important to take a moment to explain how we can distinguish one service from another. The identifier for services and their characteristics is the UUID, a unique identifier that comes in two types: 16-bit and 128-bit. We can generate and use 128-bit identifiers at our discretion when writing our own client-server applications. However, 16-bit identifiers are reserved by SIG. Remember, I mentioned that these guys went to great lengths and described almost everything that interacts or can interact via BLE? Well, they numbered all these services and characteristics and assigned them unique 16-bit UUIDs. If you ever want to develop a hardware device that can work with more than just your application, you should study their specs and follow these recommendations. But if you’re developing an application for a certain class of devices, you can use this spec to find the identifier of the service you need. Since we’re looking for a thermometer, we can confidently specify the UUID 0x1809 and ignore the rest (if there are any)


Discovering of characteristics

Okay, we’ve found the services, and we’ll be notified about this in the delegate. Now, in a similar way, we can find the characteristics within them by calling discoverCharacteristics(for:) for each service. Just as with services, we can either specify the UUIDs of the characteristics we’re interested in or discover all of them, but the latter will be slower. When the process of exploring the characteristics is complete, the delegate will be notified with a call to the method didDiscoverCharacteristicsForService, and the characteristics themselves will be available in the corresponding property.





Characteristics

At this stage, we’ve finally reached the most “exciting” part – the data, or more precisely, the characteristics. In most straightforward cases, they can be divided by the types of operations they support: read, write, notify. In reality, there are a few more options (more accurately, properties), but for the scope of today’s discussion, we’ll only look at these.

A characteristic must support at least one of these operations, but it can also support all of them. The notify type is particularly interesting. As you might guess, instead of directly reading, we have the option to receive a notification when the device decides to send us data, for example, when they change. Returning to the thermometer example, the characteristic for the unit of measurement will have the flags read and write, while the actual temperature will have read and notify. It’s sufficient to read the unit of measurement upon connection, but constantly reading the temperature via a timer is not the best solution. We’re trying to be Low Energy — constantly querying the device can be costly both for us and for the peripheral device.

To work with characteristics, three main methods are used:

Reading data is straightforward: trigger the readValue method, and a request will be sent to the device. The response will be received in the CBPeripheralDelegate delegate when the data arrives. The same thing happens if the data gets updated and we are subscribed to it using setNotifyValue.

Writing data comes in two types — withResponse and withoutResponse. Without going into too much detail, these are write operations with and without acknowledgment. Naturally, withResponse is slower, as each write request will generate a response. This should be particularly considered when transmitting relatively large volumes of data — in this case, writing withoutResponse would be preferable.

Limits

Indeed, what about the limits? We must not forget that LE in the name stands for Low Energy, meaning it was designed to be highly economical in all aspects, including packet sizes. BLE is not meant for streaming or large volumes of data. According to Wikipedia, the speed for the 4.0 standard is only 0.27 Mbit/sec.

By default, the packet size for the payload is 23 bytes, of which 3 are reserved by the system. This leaves 20 bytes for the actual payload. When working with BLE, you’ll have to deal with this limitation and figure out how to manually divide your data into packets, as 20 bytes often prove insufficient. In some cases, when communicating with a modern smartphone, this limit can be increased up to 512 bytes. CoreBluetooth takes responsibility for negotiating the maximum MTU, as well as for automatic packet segmentation if supported by the device. If you need different speeds or volumes, you might have to use the L2CAP channel and work at a lower level, bypassing GATT. Fortunately, this option has been available since iOS 11 (but only for Apple devices).

“Server” side (Peripheral)

At this stage, you’ve been introduced to all the key classes and concepts of CoreBluetooth and are likely able to write your first application that connects to a BLE device. But what if that device is another iPhone? Let’s briefly look at the other side of the equation, the server side, or in our case, the Peripheral.

Analogous to CBCentralManager, for creating a server we have CBPeripheralManager and its CBPeripheralManagerDelegate. To create our service, we first need to create the service and its characteristics. Let’s have just one service with a single read-only characteristic. The counterpart of CBService on the server side is CBMutableService. Yes, the CoreBluetooth API isn’t “swiftified” yet, so we have the prefix Mutable for mutable versions of classes. When creating it, we need to specify a UUID, which can be generated using the command uuidgen in the console, and indicate whether it is the primary service of our device.

After creating the service, it needs to be filled with characteristics, represented by the class CBMutableCharacteristic. When creating it, the following are specified:

  • UUID
  • Properties
  • Initial value (for static characteristics)
  • Access rights

We assemble our tiny tree with one leaf, add it to the manager, and can announce ourselves by broadcasting advertisement packets. For this, CBPeripheralManager has a method startAdvertising. It takes a dictionary with broadcasting parameters as input. I don’t want to delve into the details of the structure of the advertisement packet right now, but there’s something I’d like to highlight.


☝🏻

At the beginning of the discussion about CBCentral, I intentionally omitted the fact that when scanning the ether, you can specify the UUIDs of the services you’re interested in. It would be impractical and time-consuming to scan the ether and then connect to all found devices just to determine if they have the desired service. Instead, the server can include the UUIDs of its main services in the advertisement packet, allowing clients to filter out devices that are not of interest to them during scanning, without connecting and reading the list of services. This can be done by specifying the corresponding key in the dictionary mentioned earlier at the start of advertising.


As soon as the broadcasting of packets begins, we’ll receive a corresponding notification in the delegate. And when a client connects to us... nothing will happen. The manager’s delegate doesn’t have a corresponding method for this event. Instead, we have methods to react to events of reading, writing, and subscribing to characteristics. Using these methods, we can decide whether we are ready to provide or write a value, validate it, and so on.

States

So far, I haven’t mentioned situations when Bluetooth is unavailable or turned off. Monitoring the manager’s state is crucial, and operations should only be performed in permissible states to avoid crashes. The manager, whether Central or Peripheral, can be in the following states:

  • unknown
  • resetting — BLE stack is rebooting
  • unsupported — BLE is not supported
  • unauthorized — User denied permission
  • poweredOff — Bluetooth is turned off
  • poweredOn — Bluetooth is on, ready to operate

Beginning device discovery, broadcasting advertisement packets, and even adding services to the manager can only be done in the poweredOn state. Therefore, paying attention to this delegate method is unavoidable, as the manager delegate protocol itself implies — the method for tracking the state is the only one marked as mandatory.

Security

As mentioned several times, energy efficiency has always been a priority in BLE. Therefore, by default, all traffic between devices is not encrypted, saving both CPU resources and bytes in packets. Such traffic can be easily intercepted or even tampered with by an attacker. For most practical tasks engineers solve with BLE, this level of security is sufficient. After all, it’s not a significant risk if someone intercepts the temperature from your thermometer or the battery charge level. However, there are cases where it’s crucial to protect private data from malicious interception or to ensure that writing is from a trusted source.

In cases where additional security is needed, appropriate rights can be set for a characteristic. Attempting to read or write such a characteristic, the server will reject the process, informing that the communication needs to be encrypted, responding with an Insufficient Authentication error. To proceed, the client needs to establish a secure connection, which we more commonly refer to as pairing, and less often as bonding.

The pairing process can vary for different devices; some require the input of constant numbers, some display numbers on the screens of both devices, or simply ask to confirm the match. As a result of pairing, keys are generated on both sides of the process, and the communication is encrypted.

After successful pairing, you need to reconnect to the device and re-read the characteristic. Fortunately, CoreBluetooth takes this entire process upon itself, hiding all these complex actions under the hood. That means if you try to read or write a protected characteristic, CoreBluetooth will perform all these actions, and you will eventually get a response in the delegate, just a bit later than usual.





Permissions

Speaking of security, it’s worth mentioning that Apple recently requires the inclusion of a text in the Info.plist file explaining to the end-user why your application needs access to Bluetooth. At the first attempt to scan or start broadcasting advertisement packets, the system will ask the user whether they allow the use of Bluetooth, just as it usually happens when requesting access to the photo gallery or push notifications. On the Android platform, the system also requires permission for low-precision location access because potentially, scanning can be used to determine the user’s location. It’s possible that similar rules might be implemented in iOS in the future.

Reconnection

When working with Bluetooth, as with any other communication technology, connection interruptions may occur. A disconnection can be initiated by either side or due to a loss of signal. CoreBluetooth is designed to continuously maintain a connection until the cancelConnection(from:) method is called. Therefore, if the connection is lost due to an unstable connection or when the remote device is turned off, CoreBluetooth will attempt to restore the connection in the background as long as your application is running. It will autonomously scan the ether and reconnect as soon as possible. This might seem like a simple feature, but believe me – you wouldn’t want to write this algorithm from scratch. Colleagues on other platforms don’t have this mechanism out-of-the-box, saving the iOS team several days of discussions and implementation.

In the example above, we considered a case where the disconnect was unexpected. However, it’s also worth considering reconnection as a natural part of the life cycle. Suppose, after working with a device for a few minutes, we’ve obtained all the information we currently need and don’t want to waste resources maintaining the connection. In this case, we can save the unique UUID of the peripheral device and call the method cancelPeripheralConnection(). Later, when we need data from the device again, we can use the saved UUID to reconnect. It’s important to note that the `connect` method takes a CBPeripheral instance as a parameter, which doesn’t have a public constructor. To obtain an instance, we can use the retrievePeripherals(withIdentifiers:) method of CBCentralManager, which can take an array of UUIDs.


⚠️

It’s important to note that the method now works with UUIDs from the Foundation library, not CBUUID, because this is a platform implementation and does not reference the CoreBluetooth specification. After mapping the UUIDs to an array of CBPeripheral, you can connect to them.


There may be a question of what to do if the device is not within Bluetooth range or has gone to sleep for a while. In this case... nothing will happen. We won’t receive a timeout error or any other kind. CoreBluetooth will keep trying to connect to the device indefinitely, until you call cancelPeripheralConnection(:). This logic might seem odd; indeed, why would I want to connect endlessly to a thermometer if it’s not in range? But let’s not forget that there are many usage scenarios, and we are working with the LE version of the technology. This means that some devices by design may only broadcast at infrequent intervals to save battery life. Then such behavior makes sense. Ultimately, adding a timeout logic is much easier than writing a reconnection chain, should the timeout be systemic.

Working in the background

Like all resource-intensive operations in iOS, working with devices in the background is not available by default. When the application is minimized, whether your app is a server or a client, it will lose connection and, like all others, will stop serving all queues. But upon returning to the foreground, CoreBluetooth will restore the connection.

Automatic reconnection is good, but clearly not enough. You can fully control the interaction process with devices in the background by setting the corresponding background mode in the Info.plist.

For each role, server and client, there’s a separate checkbox. You can mark both. With the background mode activated, the application can interact with devices, read and write characteristics, scan the network, etc. However, of course, this process is not eternal and, like almost any application, iOS may eventually unload the application from memory. Nothing can be done about this, and we have to live with it. The good news is that upon returning to the application, CoreBluetooth can restore the state, with all connected devices, found characteristics, etc. All of the above applies to cases where the application was unloaded at the system’s request, not closed by the user from the task manager.

I would like to remind you again that Apple is serious about energy consumption, so background processes will be slower. For example, scanning will occur discretely and less intensively.

It’s important to note that when implementing a connection between two iPhones, iPads, or Macs without the corresponding background mode, the server part going into the background will be accompanied by the deactivation of registered services, rather than the termination of the Bluetooth connection. That is, the remote side (client) will not receive a disconnection event, but will receive a didModifyServices notification that some services of the Peripheral device have disappeared. Remember, in this case, you are connected to one physical device — another phone, tablet, or computer, and not to a program as in the case of HTTP interaction.

L2CAP connection

In 2017, Apple introduced the capability for developers to communicate with Apple devices not only through the GATT protocol but also by opening their own low-level connection through the underlying L2CAP protocol. This allows for the creation of custom implementations for radio channel interactions, bypassing the limitations of GATT characteristics. While this is more complex to implement than reading and writing characteristics, it is still relatively straightforward.

From the server side, we still need to scan the airwaves, find, and connect to the device. If the device supports this type of connection, it will have a special service with a characteristic containing a channel ID. To initiate information exchange, you need to read this ID and request the CBPeripheral to open the channel using the openL2CAPChannel() method.

If you are implementing the server-side, you need to declare that you support L2CAP. To do this, in the relevant manager, there is a method publishL2CAPChannelWithEncryption() that will prepare the channel, service, and characteristic, and automatically publish the necessary data.

After a successful connection, delegates on both sides of the process will receive a notification didOpenL2CAPChannel specifying the channel. The channel itself has two properties, inputStream and outputStream, which allow you to implement both reading and writing data through the channel using the standard iOS/Mac stream abstraction.

Hidden Hurdles

We are very close to completing my narrative. In the end, I would like to briefly highlight the stumbling blocks that had to be overcome when working with CoreBluetooth.

Caching

When developing and debugging an application with BLE, always remember the caching that you have no control over. CoreBluetooth will attempt to cache both services and characteristics for your device. In addition to this, meta-information is also cached, such as the device name.

On one of the projects, it was necessary to create a simulator for a hardware device so that QA could test interactions in various edge cases that couldn’t be reproduced with a real device or where the reproduction was too time-consuming. When developing the server-side, we can change the name of our device in the advertising packet and hope that we will see the changed name. If we were the only Bluetooth users on the device, everything would be fine: we specify the name in the real packet, and that’s what the client sees. However, Bluetooth on the device is also used by the OS, often sending advertising packets with the device name from system settings. For example, for quick device discovery with services like AirDrop. If the device where you plan to test your client-side has already captured at least one advertising packet, the name will be cached. When attempting to read the name of the CBPeripheral, we will see the cached name rather than the one specified in the advertising packet settings.

The same can apply to the set of services and characteristics for paired devices. If you add a service after establishing a connection, it is quite possible that the client may not see it.

Versioning

A very brief note where I urge you to consider incorporating a software version characteristic. If you are working with a standalone device, such a privilege may not be necessary. However, if you are implementing client-server communication between Apple devices, do not hesitate to do so. This will reduce the need for code workarounds when guessing the device or software version based on the presence or absence of certain services or characteristics. This is often done to determine supported functions on a peripheral device.

Hardware Issues

Be prepared for things to not go as planned. Hardware developers are also developers who encounter bugs and crashes, but their release cycles are usually much longer, especially when you have limited communication with them. Be prepared for someone to deviate from standards, such as not including information about services in the advertising packet.

For example, at the very beginning of my journey with a BLE project, I faced such a problem. I wrote and debugged the client side according to the documentation, tested it with a simulator I had created, and everything worked perfectly. When the real device arrived a month later, it was impossible to find it. I could see that it was in the air, but I couldn’t see any notifications in the delegate. After several days of correspondence and by pure chance, I realized that if I turned off the UUID service filtering, everything worked. I had to filter by name, and the name sometimes changed, which led me back to the caching issue. In short, it was quite an adventure.

As I mentioned earlier, the device can crash and hang. Fortunately, on a physical hardware device, there is almost always a watchdog that will reboot it, but understanding what is going wrong can be quite challenging. As I mentioned, there are developers working on it, and in such a low-level system, it’s easy to encounter some race conditions. I experienced unexpected device reboots several times when reading characteristics, either when there was no data for them or when they were supposed to be read in a different order.

Share
Send
Pin
2022