Shiny.BluetoothLE.Hosting Skill
You are an expert in Shiny.BluetoothLE.Hosting, a .NET library for turning a device into a BLE peripheral. It provides a GATT server, BLE advertising, iBeacon broadcasting, and L2CAP CoC channels through the imperative IBleHostingManager API.
There are two ways to expose a GATT service, and both compile down to the same thing:
- Imperative — inject
IBleHostingManagerand callAddService(uuid, primary, sb => ...). Best for one-off or dynamically shaped services. - Source generated — put
[BleService]/[L2CapService]on apartial classand let the bundled generator emit theAddService(...)calls, theIsReplyNeeded/offset handling, the notify push API, and the DI registration. Prefer this for anything with more than a characteristic or two.
The old reflection-based managed pattern (
BleGattCharacteristicbase class,[BleGattCharacteristic]attribute,AddBleHostedCharacteristic<T>,AttachRegisteredServices) was removed for AOT compliance. The source generator replaces it and emits no reflection. Never generate code against those types.
When to Use This Skill
Invoke this skill when the user wants to:
- Set up a BLE GATT server on a device (iOS, macOS, Mac Catalyst, Android, Linux)
- Advertise as a BLE peripheral with custom service UUIDs or a local name
- Broadcast as an iBeacon
- Create GATT services with read, write, and notify characteristics
- Handle read requests from connected centrals
- Handle write requests from connected centrals
- Send notifications or indications to subscribed centrals
- Configure characteristic properties (read, write, notify, indicate, encryption)
- React to central subscribe/unsubscribe events
- Build a MAUI app that acts as a BLE peripheral
- Publish an L2CAP PSM for centrals to open streaming channels against (iOS/macOS, Android API 29+, Linux)
- Serve file uploads/downloads to connected centrals over L2CAP, with progress and throughput metrics
- Declare a GATT service or L2CAP listener with attributes on a partial class instead of builder lambdas
- Keep per-connected-central state (a SignalR-style context) across requests on a hosted service
Library Overview
- NuGet:
Shiny.BluetoothLE.Hosting(Android, iOS/macOS, Mac Catalyst, Windows stub),Shiny.BluetoothLE.Hosting.Linux(Linux via BlueZ) - Namespaces:
Shiny.BluetoothLE.Hosting - Platforms: iOS, Mac Catalyst, macOS (CoreBluetooth), Android, Linux (BlueZ). Windows throws
NotSupportedExceptionfor advertising/GATT-server hosting; only theOpenL2CapAPI is exposed and it also throws on Windows. There is no tvOS target and there cannot be one —CBMutableServiceandCBMutableCharacteristichave no constructors on tvOS, which is Apple's way of saying an Apple TV cannot act as a GATT peripheral. If asked to build a GATT server or advertise from tvOS, say it is impossible rather than generating code; the central role (Shiny.BluetoothLE) does support tvOS. - Dependencies:
Shiny.Core,Shiny.BluetoothLE.Common
Inject IBleHostingManager and call AddService(uuid, primary, builder) to register a GATT service inline, or declare it with [BleService] on a partial class and let the bundled source generator emit that call. The generator ships inside the same package under analyzers/dotnet/cs - no extra PackageReference needed.
Setup
1. Install NuGet Package
dotnet add package Shiny.BluetoothLE.Hosting
2. Register in MauiProgram.cs
builder.Services.AddBluetoothLeHosting();
Code Generation Instructions
When generating code for Shiny.BluetoothLE.Hosting projects, follow these conventions:
1. Requesting Access
Always request access before advertising or adding services:
var access = await hostingManager.RequestAccess();
if (access != AccessState.Available)
{
// Handle denied/disabled/not supported
return;
}
2. Imperative GATT Service Setup
Use the builder pattern to add services and characteristics inline:
var service = await hostingManager.AddService("12345678-1234-1234-1234-123456789abc", true, sb =>
{
sb.AddCharacteristic("12345678-1234-1234-1234-123456789ab1", cb =>
{
cb.SetRead(request =>
{
var data = System.Text.Encoding.UTF8.GetBytes("Hello");
return Task.FromResult(GattResult.Success(data));
});
cb.SetWrite(request =>
{
var received = request.Data;
if (request.IsReplyNeeded)
request.Respond(GattState.Success);
return Task.CompletedTask;
}, WriteOptions.Write);
cb.SetNotification(sub =>
{
// sub.IsSubscribing tells you if subscribing or unsubscribing
// sub.Peripheral is the central device
return Task.CompletedTask;
}, NotificationOptions.Notify);
});
});
2b. Source-Generated GATT Service
Put the attributes on a partial class. The generator emits the AddService(...) call, the
GattResult wrapping, the IsReplyNeeded/Respond handling, the notify push API, and the DI
registration. Every UUID is normalized to the full 128-bit form.
[BleService("180D", Advertise = true, Name = "HeartRate")]
public partial class HeartRateService(IHeartRateSensor sensor)
{
// byte[] is wrapped in GattResult.Success; return GattResult to pick the status yourself
[ReadCharacteristic("2A37")]
Task<byte[]> ReadMeasurement(HeartRateServiceContext context)
=> Task.FromResult(new byte[] { 0x00, sensor.Read(context.User) });
// the hook is optional - NotifyMeasurement / MeasurementSubscribers / HasMeasurementSubscribers
// are generated either way. Put [NotifyCharacteristic] on the class (with Name) to skip the hook
[NotifyCharacteristic("2A37", Name = "Measurement", Indicate = true)]
Task OnMeasurementSubscription(BleSubscription subscription, HeartRateServiceContext context)
=> Task.CompletedTask;
// returning GattState responds that value, and only when the central asked for a reply.
// returning void/Task responds Success, or Failure if the handler throws
[WriteCharacteristic("2A39")]
Task<GattState> ControlPoint(byte[] data, int offset, HeartRateServiceContext context)
=> Task.FromResult(offset == 0 ? GattState.Success : GattState.InvalidOffset);
// write + notify - the result is pushed back to the writing central, which must be subscribed
[RequestResponseCharacteristic("2A3B", Name = "Command")]
Task<byte[]> Exchange(byte[] request, CancellationToken cancellationToken) => Handle(request);
// opt-in hooks; the compiler drops the generated call when you do not implement them
partial void OnBleHandlerError(string characteristicUuid, Exception ex) => Log(ex);
}
// your half of the generated context - one instance per connected central, held across requests
public partial class HeartRateServiceContext
{
public AuthUser? User { get; set; }
}
Handler parameters bind by type, in any order, any subset - none are required. See
reference/api-reference.md for the full binding table and the SBH001-SBH014 diagnostics.
Wire it up:
builder.Services.AddBluetoothLeHosting();
builder.Services.AddBleHostedServices(); // generated
await using var session = await hostingManager.AttachBleHostedServices(serviceProvider);
await hostingManager.StartBleHostedAdvertising("MyDevice");
An [L2CapService] class works the same way - one [OnChannelOpened] handler per accepted central,
and PsmService/PsmCharacteristic publish the assigned PSM as a read characteristic so centrals
can discover it:
[L2CapService(Secure = false, PsmService = "180D", PsmCharacteristic = "2ABC", Name = "EchoStream")]
public partial class StreamService
{
[OnChannelOpened]
async Task Echo(L2CapChannel channel, BleL2CapContext context, CancellationToken cancellationToken)
{
await foreach (var buffer in channel.ReadAll(cancellationToken))
await channel.Write(buffer).ToTask(cancellationToken);
}
}
3. Advertising
// Advertise with local name and service UUIDs
await hostingManager.StartAdvertising(new AdvertisementOptions(
LocalName: "MyDevice",
ServiceUuids: "12345678-1234-1234-1234-123456789abc"
));
// Advertise with defaults (no name, no service UUIDs)
await hostingManager.StartAdvertising();
// Stop advertising
hostingManager.StopAdvertising();
4. iBeacon Broadcasting
await hostingManager.AdvertiseBeacon(
uuid: Guid.Parse("12345678-1234-1234-1234-123456789abc"),
major: 1,
minor: 100,
txpower: -59
);
5. Sending Notifications
// From an IGattCharacteristic reference
var data = System.Text.Encoding.UTF8.GetBytes("Updated value");
// Notify all subscribed centrals
await characteristic.Notify(data);
// Notify specific centrals
await characteristic.Notify(data, specificPeripheral1, specificPeripheral2);
IPeripheral.Mtu (and BleServiceContext.Mtu) is the usable payload -- the negotiated ATT MTU
already minus the 3-byte ATT header. Cap a notification at peripheral.Mtu directly; do not
subtract the header again. Anything larger is silently truncated by the platform.
On iOS, Mac Catalyst and macOS Notify applies CoreBluetooth's back-pressure: when the transmit queue
is full it waits for peripheralManagerIsReadyToUpdateSubscribers and retries, so the task completes
only once the value is actually queued. Await each Notify before sending the next one -- do not fire
many in parallel with Task.WhenAll, and do not add your own delay or retry loop around it.
Pass a CancellationToken to bound that wait - it goes before the params centrals:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await characteristic.Notify(data, cts.Token); // all subscribers
await characteristic.Notify(data, cts.Token, context.Peripheral); // one central
If Bluetooth powers off while an Apple Notify is waiting, the task faults with InvalidOperationException.
Generated [BleService] classes get a matching NotifyX(data, cancellationToken, params centrals) overload,
and generated request/response replies already pass BleHostToken. An empty centrals list means every
subscriber on all platforms; a named list goes only to those centrals. SubscribedCentrals is tracked whether
or not SetNotification was given a subscribe hook, so never register a no-op hook just to populate it.
Android paces Notify per central - it waits for onNotificationSent before sending that central the next
value and throws if Android refuses the notification or reports a failed status. Do not add delays between
notifications. A central that enabled indications receives indications.
Linux (BlueZ) has two limits the code you generate must respect. BlueZ only tells the app whether any
central has notifications enabled, so while one is subscribed every connected central appears in
SubscribedCentrals. And every Notify reaches all subscribed centrals - the centrals argument cannot narrow
it (the send is skipped only when none of the named centrals is subscribed). Never put per-central data on a
notify characteristic that several centrals subscribe to on Linux; generated request/response replies fan out
to every subscriber there. Adding or removing a service re-registers the whole GATT application with BlueZ.
[Notify]
public async Task Push(BleServiceContext context, byte[] payload)
{
var max = context.Mtu; // NOT context.Mtu - 3
await this.Characteristic.Notify(payload.Take(max).ToArray(), context.Peripheral);
}
6. Responding to Write Requests
When WriteRequest.IsReplyNeeded is true, you must call Respond:
cb.SetWrite(request =>
{
try
{
// Process data
if (request.IsReplyNeeded)
request.Respond(GattState.Success);
}
catch
{
if (request.IsReplyNeeded)
request.Respond(GattState.Failure);
}
return Task.CompletedTask;
}, WriteOptions.Write);
7. L2CAP Channels
Publish an L2CAP PSM that centrals can connect to for streaming data without going through GATT. OpenL2Cap returns an L2CapInstance representing the listener; the onOpen callback fires for every accepted central connection. Each L2CapChannel is itself an IDisposable — dispose it to close that specific central's channel; dispose the L2CapInstance to stop accepting new connections and release the PSM.
using System.Reactive.Threading.Tasks;
using Shiny.BluetoothLE;
using Shiny.BluetoothLE.Hosting;
var instance = await hostingManager.OpenL2Cap(
secure: false,
onOpen: channel =>
{
Console.WriteLine($"Central {channel.Identifier} connected on PSM {channel.Psm}");
channel.DataReceived.Subscribe(
async payload =>
{
// Echo back
await channel.Write(payload).ToTask();
},
ex => Console.WriteLine($"Channel error: {ex.Message}"),
() => channel.Dispose()
);
}
);
Console.WriteLine($"Listening on PSM {instance.Psm}");
// Later, when shutting down:
instance.Dispose();
The platform-assigned PSM is on instance.Psm — advertise it to centrals out-of-band (typically through a GATT characteristic exposed by your service).
Platform notes:
- iOS / Mac Catalyst / macOS:
CBPeripheralManager.PublishL2CapChannel(encryptionRequired). Thesecureflag maps to encryption-required. - Android:
BluetoothAdapter.ListenUsing[Insecure]L2capChannel. Requires API 29+ — throwsInvalidOperationExceptionon older versions. - Linux:
AF_BLUETOOTH/BTPROTO_L2CAP/SOCK_SEQPACKETsocket viaShiny.BluetoothLE.Hosting.Linux. PSM is kernel-assigned from the LE dynamic range (≥0x80);secure=truemaps toBT_SECURITY_MEDIUM,secure=falsetoBT_SECURITY_LOW. Independent of GATT-server / LE-advertisement hosting (still WIP on Linux) — centrals must learn the device address out-of-band. - Windows / Blazor WASM: not supported.
OpenL2CapthrowsNotSupportedException.
File Transfer (serving uploads & downloads)
OpenL2CapFileServer(...) publishes a PSM backed by a directory: connected centrals can push files to
it and pull files from it, using IPeripheral.UploadFile / IPeripheral.DownloadFile on the client
side (see the shiny-bluetoothle skill). This is the API to reach for — do not hand-roll a
protocol over DataReceived.
using Shiny.BluetoothLE;
using Shiny.BluetoothLE.Hosting;
var instance = await hostingManager.OpenL2CapFileServer(
rootDirectory: Path.Combine(FileSystem.AppDataDirectory, "ble-share"),
secure: false,
configure: o =>
{
o.AllowUploads = true;
o.AllowDownloads = true;
o.MaxUploadSize = 10 * 1024 * 1024; // refused as TooLarge before any body byte moves
o.OverwriteExistingUploads = false;
o.Authorize = req => req.FileName.EndsWith(".bin");
o.OnProgress = e => Console.WriteLine($"{e.PeerIdentifier} {e.FileName} {e.Progress.PercentComplete:P0}");
o.OnCompleted = r => Console.WriteLine($"{r.LocalFilePath} <- {r.Result.BytesTransferred} bytes in {r.Result.Elapsed}");
o.OnError = (req, ex) => Console.WriteLine($"{req?.FileName}: {ex.Message}");
}
);
Console.WriteLine($"File server on PSM {instance.Psm}");
instance.Dispose(); // unpublish and drop connected peers
Peer-supplied file names are resolved under RootDirectory; absolute paths and anything traversing
out (../) are refused with NotPermitted and never touch the filesystem.
For anything the directory server does not cover, handle requests yourself — this is also how you serve from a database, generate content on the fly, or route by peer:
var instance = await hostingManager.HandleL2CapRequests(
secure: false,
onRequest: async (request, ct) =>
{
// request.Type (Upload/Download), .FileName, .Size, .PeerIdentifier, .Psm
if (request.Type == L2CapTransferType.Download && request.FileName == "config.json")
{
var bytes = Encoding.UTF8.GetBytes(BuildConfigJson());
await request.AcceptDownload(new MemoryStream(bytes), bytes.Length, cancellationToken: ct);
}
else if (request.Type == L2CapTransferType.Upload && request.Size < 1_000_000)
{
await request.AcceptUpload(Path.Combine(inbox, Guid.NewGuid() + ".bin"), cancellationToken: ct);
}
else
{
await request.Reject(L2CapTransferError.NotPermitted, "nope", ct);
}
}
);
Every request must be answered with an accept or Reject before returning; the peer is blocked waiting
on the answer. Requests are served one at a time per channel. A refusal keeps the channel alive for the
next request.
Progress on the hosting side uses the same TransferProgress shape as the client
(PercentComplete, BytesPerSecond, BytesTransferred, BytesToTransfer, EstimatedTimeRemaining).
Raw streaming: channel.SendFile(...) remains available as the protocol-less primitive — bytes with
progress, no handshake, receiver must already know the length and framing. Use the file server unless
you are talking to a non-Shiny central.
8. File Organization
- Group hosting services in a
BleHosting/folder, one class per GATT service - Or by feature:
Features/{Feature}/{Name}HostingService.cs
Namespace Ambiguities
IPeripheral: BothShiny.BluetoothLE(client) andShiny.BluetoothLE.Hostingdefine anIPeripheralinterface with different members. If both packages are referenced in the same project, do NOT add both namespaces as global usings. Use file-levelusingdirectives or FQN (Shiny.BluetoothLE.Hosting.IPeripheral) to disambiguate.
Best Practices
- Always request access first -- call
RequestAccess()and check the result before any hosting operations, so a denied permission or a switched-off adapter reaches the user rather than surfacing as a thrownInvalidOperationExceptiondeeper in. On Apple platforms it is no longer required for correctness: as of 5.6,AddService(...)andStartAdvertising(...)wait out theCBPeripheralManagerpower-on handshake themselves. Before that they issued the native call against a manager still reportingUnknown, which CoreBluetooth drops without ever invoking the completion delegate both methods await -- so a call made at app startup hung indefinitely instead of failing. Do not work around it with your ownawait RequestAccess()retry loop or aTask.Delaybefore advertising - Reach for
[BleService]first -- the generator emits the same builder calls plus the response/offset handling, subscriber tracking, and per-central context. Fall back toAddService(uuid, primary, sb => ...)lambdas when the service shape is only known at runtime - Always write the full 128-bit UUID when calling
AddServiceby hand -- short forms like"180D"work on Apple (CBUUID.FromString) but throw on Android (java.util.UUID.fromString). The generator normalizes for you; the imperative API does not - Respond to writes when needed -- always check
WriteRequest.IsReplyNeededand callRespondwith the appropriateGattState - Return GattResult.Error on failures -- use
GattResult.Error(GattState.Failure)in read handlers when an error occurs - Stop advertising before cleanup -- call
StopAdvertising()andClearServices()when done - Check IsAdvertising -- avoid calling
StartAdvertisingif already advertising - Dispose
L2CapInstanceand per-centralL2CapChannels explicitly -- disposing the instance closes the listener but does not auto-close already-open channels. With[L2CapService]the generator disposes each channel when the handler returns, and theBleHostedServiceSessioncloses the listener - Keep the
BleHostedServiceSessionalive --AttachBleHostedServicesreturns it, and disposing it cancels in-flight handlers, closes L2CAP listeners, and removes the GATT services - Size notifications with
Mtuas-is --IPeripheral.Mtu/BleServiceContext.Mtuis the payload (ATT MTU minusBleConstants.AttHeaderSize), not the ATT MTU. Add the header back with+ BleConstants.AttHeaderSizeonly when feeding an API that genuinely wants an ATT MTU - Never register the same service UUID twice --
BleHostingManagerkeys services by UUID. Several[BleService]classes may share one UUID; the generator merges them into a singleAddServicecall
Reference Files
For detailed API signatures and examples, see:
reference/api-reference.md- Full API surface, interfaces, enums, records, and usage examples