- name
- shiny-ble-hosting
- description
- Generate code using Shiny.BluetoothLE.Hosting, a BLE peripheral hosting library for .NET with GATT server, advertising, and L2CAP CoC channels
- auto_invoke
- true
- triggers
- ["ble hosting","ble peripheral","ble advertise","ble advertising","gatt server","gatt service","gatt characteristic","ble host","bluetooth hosting","bluetooth peripheral","bluetooth advertise","IBleHostingManager","IGattService","IGattServiceBuilder","IGattCharacteristic","IGattCharacteristicBuilder","AddBluetoothLeHosting","StartAdvertising","StopAdvertising","AdvertiseBeacon","AdvertisementOptions","CharacteristicSubscription","GattResult","GattState","WriteRequest","ReadRequest","WriteOptions","NotificationOptions","IPeripheral","MTU","mtu","BleConstants","[Truncated]"]
# 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:
1. **Imperative** โ inject `IBleHostingManager` and call `AddService(uuid, primary, sb => ...)`. Best for one-off or dynamically shaped services.
2. **Source generated** โ put `[BleService]` / `[L2CapService]` on a `partial class` and let the bundled generator emit the `AddService(...)` calls, the `IsReplyNeeded`/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 (`BleGattCharacteristic` base 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 `NotSupportedException` for advertising/GATT-server hosting; only the `OpenL2Cap` API is exposed and it also throws on Windows. **There is no tvOS target and there cannot be one** โ `CBMutableService` and `CBMutableCharacteristic` have 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
```bash
dotnet add package Shiny.BluetoothLE.Hosting
```
### 2. Register in MauiProgram.cs
```csharp
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:
```csharp
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:
```csharp
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.
```csharp
[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:
```csharp
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:
```csharp
[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
```csharp
// 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
```csharp
await hostingManager.AdvertiseBeacon(
uuid: Guid.Parse("12345678-1234-1234-1234-123456789abc"),
major: 1,
minor: 100,
txpower: -59
);
```
### 5. Sending Notifications
```csharp
// 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:
```csharp
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.
```csharp
[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`:
```csharp
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.
```csharp
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)`. The `secure` flag maps to encryption-required.
- **Android**: `BluetoothAdapter.ListenUsing[Insecure]L2capChannel`. Requires API 29+ โ throws `InvalidOperationException` on older versions.
- **Linux**: `AF_BLUETOOTH` / `BTPROTO_L2CAP` / `SOCK_SEQPACKET` socket via `Shiny.BluetoothLE.Hosting.Linux`. PSM is kernel-assigned from the LE dynamic range (โฅ `0x80`); `secure=true` maps to `BT_SECURITY_MEDIUM`, `secure=false` to `BT_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. `OpenL2Cap` throws `NotSupportedException`.
#### 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`.
```csharp
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:
```csharp
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);
}
}
);
View on GitHub