A cross-platform .NET MAUI Bluetooth Low Energy (BLE) library providing a clean, unified API for Android, iOS/MacCatalyst, and Windows platforms.
- π BLE Scanning - Discover nearby devices with customizable filtering
- π Connection Management - Robust connect/disconnect with auto-reconnect support
- π‘ GATT Operations - Full support for services, characteristics, and descriptors
- π Read/Write/Notify - Read values, write data, and subscribe to notifications
- π― Cross-Platform - Consistent API across all major platforms
- π Modern Async - Async/await throughout with cancellation token support
- π DI-First - Built for MAUI dependency injection
- π§© Options Pattern - Flexible configuration via options objects
- π Well-Documented - Comprehensive XML docs and examples
| Platform | Scanning | Connection | GATT Operations | Broadcasting |
|---|---|---|---|---|
| Android | β | β | β | β |
| iOS | β | β | β | β |
| MacCatalyst | β | β | β | β |
| Windows | β | β | β | β |
Note: Broadcasting (advertising and hosting local GATT services/characteristics/descriptors) is implemented on Android, Apple, and Windows. One known Windows limitation:
IBluetoothConnectedDevice.DisconnectAsync()throwsNotSupportedExceptionon Windows, because the WinRT GATT server API has no direct way to force-disconnect a subscribed central β it drops on its own once the central stops interacting or the app stops advertising.
dotnet add package Bluetooth.MauiOr via NuGet Package Manager:
<PackageReference Include="Bluetooth.Maui" Version="1.0.0" />public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit();
// Register Bluetooth services
builder.Services.AddBluetoothServices();
return builder.Build();
}
}public class ScannerViewModel
{
private readonly IBluetoothScanner _scanner;
public ScannerViewModel(IBluetoothScanner scanner)
{
_scanner = scanner;
// Subscribe to device discovery
_scanner.DeviceListChanged += OnDeviceListChanged;
}
public async Task StartScanningAsync()
{
var options = new ScanningOptions
{
// Optional: Configure scanning behavior
};
await _scanner.StartScanningAsync(options);
}
private void OnDeviceListChanged(object? sender, DeviceListChangedEventArgs e)
{
foreach (var device in _scanner.Devices)
{
Console.WriteLine($"Found: {device.Name} ({device.Id})");
Console.WriteLine($" RSSI: {device.SignalStrengthDbm} dBm");
}
}
}// Start scanning
await _scanner.StartScanningAsync();
// Access discovered devices
var devices = _scanner.Devices;
// Stop scanning when done
await _scanner.StopScanningAsync();// Get a device from the scanner
var device = _scanner.Devices.FirstOrDefault(d => d.Name == "MyDevice");
if (device != null)
{
// Connect with options
var connectionOptions = new ConnectionOptions
{
// Platform-specific connection parameters
};
await device.ConnectAsync(connectionOptions);
// Check connection status
Console.WriteLine($"Connected: {device.IsConnected}");
}The new exploration APIs use a single, flexible method with optional configuration:
// Simple exploration (defaults: services only, caching enabled)
await device.ExploreServicesAsync();
// Explore services AND characteristics
await device.ExploreServicesAsync(ServiceExplorationOptions.WithCharacteristics);
// Full exploration (services + characteristics + descriptors)
await device.ExploreServicesAsync(ServiceExplorationOptions.Full);
// Force re-exploration (ignore cache)
await device.ExploreServicesAsync(new ServiceExplorationOptions
{
UseCache = false
});
// Filter by service UUID
await device.ExploreServicesAsync(new ServiceExplorationOptions
{
ServiceUuidFilter = uuid => uuid == myServiceUuid
});// Get a specific service by UUID
var service = device.GetService(serviceGuid);
// Or use a filter
var service = device.GetService(s => s.Id == serviceGuid);
// Explore characteristics (simple)
await service.ExploreCharacteristicsAsync();
// Explore characteristics AND descriptors
await service.ExploreCharacteristicsAsync(CharacteristicExplorationOptions.Full);
// Get a characteristic
var characteristic = service.GetCharacteristic(characteristicGuid);// Read a characteristic value
var value = await characteristic.ReadValueAsync();
Console.WriteLine($"Value: {BitConverter.ToString(value.ToArray())}");
// Write a value
byte[] data = new byte[] { 0x01, 0x02, 0x03 };
await characteristic.WriteValueAsync(data);
// Check capabilities
if (characteristic.CanRead)
{
// Safe to read
}
if (characteristic.CanWrite)
{
// Safe to write
}// Subscribe to value changes
characteristic.ValueUpdated += (sender, args) =>
{
Console.WriteLine($"New value: {BitConverter.ToString(args.NewValue.ToArray())}");
Console.WriteLine($"Old value: {BitConverter.ToString(args.OldValue.ToArray())}");
};
// Start listening
await characteristic.StartListeningAsync();
// Check listening state
Console.WriteLine($"Listening: {characteristic.IsListening}");
// Stop listening when done
await characteristic.StopListeningAsync();// Explore descriptors
await characteristic.ExploreDescriptorsAsync();
// Get a specific descriptor
var descriptor = characteristic.GetDescriptor(descriptorGuid);
// Read descriptor value
var value = await descriptor.ReadValueAsync();
// Write descriptor value
await descriptor.WriteValueAsync(new byte[] { 0x01, 0x00 });Optimize connection parameters for your use case:
// High priority: Fast data transfer, low latency (11.25-15ms)
await device.RequestConnectionPriorityAsync(BluetoothConnectionPriority.High);
// Balanced: Moderate performance and power (30-50ms)
await device.RequestConnectionPriorityAsync(BluetoothConnectionPriority.Balanced);
// Low power: Battery optimization, higher latency (100-125ms)
await device.RequestConnectionPriorityAsync(BluetoothConnectionPriority.LowPower);Note: iOS, macOS, and Windows manage connection parameters automatically. This API is a no-op on those platforms.
All async operations support timeouts and cancellation:
using var cts = new CancellationTokenSource();
try
{
await device.ConnectAsync(
connectionOptions: new ConnectionOptions(),
timeout: TimeSpan.FromSeconds(10),
cancellationToken: cts.Token
);
}
catch (TimeoutException)
{
Console.WriteLine("Connection timed out");
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled");
}Subscribe to events for reactive programming:
// Scanner events
_scanner.RunningStateChanged += (s, e) => Console.WriteLine($"Scanning: {_scanner.IsRunning}");
_scanner.DeviceListChanged += OnDeviceListChanged;
// Device events
device.ConnectionStateChanged += (s, e) => Console.WriteLine($"State: {device.ConnectionState}");
device.Connected += (s, e) => Console.WriteLine("Connected");
device.Disconnected += (s, e) => Console.WriteLine("Disconnected");
device.UnexpectedDisconnection += (s, e) => Console.WriteLine($"Lost connection: {e.Exception}");
// Service events
service.CharacteristicListChanged += OnCharacteristicsChanged;
// Characteristic events
characteristic.ValueUpdated += OnValueUpdated;The exploration APIs use intelligent caching by default:
// First call: Queries the device
await device.ExploreServicesAsync(); // UseCache = true (default)
// Subsequent calls: Returns cached results instantly
await device.ExploreServicesAsync(); // Cached, no device query
// Force refresh: Ignore cache
await device.ExploreServicesAsync(new ServiceExplorationOptions
{
UseCache = false // Forces device query
});Proper cleanup ensures resources are released:
// Clear services (stops notifications, clears cache)
await device.ClearServicesAsync();
// Clear specific service characteristics
await service.ClearCharacteristicsAsync();
// Clear characteristic descriptors
await characteristic.ClearDescriptorsAsync();
// Disconnect and dispose device
await device.DisconnectAsync();
await device.DisposeAsync(); // Implements IAsyncDisposableAdd Bluetooth permissions to AndroidManifest.xml:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />Add Bluetooth usage description to Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app needs Bluetooth to scan for BLE devices</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app needs Bluetooth to scan for BLE devices</string>Add Bluetooth capability to Package.appxmanifest:
<Capabilities>
<DeviceCapability Name="bluetooth" />
</Capabilities>The solution is layered so that scanning (central role) and broadcasting (peripheral role)
stay parallel, independent concerns all the way from the interfaces down to the platform
implementations. Bluetooth.Maui is the only package published to NuGet β everything else
is an internal building block pulled in transitively.
flowchart TB
subgraph SAMPLES["Sample apps"]
direction LR
SAMPLE_SCAN["Sample.Scanner"]
SAMPLE_BCAST["Sample.Broadcaster"]
end
MAUI["π¦ Bluetooth.Maui<br/>facade + DI composition root<br/>AddBluetoothServices()<br/>β the only NuGet-published package β"]
subgraph PLATFORMS["Platform implementations β exactly one wired in per TargetFramework"]
direction LR
DROID["Platforms.Droid<br/>AndroidBluetooth*"]
APPLE["Platforms.Apple<br/>AppleBluetooth*"]
WIN["Platforms.Win<br/>WindowsBluetooth*"]
NETCORE["Platforms.DotNetCore<br/>(stub β throws PlatformNotSupportedException)"]
end
subgraph CORE_LAYER["Core layer β shared base implementations"]
direction LR
CORE_SCAN["Bluetooth.Core.Scanning<br/>BaseBluetoothScanner..."]
CORE_BCAST["Bluetooth.Core.Broadcasting<br/>BaseBluetoothBroadcaster..."]
CORE["Bluetooth.Core<br/>BaseBluetoothAdapter"]
end
subgraph ABSTRACTIONS["Abstractions layer β platform-agnostic contracts"]
direction LR
ABS_SCAN["Bluetooth.Abstractions.Scanning<br/>IBluetoothScanner, IBluetoothRemoteDevice/Service/Characteristic/Descriptor"]
ABS_BCAST["Bluetooth.Abstractions.Broadcasting<br/>IBluetoothBroadcaster, IBluetoothLocalService/Characteristic/Descriptor"]
ABS["Bluetooth.Abstractions<br/>IBluetoothAdapter"]
end
SAMPLE_SCAN --> MAUI
SAMPLE_BCAST --> MAUI
MAUI --> CORE_SCAN
MAUI --> CORE_BCAST
MAUI -. "TFM-conditional, pick exactly 1" .-> DROID
MAUI -. "TFM-conditional, pick exactly 1" .-> APPLE
MAUI -. "TFM-conditional, pick exactly 1" .-> WIN
MAUI -. "TFM-conditional, pick exactly 1" .-> NETCORE
DROID --> CORE_SCAN
DROID --> CORE_BCAST
APPLE --> CORE_SCAN
APPLE --> CORE_BCAST
WIN --> CORE_SCAN
WIN --> CORE_BCAST
NETCORE --> CORE_SCAN
NETCORE --> CORE_BCAST
CORE_SCAN --> CORE
CORE_SCAN --> ABS_SCAN
CORE_BCAST --> CORE
CORE_BCAST --> ABS_BCAST
CORE --> ABS
ABS_SCAN --> ABS
ABS_BCAST --> ABS
Notes:
- Arrows point from a project to the project(s) it depends on; transitive references
(e.g. every project's implicit dependency on
Bluetooth.Abstractions) are omitted for readability. Bluetooth.Mauidoesn't compile against all four platform projects at once β MSBuild conditions (IsForAndroid/IsForAppleStuff/IsForWindows/IsForPlainNetX) select exactly one perTargetFramework. At runtime,AddBluetoothServices()then registers theBluetooth.Mauifacade classes (BluetoothScanner/BluetoothBroadcaster) as the actualIBluetoothScanner/IBluetoothBroadcasterimplementations, wrapping the platform implementation in front of consumers (seeDocs/Architecture/ADR/0001-facade-overrides-platform-registrations.md).Bluetooth.Linuxexists as an empty placeholder folder for a future native Linux target; it has no project file yet and isn't part of the current build graph, so it's omitted above.- For deeper diagrams (class hierarchies, the DI registration tree, the async
TaskCompletionSourcecoordination flow, etc.), seeDocs/ARCHITECTURE_DIAGRAMS.md.
IBluetoothScanner- Device discovery and scanning controlIBluetoothRemoteDevice- Remote device representation and connectionIBluetoothRemoteService- GATT service on a remote deviceIBluetoothRemoteCharacteristic- GATT characteristic with read/write/notifyIBluetoothRemoteDescriptor- GATT descriptor
IBluetoothBroadcaster- Peripheral/advertising modeIBluetoothLocalService- Local GATT serviceIBluetoothLocalCharacteristic- Local characteristic for broadcastingIBluetoothConnectedDevice- Connected central device
Comprehensive exception hierarchy for error handling:
try
{
await device.ConnectAsync(connectionOptions);
}
catch (DeviceNotConnectedException ex)
{
// Device is not connected when operation requires it
}
catch (ServiceNotFoundException ex)
{
// Requested service not found on device
}
catch (CharacteristicNotFoundException ex)
{
// Requested characteristic not found in service
}
catch (TimeoutException ex)
{
// Operation timed out
}
catch (OperationCanceledException ex)
{
// Operation was cancelled
}
catch (BluetoothException ex)
{
// Base exception for all Bluetooth errors
}- Async First - All I/O operations are async with cancellation support
- Options Pattern - Flexible configuration via options objects
- Caching - Intelligent caching enabled by default for performance
- Events - Event-driven architecture for reactive patterns
- IAsyncDisposable - Proper resource cleanup with async disposal
- Immutability - ReadOnlyMemory for value types
- Platform Parity - Consistent API across all platforms
Contributions are welcome! Please:
- Ensure all public APIs have XML documentation
- Follow the existing code style and patterns
- Add unit tests for new features
- Update the README for API changes
- .NET 10.0 or higher
- .NET MAUI application
- Platform-specific Bluetooth permissions (see setup section)
MIT License - Copyright (c) 2025 Laerdal Medical
See LICENSE.md for details.
For issues, feature requests, or questions:
- π GitHub Issues
- π¬ Discussions
v1.0.0 (Current)
- β Windows platform implementation complete
- β Simplified exploration APIs (single method with options)
- β
Modern DI registration with
AddBluetoothServices() - β Comprehensive XML documentation
- β Options pattern for all configuration
Built with β€οΈ by Laerdal Medical