用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/shinyorg/skills --skill shiny-bluetoothle命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
正在显示 SKILL.md
Generate .NET MAUI Shell pages, ViewModels, navigation, and source-generated routes using Shiny MAUI Shell
iBeacon and Eddystone ranging, background region monitoring, and broadcasting for .NET MAUI, iOS, Android, macOS, Windows, Linux and Blazor using Shiny.Beacons
Generate code using Shiny.BluetoothLE.Hosting, a BLE peripheral hosting library for .NET with GATT server, advertising, and L2CAP CoC channels
| name | shiny-bluetoothle |
| description | Shiny BluetoothLE client/central operations for scanning, connecting, and communicating with BLE peripherals |
| auto_invoke | true |
| triggers | ["bluetooth","ble","bluetoothle","bluetooth le","bluetooth low energy","peripheral","gatt","characteristic","scan ble","ble scan","ble connect","IBleManager","IPeripheral","MTU","mtu","RequestMtu","TryRequestMtu","TryRequestMtuAsync","ICanRequestMtu","BleConstants","AutoConnect","ConnectionConfig","auto reconnect","OnAdapterStateChanged","IBleDelegate","adapter state","bluetooth off","AttHeaderSize","managed scan","ble notification","ble write","ble read","[Truncated]"] |
Use this skill when the user needs to:
Do NOT use this skill for BLE hosting/peripheral mode (advertising, GATT server). That is a separate library (Shiny.BluetoothLE.Hosting).
Shiny.BluetoothLE (Android, iOS/tvOS/macOS, Windows), Shiny.BluetoothLE.Linux (Linux via BlueZ), Shiny.BluetoothLE.Blazor (Blazor WebAssembly via Web Bluetooth API)Shiny.BluetoothLEShiny.BluetoothLE.ManagedtvOS runs the same CoreBluetooth central implementation as iOS — scanning, connecting, GATT and L2CAP are identical and there is no tvOS-specific code to write. Two Apple limits to encode in any guidance you generate:
bluetooth-central background mode. Scans and connections end when the app suspends, and setting AppleBleConfiguration.RestoreIdentifier accomplishes nothing — do not suggest it for tvOS.Shiny.BluetoothLE.Hosting has no tvOS target; CBMutableService/CBMutableCharacteristic carry no constructors there. If asked to build a GATT server on tvOS, say it is not possible rather than producing code that cannot compile.Also worth mentioning when relevant: the Siri Remote is itself a BLE device, so an Apple TV has fewer simultaneous connections to spare than an iPhone.
The Blazor implementation is built on the browser's Web Bluetooth API and inherits its limitations:
http://localhost required. The API is unavailable on plain http://.chrome://flags/#enable-web-bluetooth (or edge://flags, etc.) → Enabled → restart. Linux also needs experimental-web-platform-features on and BlueZ 5.43+.internet://flags → Web Bluetooth.Register in your MauiProgram.cs or host builder:
// Basic registration
services.AddBluetoothLE();
// With a delegate for background events (adapter state changes, peripheral connections)
services.AddBluetoothLE<MyBleDelegate>();
// iOS/macOS only - with Apple-specific configuration
services.AddBluetoothLE<MyBleDelegate>(new AppleBleConfiguration(
ShowPowerAlert: true,
RestoreIdentifier: "my-ble-app"
));
The delegate class:
public class MyBleDelegate : BleDelegate
{
public override Task OnAdapterStateChanged(AccessState state)
{
// Handle adapter state changes (foreground or background)
return Task.CompletedTask;
}
public override Task OnPeripheralStateChanged(IPeripheral peripheral)
{
// Handle peripheral connection state changes (foreground or background)
return Task.CompletedTask;
}
}
Add the BLE permissions to Platforms/Android/AndroidManifest.xml. Critical: on Android 12+ (API 31+) Shiny requests only BLUETOOTH_SCAN / BLUETOOTH_CONNECT at runtime — it does NOT request ACCESS_FINE_LOCATION. If you declare BLUETOOTH_SCAN without the neverForLocation flag, Android silently withholds all scan results unless fine location is also granted, so scans appear to return nothing. Unless your app actually derives physical location from BLE, always add neverForLocation:
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
If you DO use BLE to infer location, omit neverForLocation and also request/grant ACCESS_FINE_LOCATION at runtime.
Scans discover both legacy and Bluetooth 5 extended advertisements automatically (when the chipset supports extended advertising); legacy advertisements that virtually all peripherals send are always included. To force a legacy-only scan, use new AndroidScanConfig(IncludeExtendedAdvertisements: false).
When generating BLE client code, follow these conventions:
Always request access before scanning: Call IBleManager.RequestAccess() or RequestAccessAsync() and verify AccessState.Available before starting a scan - it is how a denied permission or a switched-off adapter reaches the user. It is no longer load-bearing for the scan itself: as of 5.6 a Scan() issued before the Apple central has finished powering on is parked and started automatically (see #12), rather than being silently dropped.
Use reactive (IObservable) APIs as the primary pattern: The library is built on System.Reactive. Use the Async extension methods only when you need Task-based patterns.
Dispose scan subscriptions: Only one scan can be active at a time. Always dispose the scan subscription or call StopScan() when done - either one releases the slot. Never track "am I scanning" with your own flag; read IBleManager.IsScanning, which reports whether the native scan is running (it is false while a scan is parked waiting on the adapter).
Use string-based UUIDs for services and characteristics: The API uses string UUIDs throughout (e.g., "180D" or "0000180d-0000-1000-8000-00805f9b34fb").
Prefer ConnectAsync for simple connection flows: It handles waiting for the connected state and has a default 30-second timeout.
Always call CancelConnection() or DisconnectAsync() when done: Connections are not automatically cleaned up.
Use IManagedScan for UI-bound scanning: It provides an INotifyReadOnlyCollection that works with MVVM bindings and handles peripheral deduplication, buffering, and stale removal.
Feature detection via interface checks: Optional capabilities (MTU request, pairing, reliable transactions) use feature interfaces. Always use the Try* or Can* extension methods rather than casting directly.
8a. IPeripheral.Mtu is the usable payload, not the ATT MTU: It is already the negotiated ATT MTU minus the 3-byte ATT header (BleConstants.AttHeaderSize), so fragment writes to peripheral.Mtu directly — never write peripheral.Mtu - 3. The units are asymmetric across a single call: TryRequestMtu(512) passes 512 as an ATT MTU to the platform but emits 509, the payload. When handing a value to an API that genuinely wants an ATT MTU, add the header back with peripheral.Mtu + BleConstants.AttHeaderSize.
Handle BleException and BleOperationException: GATT operations can throw these. BleOperationException includes a GattStatusCode. An in-flight operation that is interrupted by a disconnect faults with a BleException rather than hanging, so always have an onError handler (or catch) on read/write/discovery calls — with auto-reconnect enabled, retry once the peripheral reports Connected again.
Connection auto-reconnect: ConnectionConfig.AutoConnect = true (default) reconnects the peripheral after a dropped link or a power cycle on every platform — never write your own WhenDisconnected().Subscribe(_ => peripheral.Connect()) loop on top of it, the two fight each other. Set AutoConnect = false for a faster initial connection when you intend to own reconnecting. CancelConnection() disposes the auto-reconnect, so a deliberate disconnect stays disconnected; call Connect() again to re-arm it. Auto-reconnect restores the link only — re-run per-connection setup (MTU request, authentication handshake, reading a config characteristic) from WhenConnected(), not once after the first ConnectAsync().
The user toggling Bluetooth off/on is handled for you (5.6+, iOS/Mac Catalyst/macOS/Android): Do not re-implement it on those platforms. Neither OS reports the resulting drop per peripheral, so Shiny watches the adapter and, on power-down, runs the full disconnect teardown on every connected peripheral — WhenStatusChanged() emits Disconnected (agreeing with IPeripheral.Status, which reads the platform live), notifiers are cleared, in-flight operations fault with BleException, and on Android the GATT client is closed and service discovery re-armed. On power-up, every peripheral connected with AutoConnect: true is reconnected. Never write a Connect() call in IBleDelegate.OnAdapterStateChanged(AccessState.Available) for an AutoConnect: true peripheral — that is the pre-5.6 workaround and it now races Shiny's own reconnect. A Connect() issued while the adapter is off is parked and replayed when it returns rather than silently no-oping, so an explicit connect from that handler is safe but redundant. If you own reconnecting (AutoConnect = false), gate your WhenDisconnected() handler on the adapter being available, since you will now get a Disconnected on power-down. Starting a Scan() while a peripheral is waiting to reconnect is safe - the scan's cache prune skips peripherals with an armed auto-reconnect or a parked connect. Windows needs none of this (its ConnectionStatusChanged fires on a radio power-down by itself); on Linux (BlueZ) and Blazor the adapter cycle is not tracked, so there you still handle it yourself.
Never build your own "wait for the adapter, then scan" wrapper on Apple (5.6+): Scan() parks itself. A CBCentralManager reports Unknown for a moment after construction — and IBleManager builds it lazily, so on a cold start that moment is the Scan() call — and CoreBluetooth silently discards any scan issued below PoweredOn. Shiny holds the request and issues it the instant the central powers on, and re-issues it after an adapter power cycle. Do not write RequestAccess().Where(x => x == AccessState.Available).SelectMany(_ => Scan()), do not retry Scan() on a timer, and do not call Scan() from IBleDelegate.OnAdapterStateChanged — all three now race Shiny's own replay and will throw There is already an existing scan. Applies to iOS, tvOS, Mac Catalyst and macOS; Android and Windows never had the problem.
Some platforms support L2CAP Connection-Oriented Channels for streaming data without going through GATT. This is exposed as an optional capability — ICanL2Cap — on the platform Peripheral types.
using Shiny.BluetoothLE;
if (peripheral.IsL2CapAvailable())
{
// Backend supports L2CAP
}
// Safe variant — returns an empty observable on unsupported platforms
peripheral
.TryOpenL2CapChannel(psm: 0x0083, secure: false)
.Subscribe(channel => { /* ... */ });
// Direct access when the cast succeeds
if (peripheral is ICanL2Cap l2cap)
{
l2cap.OpenL2CapChannel(psm: 0x0083, secure: false).Subscribe(channel =>
{
// channel.Psm — the PSM the channel was opened on
// channel.Identifier — the remote peer identifier
// channel.DataReceived — IObservable<byte[]> of incoming bytes
// channel.Write(bytes) — IObservable<Unit> that completes when bytes are queued
});
}
L2CapChannel implements IDisposable — dispose it to close the underlying streams (Apple) or socket (Android).
using System.Reactive.Threading.Tasks;
channel.DataReceived.Subscribe(
payload => Console.WriteLine($"<- {payload.Length} bytes"),
ex => Console.WriteLine($"Channel error: {ex.Message}"),
() => Console.WriteLine("Remote closed the channel")
);
await channel.Write(payload).ToTask();
DataReceived is hot, emits right-sized byte arrays per read, completes on remote close, and surfaces I/O errors via OnError.
CBPeripheral.OpenL2CapChannel. The secure flag is ignored — security is set by how the peripheral published the channel.BluetoothDevice.CreateL2capChannel / CreateInsecureL2capChannel. Requires API 29+. Throws InvalidOperationException on older versions.IsL2CapAvailable() returns false).Prefer these over hand-rolling a protocol on DataReceived/Write. The peripheral must be serving
with IBleHostingManager.OpenL2CapFileServer(...) (or its own ReadFileRequest loop) — see the
shiny-ble-hosting skill.
The one-liners on IPeripheral open a channel, run the transfer, and close it again:
using Shiny.BluetoothLE;
var result = await peripheral.UploadFile(
psm: 0x0083,
localFilePath: "/path/to/file.bin",
remoteFileName: "file.bin", // optional, defaults to the local file name
secure: false,
onProgress: p => Console.WriteLine(
$"{p.PercentComplete:P0} ({p.BytesTransferred}/{p.BytesToTransfer}) " +
$"{p.BytesPerSecond / 1024} KB/s, ETA {p.EstimatedTimeRemaining}"
),
cancellationToken: ct
);
// result.BytesTransferred / result.Elapsed / result.BytesPerSecond (average for the whole transfer)
await peripheral.DownloadFile(
psm: 0x0083,
remoteFileName: "firmware.bin",
localFilePath: "/local/firmware.bin",
onProgress: p => Console.WriteLine($"{p.PercentComplete:P0}")
);
Rx flavours emit progress and complete when the transfer finishes — disposing the subscription cancels it:
peripheral
.DownloadFileWithProgress(0x0083, "firmware.bin", "/local/firmware.bin")
.Subscribe(p => this.Percent = p.PercentComplete);
To move several files over one channel, open it yourself and use the L2CapChannel extensions:
using var channel = await peripheral.OpenL2CapChannelAsync(psm: 0x0083, secure: false);
await channel.UploadFile("/path/a.bin", onProgress: OnProgress);
await channel.DownloadFile("b.bin", "/local/b.bin", onProgress: OnProgress);
Tuning is via L2CapTransferOptions (BufferSize, ProgressInterval, IdleTimeout).
Progress metrics are TransferProgress — identical in shape to Shiny.Net.Http.TransferProgress:
PercentComplete, BytesPerSecond, BytesTransferred, BytesToTransfer, EstimatedTimeRemaining,
IsDeterministic. Because the peer agrees the exact byte count up front, percent complete and ETA are
always real (never -1) on both ends. Emissions fire on ProgressInterval (default 2s) plus a final
100% emission carrying the average throughput.
Failures: a refusal from the peer surfaces as L2CapTransferException with an Error code
(NotFound, NotPermitted, TooLarge, IoError, ProtocolError, Cancelled). Refusals leave the
channel usable for the next request; a transfer that dies mid-body does not — close the channel and
open a new one. A failed download never leaves a partial local file behind.
Raw streaming: channel.SendFile(...) is the protocol-less primitive — it just pushes bytes with
progress and no handshake, so the receiver must already know the length and framing. Use UploadFile
unless you are talking to a non-Shiny peer.
Stream overload exists for non-file sources. Pass totalBytes to enable percent / ETA; pass null and IsDeterministic will be false, PercentComplete returns -1, EstimatedTimeRemaining returns TimeSpan.Zero.IPeripheral: Both Shiny.BluetoothLE and Shiny.BluetoothLE.Hosting define an IPeripheral interface. If both packages are referenced, do NOT add Shiny.BluetoothLE.Hosting as a global using. Use file-level using or FQN (Shiny.BluetoothLE.IPeripheral) to disambiguate.DeviceInfo: Shiny.BluetoothLE has a DeviceInfo class that conflicts with Microsoft.Maui.Devices.DeviceInfo in MAUI apps. Use FQN when needed.ScanConfig with ServiceUuids to filter scans, especially on iOS where background scanning requires a service UUID filter.AndroidScanConfig for scan mode and batching options.AndroidConnectionConfig for connection priority settings.CharacteristicProperties before attempting read/write/notify operations using the convenience extensions (CanRead(), CanWrite(), CanNotify(), etc.).