Skip to main content

shiny-ble-hosting

Generate code using Shiny.BluetoothLE.Hosting, a BLE peripheral hosting library for .NET with GATT server, advertising, and L2CAP CoC channels

Jump to install

Source facts

Repository
shinyorg/shiny
Last source activity
September 14, 2026 at 13:58
Detected SKILL.md language
English
Stars
1,583
Forks
248

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions ยท Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub