Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

BluetoothLE Hosting Releases

FixiOS
IGattCharacteristic.Notify could hang forever, or silently drop the value, when the transmit queue was full. UpdateValue returns false when CoreBluetooth’s queue is full, and Notify only attached its ReadyToUpdateSubscribers handler after that call returned - so a ready callback that arrived in between was missed and the returned task never completed. When the callback was caught, the retry ignored UpdateValue’s result, so a queue that had filled again dropped the notification while Notify still reported success. The handler is now attached before every attempt, and Notify retries on each ready callback until CoreBluetooth accepts the value - the task completes only once the notification is actually queued. Applies to iOS, Mac Catalyst and macOS.
Enhancement
IGattCharacteristic.Notify(data, cancellationToken, params centrals) - a new overload that stops waiting when the token is cancelled. It matters on iOS, Mac Catalyst and macOS, where Notify waits while CoreBluetooth’s transmit queue is full; that wait now also ends with an InvalidOperationException if Bluetooth stops being powered on, since a powered-off manager never drains the queue and the task would otherwise never complete. Generated [BleService] classes get a matching NotifyX(data, cancellationToken, params centrals), and the reply a [RequestResponseCharacteristic] sends now passes BleHostToken, so tearing the service down abandons it. The existing Notify(data, params centrals) is unchanged.
FixiOS
Notify(data, centrals) ignored the centrals you named and sent to every subscriber - UpdateValue was always called with a null central list. The named centrals are now passed through, so a targeted notification (including every generated request/response reply) reaches only the central it was meant for. Applies to iOS, Mac Catalyst and macOS.
FixAndroid
Notify(data) with no centrals sent to nobody. The fallback to “every subscriber” was written as centrals.OfType<Peripheral>() ?? SubscribedCentrals, and OfType never returns null, so the empty list was used as-is. An empty list now means every subscriber, as documented.
Fix
SubscribedCentrals stayed empty unless SetNotification was given a subscribe hook, because subscriptions were only tracked when there was a hook to call. The source generator registers class-level [NotifyCharacteristic] and every [RequestResponseCharacteristic] without one, so XSubscribers / HasXSubscribers always read empty and every generated request/response reply was dropped through OnBleResponseDropped. On Android it was worse: the CCCD descriptor was only added when a hook existed, so a central could not subscribe to such a characteristic at all. Subscriptions are now tracked, and the descriptor added, whenever the characteristic can notify or indicate. Applies to iOS, Mac Catalyst, macOS, Android and Windows; Linux already tracked them.
BREAKINGFixAndroid
IPeripheral.Uuid for a connected central was the literal string System.Byte[] - the identifier was built with byte[].ToString() - so every central had the same id and none could be told apart. It is now the same MAC-derived GUID Shiny.BluetoothLE uses for the central role. This is a behaviour change: anything that stored or compared the old value will see a different, per-device identifier.
FixAndroid
Back-to-back notifications were silently dropped. Android allows one notification in flight per central and refuses the next until onNotificationSent arrives, but Notify sent without waiting and ignored the result - so a burst lost everything after the first while every call reported success. Notify now waits for onNotificationSent before sending that central its next value (different centrals are sent in parallel), throws if Android refuses a notification or reports a failed status, and stops waiting when the central disconnects, the GATT server closes, or the token is cancelled. On API 33+ it uses the notifyCharacteristicChanged overload that carries the value rather than writing it onto the shared characteristic first. Indications were never confirmed either: confirm was always false, so a central that enabled indications received plain notifications. It now gets indications.
FeatureLinux
The Linux GATT server is implemented. AddService threw NotSupportedException - the BlueZ plumbing behind it was a TODO - so nothing could be read, written or subscribed to, and Notify always threw “not registered with BlueZ yet”. Shiny now exports the application BlueZ expects - an org.freedesktop.DBus.ObjectManager root with an org.bluez.GattService1 object per service and an org.bluez.GattCharacteristic1 object per characteristic - and registers it with GattManager1. ReadValue and WriteValue reach your SetRead / SetWrite handlers with the requesting central, offset and MTU; WriteRequest.Respond sends its status back as the GATT response immediately, and a write without response (type=command) reports IsReplyNeeded = false. StartNotify / StopNotify drive SubscribedCentrals and your subscribe hook, and Notify sends the value as PropertiesChanged on Value, which BlueZ turns into a notification or indication. BlueZ reads the object tree only once, at registration, so adding or removing a service re-registers the whole application. Two BlueZ limits: it tells an external application only whether notifications are enabled, not which central enabled them, so while any central is subscribed every connected central is reported as subscribed; and it sends each value to every subscribed central, so Notify(data, centrals) cannot narrow the recipients - a targeted notification is skipped when none of the named centrals is subscribed and otherwise reaches every subscriber, [RequestResponseCharacteristic] replies included.
FeatureLinux
BlueZ advertising is implemented. StartAdvertising and AdvertiseBeacon both threw NotSupportedException on Linux - the D-Bus plumbing behind them was a TODO. BlueZ inverts the model the other platforms use: rather than handing a payload to an API, the application exports an org.bluez.LEAdvertisement1 object and registers its path with LEAdvertisingManager1, and BlueZ then calls back into the process to read the properties. Shiny now exports that object - answering Properties.GetAll/Get, and Introspect so busctl can read it back - and registers it, mapping AdvertisementOptions onto BlueZ’s Type, LocalName, ServiceUUIDs, ServiceData, ManufacturerData and Includes. Two details that make it behave rather than merely work: Discoverable is omitted entirely on a broadcast-type advertisement, which BlueZ rejects the whole registration over, and every property that has no value is omitted rather than sent empty, which it also rejects. BlueZ’s Release() is honoured, so an adapter power-down, a bluetoothd restart, or another client taking the last advertising slot clears IsAdvertising instead of leaving it stuck on. Each registration takes a fresh object path, so a Stop immediately followed by a Start cannot collide with the previous registration BlueZ has not finished dropping. Note the consequence of the callback model: the process must stay alive and on the system bus for the advertisement to keep running.
BREAKINGFix
AdvertiseBeacon broadcast a byte-swapped UUID, major and minor - no receiver could match the beacon. Every multi-byte field in an iBeacon payload is big-endian, but the packet was assembled from Guid.ToByteArray() and BitConverter.GetBytes(), both of which are little-endian on every platform Shiny targets. The UUID’s first three fields and both the major and minor therefore went out reversed. Android additionally prefixed 0xBE 0xAC - AltBeacon’s identifier - where iBeacon requires 0x02 0x15, so the payload was not an iBeacon at all, and Mac Catalyst/macOS wrote the same reversed bytes into kCBAdvDataAppleBeaconKey. All three now build the packet through IBeaconPacket, which is big-endian throughout and covered by round-trip tests against a known-good advertisement. This is a behaviour change: a receiver that was matching the previously-broadcast (reversed) UUID will stop matching, because the device now advertises the UUID you actually asked for.
FeatureWindows
Windows can broadcast iBeacon. AdvertiseBeacon threw NotSupportedException on Windows, but WinRT has no problem with it - an iBeacon is a manufacturer data section, and BluetoothLEAdvertisementPublisher takes those. It now works the same as everywhere else.
Feature
AdvertisementOptions can carry a real payload. New ServiceData, ManufacturerData, IsConnectable and IncludeTxPower properties, so a peripheral can advertise arbitrary data rather than only a local name and service UUIDs - which is what beacon formats, and most proprietary discovery schemes, actually need. Supported on Android, Windows and Linux. Apple’s startAdvertising reads only the local name and service UUID keys and silently discards anything else in the dictionary, so setting either payload property throws there with a message naming the limit, rather than advertising something that never goes out.
Chore
AdvertisementServiceData and ManufacturerData moved from Shiny.BluetoothLE to Shiny.BluetoothLE.Common, so the peripheral role can describe the payloads it advertises without depending on the central role. Same namespace, so nothing recompiles, and type forwards keep already-compiled assemblies resolving.
Feature
IBeaconPacket (in Shiny.BluetoothLE.Common, so both roles can reach it) reads and writes the 23-byte iBeacon manufacturer payload - Build(uuid, major, minor, txPower), Read(span) and IsIBeacon(companyId, span). Use it rather than hand-rolling the bytes; the endianness trap above is easy to fall into and silent when you do.
FixiOS
AddService(...) and StartAdvertising(...) hung when called before the peripheral manager powered on. A CBPeripheralManager reports Unknown until its first state callback lands, and CoreBluetooth drops addService / startAdvertising issued below PoweredOn without ever invoking the completion delegate. Both methods await exactly that delegate, so a call made at startup - registering [BleService] classes from a hosting builder, say - never completed and never faulted. Both now wait out the power-on handshake first and throw when the adapter turns out to be unusable, instead of awaiting a callback the OS will not send. This does not prompt for anything; you still call RequestAccess() yourself to surface permission state to the user. Applies to iOS, Mac Catalyst and macOS. Regression introduced in 5.0.
Fix
IPeripheral.Mtu and BleServiceContext.Mtu now mean the same thing on every platform. Both were documented as the “negotiated MTU” while the value returned differed by platform - Apple gave the usable ATT payload (MaximumUpdateValueLength), Android, Windows, and Linux gave the raw ATT MTU. Sizing a notification against it was therefore correct on Apple and 3 bytes over the link limit everywhere else, which some stacks silently truncate rather than reject. The property is now the usable payload - the negotiated ATT MTU minus the 3-byte ATT header - on all platforms, matching Shiny.BluetoothLE’s IPeripheral.Mtu. Cap notification and read payloads at Mtu directly; do not subtract the header again. Linux hosting’s pre-negotiation default drops from 23 to 20 for the same reason.
BREAKINGFixAndroid
IPeripheral.Mtu was assigned the raw onMtuChanged value with no header subtraction, so it started life as a payload (20) and became an ATT MTU after the central negotiated - two different units on one property. It now subtracts BleConstants.AttHeaderSize consistently. If you were compensating with context.Mtu - 3, remove the subtraction; if you were using Mtu as-is you were overshooting by 3 and are now correct.
BREAKINGFixWindows
IPeripheral.Mtu returned GattSession.MaxPduSize, which is the ATT MTU. It now subtracts the 3-byte ATT header. Same migration note as Android.
Feature
Source generator for GATT services and L2CAP listeners. Put [BleService] / [L2CapService] on a partial class and the generator emits the AddService(...) / OpenL2Cap(...) calls, the GattResult wrapping, the IsReplyNeeded/Respond and offset handling, the notify push API (NotifyX / XSubscribers / HasXSubscribers), and AddBleHostedServices() / AttachBleHostedServices(sp) / StartBleHostedAdvertising(name) extension methods on IBleHostingManager. Handler parameters bind by type in any order and any subset — ReadRequest/WriteRequest, the raw byte[], the offset, IPeripheral, a CancellationToken, and the generated context all just work. Nothing reflective is emitted, so it replaces the managed pattern removed in 5.0.0 without giving up AOT-cleanliness. The generator ships inside Shiny.BluetoothLE.Hosting under analyzers/dotnet/cs — nothing extra to install. See the new Source Generator page.
Feature
Per-connected-central context. Each [BleService] class gets a generated {ServiceClass}Context — a partial class you add your own properties to — created lazily per central and passed to any handler that declares it as a parameter. It carries Peripheral, ConnectionId, Mtu, ServiceUuid, Service, and a loosely typed Items bag, in the spirit of SignalR’s Hub.Context. Storage is a ConditionalWeakTable keyed on the peripheral rather than the public IPeripheral.Context slot, so it never collides with app state.
Feature
[RequestResponseCharacteristic] registers a characteristic as Write | Notify and pushes the handler’s returned bytes back to the central that wrote — a GATT write response cannot carry a payload, so the reply travels as a notification addressed to that central. Implement the generated OnBleResponseDropped hook to observe the case where the central was not subscribed.
Feature
[L2CapService(PsmService = ..., PsmCharacteristic = ...)] publishes the platform-assigned PSM as a GATT read characteristic (two little-endian bytes) on a service in the same compilation, which is the only in-band way a central can learn it. Listeners are opened before AddService, so a read immediately after registration returns a live value. L2CapChannel.ReadAll(cancellationToken) is a new IAsyncEnumerable convenience over the Rx DataReceived observable.
Enhancement
Several [BleService] classes may declare the same service UUID — the generator merges them into a single AddService call, which matters because BleHostingManager keys its services by UUID and would throw on a second registration. Declaring the same characteristic UUID in two merged classes is a compile error.
Enhancement
Fourteen compile-time diagnostics (SBH001-SBH014) cover invalid UUIDs, duplicate handlers for one characteristic, unbindable signatures, dangling PSM publications, and merge conflicts — mistakes that used to surface as a silent no-op or a runtime throw on device.
Enhancement
Generated UUIDs are always emitted in the full 128-bit form. Short forms like "180D" are accepted by Apple’s CBUUID.FromString but throw on Android, which goes through java.util.UUID.fromString — so a service that worked on iOS could fail on Android. This only applies to generated code; write full UUIDs yourself when calling AddService directly.
Feature
L2CAP file server. IBleHostingManager.OpenL2CapFileServer(rootDirectory, secure, configure) publishes a PSM backed by a directory that connected centrals can push files to and pull files from, using the matching UploadFile / DownloadFile helpers in Shiny.BluetoothLE. Configure it with upload/download toggles, MaxUploadSize (refused with TooLarge before a single body byte is read), an overwrite policy, an Authorize hook per request, and OnProgress / OnCompleted / OnError callbacks carrying the peer identifier, file name, and transfer metrics. Peer-supplied names are resolved under the root - absolute paths and ../ traversal are refused before any filesystem access.
Feature
IBleHostingManager.HandleL2CapRequests(secure, onRequest, options, onError) publishes a PSM and hands every inbound L2CapFileRequest to your own handler - for serving from a database, generating content on the fly, or any shape the directory server doesn’t cover. Answer with AcceptUpload / AcceptDownload / Reject; a request your handler leaves unanswered is auto-rejected so the peer is never left hanging.
Fix
L2CapChannelExtensions.SendFile no longer risks sending corrupt bytes on slower links. The reusable read buffer was handed straight to Write, which only promises the bytes are queued - so the next chunk could overwrite bytes still in flight. Each write now gets its own array.
BREAKING
Managed characteristic pattern removed for AOT compliance. The BleGattCharacteristic base class, [BleGattCharacteristic] attribute, AddBleHostedCharacteristic<T>(), AttachRegisteredServices(), and DetachRegisteredServices() are all gone. Compose GATT services in code via IBleHostingManager.AddService(uuid, primary, sb => ...) — typically inside a class registered as an IShinyStartupTask. See the GATT Service page for the new pattern.
Enhancement
Rx removed from Shiny.BluetoothLE.Hosting’s manager surface. IBleHostingManager no longer exposes any IObservable<T> members — characteristic write/read/notification hooks are async Task-based as before. The L2CapChannel record still uses Rx for its bytes-in/bytes-out streams (shared with Shiny.BluetoothLE client).
Feature
L2CapChannelExtensions.SendFile(...) — new file-transfer helper on top of an open L2CapChannel with HTTP-transfer-style progress metrics (bytes-per-second, percent-complete, estimated time remaining). Useful for streaming firmware blobs and other large payloads to a connected central. Overloads accept either a file path (length auto-detected) or an arbitrary Stream with an optional totalBytes. Progress callbacks fire ~every 2s plus a final 100% emission on completion. The supporting Shiny.BluetoothLE.TransferProgress record mirrors Shiny.Net.Http.TransferProgress. Lives in Shiny.BluetoothLE.Common, shared with the central library.
FixAndroid
BleHostingManager now takes AndroidPlatform through a primary constructor and eagerly initializes its GattServerContext. Previously the context field was declared readonly but never assigned, so every call into the Android hosting manager (advertising, GATT services, request-access, beacon) would have NREd at runtime once the DI container resolved the manager via its compiler-generated parameterless constructor.
Feature
L2CAP CoC peripheral hosting shipped — IBleHostingManager.OpenL2Cap(bool secure, Action<L2CapChannel> onOpen) publishes a PSM and invokes the callback for every accepted central connection. Each L2CapChannel exposes Func<byte[], IObservable<Unit>> Write and IObservable<byte[]> DataReceived, and disposes cleanly via the channel’s IDisposable. Dispose the returned L2CapInstance to stop accepting and release the PSM. Implemented on iOS, Mac Catalyst, macOS (CoreBluetooth CBPeripheralManager.PublishL2CapChannel), Android API 29+ (BluetoothAdapter.ListenUsing[Insecure]L2capChannel), and Linux (BlueZ — raw AF_BLUETOOTH socket with kernel-assigned dynamic PSM ≥ 0x80 and a background accept loop). Windows hosting throws NotSupportedException from OpenL2Cap — WinRT exposes no LE CoC surface.
Enhancement
The public L2CapChannel record moved into Shiny.BluetoothLE.Common (namespace Shiny.BluetoothLE) so both hosting and central libraries share a single type. It now also implements IDisposable with an optional OnDispose hook for platform cleanup (closing streams, releasing sockets).
Feature
macOS support added - peripheral / GATT server hosting via CoreBluetooth
Feature
Linux support added via BlueZ / D-Bus - new Shiny.BluetoothLE.Hosting.Linux package
Feature
Windows support added (No Background Support at this time)
Enhancement
BLE Hosting manager now allows you to check current permissions without requesting
FixAndroid
Managed BLE Services won’t always auto-restart post reboot
EnhancementAndroid
RequestAccess now exists - you can specifically target your permissions to take advantage of Android API 31
Enhancement
All characteristic hooks are now async
Enhancement
New “managed” model for characteristics
Enhancement
Advertise iBeacons is now supported - it exists here instead of Shiny.Beacons because all of the advertising code is here