blog heading image

Introduction

One of the great pros of working with .NET MAUI is the support for native SDKs. Most if not all the available functionality from each native platform's SDK is translated into a C# version, ready for use with no more effort than doing a File > New Project.

This article will detail how to use one of these native SDKs, Apple's CoreBluetooth (CB). Unfortunately, there are some slight changes in the MAUI version of CB compared to its native counterpart, making the implementation process slightly confusing. For the most part, these changes are with the names of functions and properties from CB, so if you are already familiar with native CB, then much of your knowledge will transfer to MAUI.

Creating a new project

Prerequisites:

  • Visual Studio or JetBrains rider
  • .NET 8
  • .NET MAUI

For installation instructions, see: https://learn.microsoft.com/en-us/dotnet/maui/get-started/installation?view=net-maui-8.0&tabs=vswin

Fire up your IDE of choice (I will be using Rider for this tutorial) and after installing MAUI you should see a template for a .NET MAUI application:

A screenshot of a computerDescription automatically generated
Create a new solution, and make sure your project builds and deploys properly.

CBCentralManager in .NET MAUI

Just as in native CoreBluetooth, we create an instance of a <span class="richtext-chip">CBCentralManager</span> and use that instance to interact with most Bluetooth logic. The one challenge with .NET MAUI's implementation of CB is how the types of these classes are defined.

In native Swift, the type <span class="richtext-chip">CBCentralManager</span> is a class, and <span class="richtext-chip">CBCentralManagerDelegate</span> is a protocol. This means you can have a single class inherit from both types, as there is no multiple-class inheritance. In .NET MAUI on the other hand, <span class="richtext-chip">CBCentralManager</span> is also a class, but <span class="richtext-chip">CBCentralManagerDelegate</span> is an abstract class and not an interface. This means we need to split up our implementation to work around the multiple-class inheritance restrictions of C#, which can be a pain.

The way I worked around this is one of many possible solutions to this issue, I encourage you to find a better way if possible. I created a class called <span class="richtext-chip">AppleBleManager</span>, with two static members:

1public static CBCentralManager? CentralManager { get; private set; }  
2public static void Initialize() 
3{     
4	CentralManager = new CBCentralManager(CentralManagerDelegate.Instance, DispatchQueue.CurrentQueue); 
5} 

I found that initializing the instance of the CentralManager on app start-up was causing issues, therefore I pulled the logic out into a static initialization function that I can call closer to when I want to start using BLE in my app.

Note that there are C# interface declarations of these two classes as well, ICBCentralManager as well as ICBCentralManagerDelegate. Unfortunately, if you try to inherit from these interfaces, none of the functions from their respective class counterparts are in the interface for some reason.

As for the instance of <span class="richtext-chip">CBCentralManagerDelegate</span>, I chose to follow a similar, static singleton approach:

1internal class CentralManagerDelegate : CBCentralManagerDelegate 
2{ 
3internal static CentralManagerDelegate Instance { get; } = new();  
4private CentralmanagerDelegate(){}  
5// delegate callbacks... 
6} 

The last piece to our delegate puzzle is the <span class="richtext-chip">CBPeripheralDelegate</span>, so we can receive callbacks for interacting with our peripheral. The way I achieved this was to create a class representing an instance of a peripheral or device:

1public class AppleBluetoothDevice : CBPeripheralDelegate
2{
3...
4}

It is important to note that we need to keep an instance of the <span class="richtext-chip">CBPeripheral</span> that is returned from interacting with the device in certain operations, which we will get to in a bit:

1public class AppleBluetoothDevice : CBPeripheralDelegate
2{
3  private CBPeripheral Peripheral { get; set; }
4  public AppleBluetoothDevice(CBPeripheral peripheral)
5  {
6    Peripheral = peripheral;
7  }
8}

Scanning for peripherals

Now that we have access to instances of our manager and delegates, we can get to work. Our first step to interacting with a peripheral is to obtain an instance of it via a scan:

1public async Task ScanForPeripherals(ScanOptions options)
2{
3   // Start the scan.
4   AppleBleManager.CentralManager?.ScanForPeripherals((CBUUID[]
5   // Wait for a specified duration.
6   await Task.Delay(TimeSpan.FromSeconds(options.ScanDurationSe
7   // Stop the scan.
8   AppleBleManager.CentralManager?.StopScan();
9}

Any device that is found will result in a callback being fired on <span class="richtext-chip">CentralManagerDelegate.DiscoveredPeripheral()</span>. Here we can keep a list of devices to add to our scan result:

1internal class CentralManagerDelegate : CBCentralManagerDeleg
2ate
3{
4  ...
5  // ConcurrentQueue<T> for thread safety, there is no Conc
6  urrentList<T> so use the next thing that makes sense.
7  internal ConcurrentQueue<AppleBluetoothDevice> Discovered
8  Devices { get; } = [];
9  ...
10  public override void DiscoveredPeripheral(CBCentralManage
11  r central, CBPeripheral peripheral, NSDictionary advertisementData, NSNumber RSSI)
12  {
13    // Your scan logic
14    // Create an instance of the device.
15    var device = new AppleBluetoothDevice(peripheral)
16    {
17      AdvertisedData = manufacturerDataBytes,
18      FullAdvertisementData = advertisementData,
19      Name = peripheral.Name,
20      Getting started with MAUI CoreBluetooth 6
21    };
22 // Add it to the list of all found devices.
23 DiscoveredDevices.Enqueue(device);
24  }
25}

TaskCompletionSource in C#

If you are new to C#, there exists an asynchronous blocking mechanism known as <span class="richtext-chip">TaskCompletionSource</span>. You can think of these as a regular old semaphore with the ability to return a value from the thread releasing the semaphore. Here is the documentation for more details: https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcompletionsource-1?view=net-8.0. I use them quite a bit from here on out, so understanding how they work is imperative.

Connecting to a peripheral

Now that we have an instance of our <span class="richtext-chip">CBPeripheral</span> in memory, we can start to interact with it. Our next step will be to connect to said device.

It is important to note that on a connection or failure to connect, <span class="richtext-chip">CentralManagerDelegate.ConnectedPeripheral()</span> or <span class="richtext-chip">CentralManagerDelegate.FailedToConnectPeripheral()</span> will fire respectively with a new instance of the <span class="richtext-chip">CBPeripheral</span> object.

1public class AppleBluetoothDevice 
2{ ...  
3	public async Task Connect() 
4	{ 
5    	CentralManager?.ConnectPeripheral(Peripheral, new PeripheralConnectionOptions 
6    	{     
7        NotifyOnConnection = true,     
8        NotifyOnDisconnection = true,     
9        NotifyOnNotification = true 
10        });  
11        // await your TaskCompletionSource var res = await CompletionSources.Connect.Task; 
12	} 
13} 

then in <span class="richtext-chip">CentralManagerDelegate.ConnectedPeripheral()</span>:

1public class CentralManagerDelegate 
2{
3
4    public override void ConnectedPeripheral(CBCentralManager central, CBPeripheral peripheral) 
5    { 
6    	// release our TaskCompletionSource
7        CompletionSources.Connect.TrySetResult(new Tuple<ConnectResult, object>(ConnectResult.Connected, peripheral));
8    }
9    
10    public override void FailedToConnectPeripheral(CBCentralManager central, CBPeripheral peripheral, NSError? error)
11    {
12    	CompletionSources.Connect.TrySetResult(new Tuple<ConnectResult, object>(ConnectResult.Failure, error ?? new NSError()));
13    } 
14} 

Note that my return type for a connection is a <span class="richtext-chip">Tuple<ConnectResult, object></span>. It is very important that after connecting to the peripheral, we set <span class="richtext-chip">AppleBluetoothDevice.Peripheral</span> to the instance that is passed in from <span class="richtext-chip">CentralManagerDelegate.ConnectedPeripheral()</span>. Here is the rest of our <span class="richtext-chip">Connect<span> function with this in mind:

1public class AppleBluetoothDevice 
2{
3	...  
4    
5    public async Task Connect() 
6    { 
7   	   ...  
8    
9    if (res.Item1 is ConnectResult.Connected) 
10    {
11		Peripheral = res.Item2 as CBPeripheral ?? throw new InvalidOperationException();
12    	Peripheral.Delegate = this; 
13        }
14        
15        return res.Item1; 
16    }
17} 

Service Discovery

Now that we are connected to our peripheral, we must discover the available services and characteristics to start interacting with it. Performing a service discovery will ultimately return instances of span <class="richtext-chip">CBCharacteristic</span>, which we need to maintain to communicate with that characteristic.

To hold on to instances of the <span class="richtext-chip">CBCharacteristic</span>, I create a <span class="richtext-chip">Dictionary<Characteristic, CBCharacteristic></span>, wherespan <class="richtext-chip">Characteristic</span> is an enum type with each member decorated with a custom attribute that takes a string as a parameter:

1[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
2sealed class CharacteristicUuidAttribute(string uuid) : Attribute 
3{
4	public string Uuid { get; } = uuid;
5}
6
7public enum Characteristic
8{
9	[CharacteristicUuid("3298AA05-360E-4FFF-A663-CAB47BE21330")]
10    MyCharacteristic,
11} 

This allows for generic usage of the characteristic in the non-platform-specific code up in our view model or services for example.

Here is an example of performing a service discovery, and then storing the characteristic references to use later:

1public async Task DiscoverServices()
2{ 
3	// CoreBluetooth-level call to start service discovery
4    Peripheral.DiscoverServices();
5    
6    // await your TaskCompletionSource
7    // release on CBPeripheralDelegate.DiscoveredService()
8    var services = await _centralManagerDelegate.CompletionSources.ServiceDiscovery.Task;
9    
10    if (services == ServiceDiscoveryResult.Success)
11    {
12    	// Cache the characteristics for later use.
13        foreach (var service in Peripheral.Services ?? [])
14        {
15        	Peripheral.DiscoverCharacteristics(service);
16            	foreach (var chara in service.Characteristics ?? [])
17                {
18                    // store 'chara' in dictionary for later use
19                }
20                }
21            return ServiceDiscoveryResult.Success; 
22      }
23} 

then in your <class="richtext-chip">CBPeripheralDelegate.DiscoveredService()</span>

1public override void DiscoveredService(CBPeripheral peripheral, NSError? error) 
2{
3	if(error is not null)
4    	// error handling
5        
6    CompletionSources.ServiceDiscovery.TrySetResult(ServiceDiscoveryResult.Success);
7} 

Reading from a characteristic

Now we have connected to a peripheral and have instances in memory of all the available characteristics, we can begin communication with the peripheral. Firstly let’s start with reading from a characteristic:

1public async Task ReadCharacteristic(Characteristic characteristic)
2{
3		// obtain an instance of CBCharacteristic
4        _characteristics.TryGetValue(options.Characteristic, out var cbChara);
5        
6        // read the data from that characteristic
7        Peripheral.ReadValue(cbChara);
8        /* Keep track of which characteristic we are reading from
9           because the callback is shared with incoming notifications. */
10    _readCharacteristics.Add(characteristic);
11    
12    var res = await CompletionSources.ReadCharacteristic.Task; 
13}
14
15// delegate -- called when the read completed 
16public override void UpdatedCharacterteristicValue(CBPeripheral peripheral, CBCharacteristic characteristic, NSError? error) 
17{ 
18	if(error is not null) 
19		// error handling
20        // extract read byte data
21    var data = characteristic.Value?.ToArray() ?? [];
22    	/* Keep track of which characteristic we are reading from
23        because the callback is shared with incoming notifications. */
24    if (_readCharacteristics.Contains(characteristic))
25    {
26    _readCharacteristics.Remove(characteristic);
27    CompletionSources.ReadCharacteristic.TrySetResult(new ReadResult(ReadStatus.Success, data));
28    }
29} 

Writing data to a characteristic

We also of course can write data to characteristics:

1public async Task WriteCharacteristic()
2{
3	// obtain an instance of CBCharacteristic
4    _characteristics.TryGetValue(options.Characteristic, out var characteristic);
5    
6    	// convert C# byte[] data into NSData
7    var data = NSData.FromArray({your byte[] of data});
8    
9    // perform the write
10    Peripheral.WriteValue(data, characteristic, CBCharacteristicWriteType.WithResponse);
11    
12    var res = await CompletionSources.WriteCharacteristic.Task;
13}
14
15// delegate -- called when the write completed
16public override void WroteCharacteristicValue(CBPeripheral peripheral, CBCharacteristic characteristic, NSError? error) 
17{
18	if(error is not null)
19   	// error handling 
20        
21  CompletionSources.WriteCharacteristic.TrySetResult(WriteResult.Success);
22} 

Learn more about our software engineering capabilities!

Closing

As you can see, .NET MAUI CoreBluetooth isn’t so bad. I hope this tutorial was able to shed some light on the matter. If you are looking for a more intricate example, I have one up on my GitHub. You can view the source code here: https://github.com/mattsetaro/MauiCoreBluetooth

By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.