Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

GATT Service

GATT services define the data your BLE peripheral exposes. Each service contains characteristics that centrals can read from, write to, or subscribe to for notifications.

Use the builder pattern to create services dynamically.

IBleHostingManager hostingManager; // injected
var service = await hostingManager.AddService(
"Your-Service-UUID",
true, // primary service
sb =>
{
sb.AddCharacteristic("char-uuid-1", cb =>
{
cb.SetRead(request =>
{
var data = System.Text.Encoding.UTF8.GetBytes("Hello");
return Task.FromResult(GattResult.Success(data));
});
cb.SetWrite(request =>
{
var receivedData = request.Data;
Console.WriteLine($"Received: {System.Text.Encoding.UTF8.GetString(receivedData)}");
return Task.CompletedTask;
});
cb.SetNotification((subscription) =>
{
Console.WriteLine(subscription.IsSubscribing
? "Central subscribed"
: "Central unsubscribed");
return Task.CompletedTask;
});
});
}
);

Return a GattResult with your data or an error status.

cb.SetRead(request =>
{
// request.Peripheral - the connecting central
// request.Offset - data offset
var data = GetYourData();
return Task.FromResult(GattResult.Success(data));
}, encrypted: false);

Handle incoming writes from centrals.

cb.SetWrite(request =>
{
// request.Data - the written bytes
// request.Peripheral - the connecting central
// request.IsReplyNeeded - whether a response is expected
// request.Respond(GattState) - send response if needed
if (request.IsReplyNeeded)
request.Respond(GattState.Success);
return Task.CompletedTask;
}, WriteOptions.Write);

WriteOptions flags: Write, WriteWithoutResponse, AuthenticatedSignedWrites, EncryptionRequired

Send data to subscribed centrals.

// After setting up notification on a characteristic:
var characteristic = service.Characteristics.First();
// Notify all subscribed centrals
await characteristic.Notify(data);
// Notify specific centrals
await characteristic.Notify(data, central1, central2);
// Check who is subscribed
var subscribers = characteristic.SubscribedCentrals;

On iOS, Mac Catalyst and macOS, Notify applies CoreBluetooth’s back-pressure: when the transmit queue is full it waits until the queue drains and retries, so the returned task completes once the value is actually queued. Await it before sending the next notification rather than firing many in parallel.

Pass a CancellationToken to stop waiting - it goes before the centrals. If Bluetooth powers off while a notification is waiting, the task faults with InvalidOperationException.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await characteristic.Notify(data, cts.Token);
await characteristic.Notify(data, cts.Token, central1, central2);

An empty centrals list sends to every subscriber; a named list sends only to those centrals. SubscribedCentrals is tracked whether or not you pass a subscribe hook to SetNotification.

NotificationOptions flags: Notify, Indicate, EncryptionRequired

// Remove a specific service
hostingManager.RemoveService("Your-Service-UUID");
// Clear all services
hostingManager.ClearServices();
// List active services
var services = hostingManager.Services;

The reflection-based managed characteristic pattern (BleGattCharacteristic base + [BleGattCharacteristic] attribute + AttachRegisteredServices) was removed for AOT compliance. You have two replacements:

  • Declare the service with [BleService] on a partial class and let the source generator emit these calls for you — attribute ergonomics with no reflection. This is the shorter path for anything beyond a characteristic or two.
  • Or compose the service in code by registering a hosting class that calls AddService(...) on startup:
public class MyGattHostingService(IBleHostingManager manager, ILogger<MyGattHostingService> logger) : IShinyStartupTask
{
public async void Start()
{
var access = await manager.RequestAccess(advertise: true, connect: true);
if (access != AccessState.Available)
return;
await manager.AddService("Your-Service-UUID", primary: true, sb =>
{
sb.AddCharacteristic("Your-Characteristic-UUID", cb =>
{
cb.SetRead(req =>
{
var data = System.Text.Encoding.UTF8.GetBytes("Hello");
return Task.FromResult(GattResult.Success(data));
});
cb.SetWrite(req =>
{
logger.LogInformation("Received {Bytes} bytes", req.Data.Length);
if (req.IsReplyNeeded)
req.Respond(GattState.Success);
return Task.CompletedTask;
}, WriteOptions.Write);
cb.SetNotification(sub =>
{
logger.LogInformation("Subscription change: {Sub}", sub.IsSubscribing);
return Task.CompletedTask;
}, NotificationOptions.Notify);
});
});
await manager.StartAdvertising(new AdvertisementOptions("MyDevice", "Your-Service-UUID"));
}
}

Register it like any other Shiny service:

services.AddBluetoothLeHosting();
services.AddSingleton<IShinyStartupTask, MyGattHostingService>();

This keeps everything testable (the class can be exercised against a mocked IBleHostingManager), preserves AOT-cleanliness, and avoids the runtime attribute scanning the old pattern relied on. The source generator produces the same shape from attributes, and additionally hands you a per-connected-central context, generated notify helpers, and compile-time checks on your UUIDs and handler signatures.