| 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:
- Imperative — inject
IBleHostingManager and call AddService(uuid, primary, sb => ...). Best for one-off or dynamically shaped services.
- 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
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)
{
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 =>
{
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)
{
[ReadCharacteristic("2A37")]
Task<byte[]> ReadMeasurement(HeartRateServiceContext context)
=> Task.FromResult(new byte[] { 0x00, sensor.Read(context.User) });
[NotifyCharacteristic("2A37", Name = "Measurement", Indicate = true)]
Task OnMeasurementSubscription(BleSubscription subscription, HeartRateServiceContext context)
=> Task.CompletedTask;
[WriteCharacteristic("2A39")]
Task<GattState> ControlPoint(byte[] data, int offset, HeartRateServiceContext context)
=> Task.FromResult(offset == 0 ? GattState.Success : GattState.InvalidOffset);
[RequestResponseCharacteristic("2A3B", Name = "Command")]
Task<byte[]> Exchange(byte[] request, CancellationToken cancellationToken) => Handle(request);
partial void OnBleHandlerError(string characteristicUuid, Exception ex) => Log(ex);
}
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();
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
await hostingManager.StartAdvertising(new AdvertisementOptions(
LocalName: "MyDevice",
ServiceUuids: "12345678-1234-1234-1234-123456789abc"
));
await hostingManager.StartAdvertising();
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
var data = System.Text.Encoding.UTF8.GetBytes("Updated value");
await characteristic.Notify(data);
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.
[Notify]
public async Task Push(BleServiceContext context, byte[] payload)
{
var max = context.Mtu;
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
{
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 =>
{
await channel.Write(payload).ToTask();
},
ex => Console.WriteLine($"Channel error: {ex.Message}"),
() => channel.Dispose()
);
}
);
Console.WriteLine($"Listening on PSM {instance.Psm}");
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.
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;
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();
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) =>
{
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: Both Shiny.BluetoothLE (client) and Shiny.BluetoothLE.Hosting define an IPeripheral interface with different members. If both packages are referenced in the same project, do NOT add both namespaces as global usings. Use file-level using directives or FQN (Shiny.BluetoothLE.Hosting.IPeripheral) to disambiguate.
Best Practices
- Always request access first -- call
RequestAccess() and check the result before any hosting operations
- Reach for
[BleService] first -- the generator emits the same builder calls plus the response/offset handling, subscriber tracking, and per-central context. Fall back to AddService(uuid, primary, sb => ...) lambdas when the service shape is only known at runtime
- Always write the full 128-bit UUID when calling
AddService by 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.IsReplyNeeded and call Respond with the appropriate GattState
- Return GattResult.Error on failures -- use
GattResult.Error(GattState.Failure) in read handlers when an error occurs
- Stop advertising before cleanup -- call
StopAdvertising() and ClearServices() when done
- Check IsAdvertising -- avoid calling
StartAdvertising if already advertising
- Dispose
L2CapInstance and per-central L2CapChannels 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 the BleHostedServiceSession closes the listener
- Keep the
BleHostedServiceSession alive -- AttachBleHostedServices returns it, and disposing it cancels in-flight handlers, closes L2CAP listeners, and removes the GATT services
- Size notifications with
Mtu as-is -- IPeripheral.Mtu/BleServiceContext.Mtu is the payload (ATT MTU minus BleConstants.AttHeaderSize), not the ATT MTU. Add the header back with + BleConstants.AttHeaderSize only when feeding an API that genuinely wants an ATT MTU
- Never register the same service UUID twice --
BleHostingManager keys services by UUID. Several [BleService] classes may share one UUID; the generator merges them into a single AddService call
Reference Files
For detailed API signatures and examples, see:
reference/api-reference.md - Full API surface, interfaces, enums, records, and usage examples